Skip to content
  •  
  •  
  •  
108 changes: 108 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion build/cherry-pick.js
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#!/usr/bin/env node

const { execSync } = require('child_process');
const conventionalCommitsParser = require('conventional-commits-parser');
const chalk = require('chalk');
Expand Down Expand Up @@ -129,7 +131,7 @@ commitsToCherryPick.forEach(({ hash, type, scope, subject }) => {

try {
execSync(`git cherry-pick ${hash} -X theirs`);
} catch (e) {
} catch {
console.error(
chalk.red.bold('\nAborting cherry-pick and reseting to master')
);
Expand Down
2 changes: 1 addition & 1 deletion build/tasks/metadata-function-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module.exports = function (grunt) {
'// This file is automatically generated using build/tasks/metadata-function-map.js\n';

src.forEach(globPath => {
glob.sync(globPath).forEach(filePath => {
glob.sync(globPath, { posix: true }).forEach(filePath => {
const relativePath = path.relative(
path.dirname(file.dest),
filePath
Expand Down
12 changes: 9 additions & 3 deletions build/tasks/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function fileExists(v, o) {
var exists;
try {
exists = fs.existsSync(file);
} catch (e) {
} catch {
return false;
}
return exists;
Expand Down Expand Up @@ -361,6 +361,12 @@ const standardsTags = [
standardRegex: /^EN-301-549$/,
criterionRegex: /^EN-9\.[1-4]\.[1-9]\.\d{1,2}$/,
wcagLevelRegex: /^wcag21?aa?$/
},
{
name: 'RGAA',
standardRegex: /^RGAAv4$/,
criterionRegex: /^RGAA-\d{1,2}\.\d{1,2}\.\d{1,2}$/,
wcagLevelRegex: /^wcag21?aa?$/
}
];

Expand Down Expand Up @@ -411,7 +417,7 @@ function findTagIssues(tags) {
standardTag: standardTags[0] ?? null,
criterionTags
};
if (bestPracticeTags.length !== 0) {
if (name !== 'RGAA' && bestPracticeTags.length !== 0) {
issues.push(`${name} tags cannot be used along side best-practice tag`);
}
if (standardTags.length === 0) {
Expand All @@ -423,7 +429,7 @@ function findTagIssues(tags) {
issues.push(`Expected at least one ${name} criterion tag, got 0`);
}

if (wcagLevelRegex) {
if (wcagLevelRegex && standards.WCAG) {
const wcagLevel = standards.WCAG.standardTag;
if (!wcagLevel.match(wcagLevelRegex)) {
issues.push(`${name} rules not allowed on ${wcagLevel}`);
Expand Down
4 changes: 2 additions & 2 deletions doc/examples/qunit/test/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
<!-- Load local QUnit. -->
<link
rel="stylesheet"
href="../node_modules/qunitjs/qunit/qunit.css"
href="../node_modules/qunit/qunit/qunit.css"
media="screen"
/>
<script src="../node_modules/qunitjs/qunit/qunit.js"></script>
<script src="../node_modules/qunit/qunit/qunit.js"></script>
<!-- Load local lib and tests. -->
<script src="../node_modules/axe-core/axe.min.js"></script>
<script src="a11y.js"></script>
Expand Down
230 changes: 116 additions & 114 deletions doc/rule-descriptions.md

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion lib/checks/aria/aria-allowed-attr-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export default function ariaAllowedAttrEvaluate(node, options, virtualNode) {

// Unknown ARIA attributes are tested in aria-valid-attr
for (const attrName of virtualNode.attrNames) {
if (validateAttr(attrName) && !allowed.includes(attrName)) {
if (
validateAttr(attrName) &&
!allowed.includes(attrName) &&
!ignoredAttrs(attrName, virtualNode.attr(attrName), virtualNode)
) {
invalid.push(attrName);
}
}
Expand All @@ -57,3 +61,23 @@ export default function ariaAllowedAttrEvaluate(node, options, virtualNode) {
}
return false;
}

function ignoredAttrs(attrName, attrValue, vNode) {
// allow aria-required=false as screen readers consistently ignore it
// @see https://github.com/dequelabs/axe-core/issues/3756
if (attrName === 'aria-required' && attrValue === 'false') {
return true;
}

// allow aria-multiline=false when contenteditable is set
// @see https://github.com/dequelabs/axe-core/issues/4463
if (
attrName === 'aria-multiline' &&
attrValue === 'false' &&
vNode.hasAttr('contenteditable')
) {
return true;
}

return false;
}
8 changes: 4 additions & 4 deletions lib/checks/aria/aria-errormessage-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import standards from '../../standards';
import { idrefs } from '../../commons/dom';
import { idrefs, isVisibleToScreenReaders } from '../../commons/dom';
import { tokenList } from '../../core/utils';
import { isVisibleToScreenReaders } from '../../commons/dom';
import { getExplicitRole } from '../../commons/aria';
/**
* Check if `aria-errormessage` references an element that also uses a technique to announce the message (aria-live, aria-describedby, etc.).
*
Expand Down Expand Up @@ -46,7 +46,7 @@ export default function ariaErrormessageEvaluate(node, options, virtualNode) {

try {
idref = attr && idrefs(virtualNode, 'aria-errormessage')[0];
} catch (e) {
} catch {
this.data({
messageKey: 'idrefs',
values: tokenList(attr)
Expand All @@ -63,7 +63,7 @@ export default function ariaErrormessageEvaluate(node, options, virtualNode) {
return false;
}
return (
idref.getAttribute('role') === 'alert' ||
getExplicitRole(idref) === 'alert' ||
idref.getAttribute('aria-live') === 'assertive' ||
idref.getAttribute('aria-live') === 'polite' ||
tokenList(virtualNode.attr('aria-describedby')).indexOf(attr) > -1
Expand Down
6 changes: 3 additions & 3 deletions lib/checks/aria/aria-errormessage.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
"hidden": "aria-errormessage value `${data.values}` cannot reference a hidden element"
},
"incomplete": {
"singular": "ensure aria-errormessage value `${data.values}` references an existing element",
"plural": "ensure aria-errormessage values `${data.values}` reference existing elements",
"idrefs": "unable to determine if aria-errormessage element exists on the page: ${data.values}"
"singular": "Ensure aria-errormessage value `${data.values}` references an existing element",
"plural": "Ensure aria-errormessage values `${data.values}` reference existing elements",
"idrefs": "Unable to determine if aria-errormessage element exists on the page: ${data.values}"
}
}
}
Expand Down
35 changes: 30 additions & 5 deletions lib/checks/aria/aria-prohibited-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getRole } from '../../commons/aria';
import { getRole, getRoleType } from '../../commons/aria';
import { sanitize, subtreeText } from '../../commons/text';
import standards from '../../standards';
import memoize from '../../core/utils/memoize';

/**
* Check that an element does not use any prohibited ARIA attributes.
Expand Down Expand Up @@ -33,9 +34,14 @@ export default function ariaProhibitedAttrEvaluate(
) {
const elementsAllowedAriaLabel = options?.elementsAllowedAriaLabel || [];
const { nodeName } = virtualNode.props;
const role = getRole(virtualNode, { chromium: true });
const role = getRole(virtualNode, {
chromium: true,
// this check allows fallback roles. For example, `<div role="foo img" aria-label="...">` is legal.
fallback: true
});

const prohibitedList = listProhibitedAttrs(
virtualNode,
role,
nodeName,
elementsAllowedAriaLabel
Expand All @@ -51,7 +57,7 @@ export default function ariaProhibitedAttrEvaluate(
return false;
}

let messageKey = virtualNode.hasAttr('role') ? 'hasRole' : 'noRole';
let messageKey = role !== null ? 'hasRole' : 'noRole';
messageKey += prohibited.length > 1 ? 'Plural' : 'Singular';
this.data({ role, nodeName, messageKey, prohibited });

Expand All @@ -64,13 +70,32 @@ export default function ariaProhibitedAttrEvaluate(
return true;
}

function listProhibitedAttrs(role, nodeName, elementsAllowedAriaLabel) {
function listProhibitedAttrs(vNode, role, nodeName, elementsAllowedAriaLabel) {
const roleSpec = standards.ariaRoles[role];
if (roleSpec) {
return roleSpec.prohibitedAttrs || [];
}
if (!!role || elementsAllowedAriaLabel.includes(nodeName)) {
if (
!!role ||
elementsAllowedAriaLabel.includes(nodeName) ||
getClosestAncestorRoleType(vNode) === 'widget'
) {
return [];
}
return ['aria-label', 'aria-labelledby'];
}

const getClosestAncestorRoleType = memoize(
function getClosestAncestorRoleTypeMemoized(vNode) {
if (!vNode) {
return;
}

const role = getRole(vNode, { noPresentational: true, chromium: true });
if (role) {
return getRoleType(role);
}

return getClosestAncestorRoleType(vNode.parent);
}
);
5 changes: 5 additions & 0 deletions lib/checks/aria/aria-required-attr-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export default function ariaRequiredAttrEvaluate(
) {
return true;
}
// Non-normative exception for things like media player seek slider.
// Tested to work in various screen readers.
if (role === 'slider' && virtualNode.attr('aria-valuetext')?.trim()) {
return true;
}

const elmSpec = getElementSpec(virtualNode);
const missingAttrs = requiredAttrs.filter(
Expand Down
4 changes: 2 additions & 2 deletions lib/checks/aria/aria-required-parent-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ function getMissingContext(
}

function getAriaOwners(element) {
var owners = [],
o = null;
const owners = [];
let o = null;

while (element) {
if (element.getAttribute('id')) {
Expand Down
17 changes: 14 additions & 3 deletions lib/checks/aria/aria-valid-attr-value-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ export default function ariaValidAttrValueEvaluate(node, options, virtualNode) {

const preChecks = {
// aria-controls should only check if element exists if the element
// doesn't have aria-expanded=false or aria-selected=false (tabs)
// doesn't have aria-expanded=false, aria-selected=false (tabs),
// or aria-haspopup (may load later)
// @see https://github.com/dequelabs/axe-core/issues/1463
// @see https://github.com/dequelabs/axe-core/issues/4363
'aria-controls': () => {
const hasPopup =
['false', null].includes(virtualNode.attr('aria-haspopup')) === false;

if (hasPopup) {
needsReview = `aria-controls="${virtualNode.attr('aria-controls')}"`;
messageKey = 'controlsWithinPopup';
}

return (
virtualNode.attr('aria-expanded') !== 'false' &&
virtualNode.attr('aria-selected') !== 'false'
virtualNode.attr('aria-selected') !== 'false' &&
hasPopup === false
);
},
// aria-current should mark as needs review if any value is used that is
Expand Down Expand Up @@ -104,7 +115,7 @@ export default function ariaValidAttrValueEvaluate(node, options, virtualNode) {

try {
validValue = validateAttrValue(virtualNode, attrName);
} catch (e) {
} catch {
needsReview = `${attrName}="${attrValue}"`;
messageKey = 'idrefs';
return;
Expand Down
3 changes: 2 additions & 1 deletion lib/checks/aria/aria-valid-attr-value.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"noIdShadow": "ARIA attribute element ID does not exist on the page or is a descendant of a different shadow DOM tree: ${data.needsReview}",
"ariaCurrent": "ARIA attribute value is invalid and will be treated as \"aria-current=true\": ${data.needsReview}",
"idrefs": "Unable to determine if ARIA attribute element ID exists on the page: ${data.needsReview}",
"empty": "ARIA attribute value is ignored while empty: ${data.needsReview}"
"empty": "ARIA attribute value is ignored while empty: ${data.needsReview}",
"controlsWithinPopup": "Unable to determine if aria-controls referenced ID exists on the page while using aria-haspopup: ${data.needsReview}"
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/checks/aria/has-widget-role-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getRoleType } from '../../commons/aria';
import { getRoleType, getExplicitRole } from '../../commons/aria';

/**
* Check if an elements `role` attribute uses any widget or composite role values.
Expand All @@ -8,8 +8,8 @@ import { getRoleType } from '../../commons/aria';
* @memberof checks
* @return {Boolean} True if the element uses a `widget` or `composite` role. False otherwise.
*/
function hasWidgetRoleEvaluate(node) {
const role = node.getAttribute('role');
function hasWidgetRoleEvaluate(node, options, virtualNode) {
const role = getExplicitRole(virtualNode);
if (role === null) {
return false;
}
Expand Down
2 changes: 1 addition & 1 deletion lib/checks/aria/invalidrole-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { tokenList } from '../../core/utils';
function invalidroleEvaluate(node, options, virtualNode) {
const allRoles = tokenList(virtualNode.attr('role'));
const allInvalid = allRoles.every(
role => !isValidRole(role, { allowAbstract: true })
role => !isValidRole(role.toLowerCase(), { allowAbstract: true })
);

/**
Expand Down
2 changes: 1 addition & 1 deletion lib/checks/aria/no-implicit-explicit-label-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function noImplicitExplicitLabelEvaluate(node, options, virtualNode) {
try {
label = sanitize(labelText(virtualNode)).toLowerCase();
accText = sanitize(accessibleTextVirtual(virtualNode)).toLowerCase();
} catch (e) {
} catch {
return undefined;
}

Expand Down
10 changes: 8 additions & 2 deletions lib/checks/aria/valid-scrollable-semantics-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@ const VALID_TAG_NAMES_FOR_SCROLLABLE_REGIONS = {
* appropriate for scrollable elements found in the focus order.
*/
const VALID_ROLES_FOR_SCROLLABLE_REGIONS = {
alert: true,
alertdialog: true,
application: true,
article: true,
banner: false,
complementary: true,
contentinfo: true,
dialog: true,
form: true,
log: true,
main: true,
navigation: true,
region: true,
search: false
search: false,
status: true,
tabpanel: true
};

/**
Expand All @@ -46,7 +52,7 @@ function validScrollableTagName(node) {
* region.
*/
function validScrollableRole(node, options) {
var role = getExplicitRole(node);
const role = getExplicitRole(node);
if (!role) {
return false;
}
Expand Down
Loading
Loading