Skip to content

Commit

Permalink
fix(aria-allowed-attr): no inconsistent aria-checked on HTML checkbox…
Browse files Browse the repository at this point in the history
…es (#3895)

* feat(aria-allowed-attr): no inconsistent aria-checked on HTML checkboxes

* Add integration tests

* Add non-table row test

* Cleanup / docs work

* Handle checkbox indeterminate state

* Fix virtual rule test

* Apply suggestions from code review

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>

* Fix broken test

---------

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
WilcoFiers and straker authored May 2, 2023
1 parent 0f405c6 commit 704043e
Show file tree
Hide file tree
Showing 18 changed files with 498 additions and 173 deletions.
6 changes: 5 additions & 1 deletion doc/check-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ All checks allow these global options:

### aria-allowed-attr

Previously supported properties `validTreeRowAttrs` is no longer available. `invalidTableRowAttrs` from [aria-conditional-attr](#aria-conditional-attr) instead.

### aria-conditional-attr

<table>
<thead>
<tr>
Expand All @@ -218,7 +222,7 @@ All checks allow these global options:
<tbody>
<tr>
<td>
<code>validTreeRowAttrs</code>
<code>invalidTableRowAttrs</code>
</td>
<td align="left">
<pre lang=js><code>[
Expand Down
62 changes: 15 additions & 47 deletions lib/checks/aria/aria-allowed-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { uniqueArray, closest, isHtmlElement } from '../../core/utils';
import { uniqueArray, isHtmlElement } from '../../core/utils';
import { getRole, allowedAttr, validateAttr } from '../../commons/aria';
import { isFocusable } from '../../commons/dom';
import cache from '../../core/base/cache';

/**
* Check if each ARIA attribute on an element is allowed for its semantic role.
Expand Down Expand Up @@ -30,62 +29,31 @@ import cache from '../../core/base/cache';
export default function ariaAllowedAttrEvaluate(node, options, virtualNode) {
const invalid = [];
const role = getRole(virtualNode);
const attrs = virtualNode.attrNames;
let allowed = allowedAttr(role);

// @deprecated: allowed attr options to pass more attrs.
// configure the standards spec instead
if (Array.isArray(options[role])) {
allowed = uniqueArray(options[role].concat(allowed));
}

const tableMap = cache.get('aria-allowed-attr-table', () => new WeakMap());

function validateRowAttrs() {
// check if the parent exists otherwise a TypeError will occur (virtual-nodes specifically)
if (virtualNode.parent && role === 'row') {
const table = closest(
virtualNode,
'table, [role="treegrid"], [role="table"], [role="grid"]'
);

let tableRole = tableMap.get(table);
if (table && !tableRole) {
tableRole = getRole(table);
tableMap.set(table, tableRole);
}
if (['table', 'grid'].includes(tableRole) && role === 'row') {
return true;
}
}
}
// Allows options to be mapped to object e.g. {'aria-level' : validateRowAttrs}
const ariaAttr = Array.isArray(options.validTreeRowAttrs)
? options.validTreeRowAttrs
: [];
const preChecks = {};
ariaAttr.forEach(attr => {
preChecks[attr] = validateRowAttrs;
});
if (allowed) {
for (let i = 0; i < attrs.length; i++) {
const attrName = attrs[i];
if (validateAttr(attrName) && preChecks[attrName]?.()) {
invalid.push(attrName + '="' + virtualNode.attr(attrName) + '"');
} else if (validateAttr(attrName) && !allowed.includes(attrName)) {
invalid.push(attrName + '="' + virtualNode.attr(attrName) + '"');
}
// Unknown ARIA attributes are tested in aria-valid-attr
for (const attrName of virtualNode.attrNames) {
if (validateAttr(attrName) && !allowed.includes(attrName)) {
invalid.push(attrName);
}
}

if (invalid.length) {
this.data(invalid);
if (!invalid.length) {
return true;
}

if (!isHtmlElement(virtualNode) && !role && !isFocusable(virtualNode)) {
return undefined;
}
this.data(
invalid.map(attrName => attrName + '="' + virtualNode.attr(attrName) + '"')
);

return false;
if (!role && !isHtmlElement(virtualNode) && !isFocusable(virtualNode)) {
return undefined;
}

return true;
return false;
}
20 changes: 20 additions & 0 deletions lib/checks/aria/aria-conditional-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import getRole from '../../commons/aria/get-role';
import ariaConditionalCheckboxAttr from './aria-conditional-checkbox-attr-evaluate';
import ariaConditionalRowAttr from './aria-conditional-row-attr-evaluate';

const conditionalRoleMap = {
row: ariaConditionalRowAttr,
checkbox: ariaConditionalCheckboxAttr
};

export default function ariaConditionalAttrEvaluate(
node,
options,
virtualNode
) {
const role = getRole(virtualNode);
if (!conditionalRoleMap[role]) {
return true;
}
return conditionalRoleMap[role].call(this, node, options, virtualNode);
}
23 changes: 23 additions & 0 deletions lib/checks/aria/aria-conditional-attr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"id": "aria-conditional-attr",
"evaluate": "aria-conditional-attr-evaluate",
"options": {
"invalidTableRowAttrs": [
"aria-posinset",
"aria-setsize",
"aria-expanded",
"aria-level"
]
},
"metadata": {
"impact": "serious",
"messages": {
"pass": "ARIA attribute is allowed",
"fail": {
"checkbox": "Remove aria-checked, or set it to \"${data.checkState}\" to match the real checkbox state",
"rowSingular": "This attribute is supported with treegrid rows, but not ${data.ownerRole}: ${data.invalidAttrs}",
"rowPlural": "These attributes are supported with treegrid rows, but not ${data.ownerRole}: ${data.invalidAttrs}"
}
}
}
}
39 changes: 39 additions & 0 deletions lib/checks/aria/aria-conditional-checkbox-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export default function ariaConditionalCheckboxAttr(
node,
options,
virtualNode
) {
const { nodeName, type } = virtualNode.props;
const ariaChecked = normalizeAriaChecked(virtualNode.attr('aria-checked'));
if (nodeName !== 'input' || type !== 'checkbox' || !ariaChecked) {
return true;
}

const checkState = getCheckState(virtualNode);
if (ariaChecked === checkState) {
return true;
}
this.data({
messageKey: 'checkbox',
checkState
});
return false;
}

function getCheckState(vNode) {
if (vNode.props.indeterminate) {
return 'mixed';
}
return vNode.props.checked ? 'true' : 'false';
}

function normalizeAriaChecked(ariaCheckedVal) {
if (!ariaCheckedVal) {
return '';
}
ariaCheckedVal = ariaCheckedVal.toLowerCase();
if (['mixed', 'true'].includes(ariaCheckedVal)) {
return ariaCheckedVal;
}
return 'false';
}
36 changes: 36 additions & 0 deletions lib/checks/aria/aria-conditional-row-attr-evaluate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import getRole from '../../commons/aria/get-role';
import { closest } from '../../core/utils';

export default function ariaConditionalRowAttr(
node,
{ invalidTableRowAttrs } = {},
virtualNode
) {
const invalidAttrs =
invalidTableRowAttrs?.filter?.(invalidAttr => {
return virtualNode.hasAttr(invalidAttr);
}) ?? [];
if (invalidAttrs.length === 0) {
return true;
}

const owner = getRowOwner(virtualNode);
const ownerRole = owner && getRole(owner);
if (!ownerRole || ownerRole === 'treegrid') {
return true;
}

const messageKey = `row${invalidAttrs.length > 1 ? 'Plural' : 'Singular'}`;
this.data({ messageKey, invalidAttrs, ownerRole });
return false;
}

function getRowOwner(virtualNode) {
// check if the parent exists otherwise a TypeError will occur (virtual-nodes specifically)
if (!virtualNode.parent) {
return;
}
const rowOwnerQuery =
'table:not([role]), [role~="treegrid"], [role~="table"], [role~="grid"]';
return closest(virtualNode, rowOwnerQuery);
}
17 changes: 14 additions & 3 deletions lib/core/base/virtual-node/virtual-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,17 @@ class VirtualNode extends AbstractVirtualNode {
// add to the prototype so memory is shared across all virtual nodes
get props() {
if (!this._cache.hasOwnProperty('props')) {
const { nodeType, nodeName, id, multiple, nodeValue, value, selected } =
this.actualNode;
const {
nodeType,
nodeName,
id,
multiple,
nodeValue,
value,
selected,
checked,
indeterminate
} = this.actualNode;

this._cache.props = {
nodeType,
Expand All @@ -67,7 +76,9 @@ class VirtualNode extends AbstractVirtualNode {
multiple,
nodeValue,
value,
selected
selected,
checked,
indeterminate
};
}

Expand Down
4 changes: 2 additions & 2 deletions lib/rules/aria-allowed-attr.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"description": "Ensures ARIA attributes are allowed for an element's role",
"help": "Elements must only use allowed ARIA attributes"
},
"all": [],
"any": ["aria-allowed-attr"],
"all": ["aria-allowed-attr", "aria-conditional-attr"],
"any": [],
"none": ["aria-unsupported-attr", "aria-prohibited-attr"]
}
8 changes: 8 additions & 0 deletions locales/_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,14 @@
"pass": "Element has an aria-busy attribute",
"fail": "Element uses aria-busy=\"true\" while showing a loader"
},
"aria-conditional-attr": {
"pass": "ARIA attribute is allowed",
"fail": {
"checkbox": "Remove aria-checked, or set it to \"${data.checkState}\" to match the real checkbox state",
"rowSingular": "This attribute is supported with treegrid rows, but not ${data.ownerRole}: ${data.invalidAttrs}",
"rowPlural": "These attributes are supported with treegrid rows, but not ${data.ownerRole}: ${data.invalidAttrs}"
}
},
"aria-errormessage": {
"pass": "aria-errormessage exists and references elements visible to screen readers that use a supported aria-errormessage technique",
"fail": {
Expand Down
Loading

0 comments on commit 704043e

Please sign in to comment.