Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(aria-allowed-attr): no inconsistent aria-checked on HTML checkboxes #3895

Merged
merged 9 commits into from
May 2, 2023
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.
straker marked this conversation as resolved.
Show resolved Hide resolved

### 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';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think this cache was useful, so I've left it out on my rewrite. If we ever want to cache getRole, it should be memoized. And unlike this current implementation I'm only looking up the table role once per row, not for every attribute.

/**
* 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) + '"');
}
Comment on lines -72 to -75
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was really weird and messy. Took some time refactoring and cleaning this up. Much easier to grok now. The gist of what this does is that if you're using one of these validTreeRowAttrs on a table or grid, it should fail because those attrs are only allowed on treegrid.

// 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);
Comment on lines +10 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this was over-engineering or not. Having two new checks would be fine, but then if we wanted to add more that becomes kind of bloated. These things are mutually exclusive because they run for different roles.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They certainly should be their own functions, but having them be "evaluate" methods is interesting, especially since they are two evaluate functions without an associated metadata file or test file.

I can see why having them function like evaluate methods is helpful so you can call data() in them, but you could do that using a destructured return and calling data in here. I think I would prefer them as a common function or something rather than their own evaluate files. That way they can't accidentally be used in a rule, and they can be tested in their own files rather than combining the tests into a single file for aria-conditional-attr.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guess I can put them into commons/aria then.

}
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 => {
straker marked this conversation as resolved.
Show resolved Hide resolved
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"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like "none" checks. Always messes with my head that false means pass. Can't undo that on the other checks but I don't want to make it worse.

"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 has no aria-busy=\"true\" attribute"
},
"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