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
65 changes: 19 additions & 46 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,36 @@ 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;
}
for (const attrName of virtualNode.attrNames) {
if (!validateAttr(attrName)) {
continue; // Unknown ARIA attributes are tested in aria-valid-attr
}
}
// 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.

}
if (!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 => {
return attrName + '="' + virtualNode.attr(attrName) + '"';
})
);

return false;
if (!role && !isHtmlElement(virtualNode) && !isFocusable(virtualNode)) {
// TODO: Some message thing
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.

}
24 changes: 24 additions & 0 deletions lib/checks/aria/aria-conditional-attr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"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": "The aria-checked attribute must be removed, or be consistent with the native checked state",
"checkboxMixed": "aria-checked=\"mixed\" cannot be used on an native HTML checkbox",
"rowSingular": "This row attribute is supported with treegrid, but not ${data.ownerRole}: ${data.invalidAttrs}",
"rowPlural": "These row attributes are supported with treegrid, but not ${data.ownerRole}: ${data.invalidAttrs}"
}
}
}
}
30 changes: 30 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,30 @@
export default function ariaConditionalCheckboxAttr(
node,
options,
virtualNode
) {
const { nodeName, type } = virtualNode.props;
const ariaChecked = virtualNode.attr('aria-checked')?.toLowerCase();
if (nodeName !== 'input' || type !== 'checkbox' || ariaChecked === null) {
return true;
}

// Mixed is never allowed on native checkboxes:
if (ariaChecked === 'mixed') {
this.data({ messageKey: 'checkboxMixed' });
return false;
}

// aria-checked has to be consistent with native checked:
if (xor(ariaChecked === 'true', isChecked(virtualNode))) {
this.data({ messageKey: 'checkbox' });
return false;
}

return true;
}

const xor = (a, b) => (a || b) && !(a && b);

const isChecked = vNode =>
vNode.actualNode ? !!vNode.actualNode.checked : vNode.hasAttr('checked');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using actualNode.checked because if you toggle a checkbox it doesn't update the checked attribute. It's not a prop on virtualNode

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 true;
WilcoFiers marked this conversation as resolved.
Show resolved Hide resolved
}
const rowOwnerQuery =
'table:not([role]), [role~="treegrid"], [role~="table"], [role~="grid"]';
return closest(virtualNode, rowOwnerQuery);
}
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"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,124 +137,6 @@ describe('aria-allowed-attr', function () {
assert.isNotNull(checkContext._data);
});

describe('invalid aria-attributes when used on role=row as a descendant of a table or a grid', function () {
[
'aria-posinset="1"',
'aria-setsize="1"',
'aria-expanded="true"',
'aria-level="1"'
].forEach(function (attrName) {
it(
'should return false when ' +
attrName +
' is used on role=row thats parent is a table',
function () {
var vNode = queryFixture(
' <div role="table">' +
'<div id="target" role="row" ' +
attrName +
'></div>' +
'</div>'
);
assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
.call(checkContext, null, null, vNode)
);
assert.isNotNull(checkContext._data);
}
);
});

[
'aria-posinset="1"',
'aria-setsize="1"',
'aria-expanded="true"',
'aria-level="1"'
].forEach(function (attrName) {
it(
'should return false when ' +
attrName +
' is used on role=row thats parent is a grid',
function () {
var vNode = queryFixture(
' <div role="grid">' +
'<div id="target" role="row" ' +
attrName +
'></div>' +
'</div>'
);
assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
.call(checkContext, null, null, vNode)
);
assert.isNotNull(checkContext._data);
}
);
});
});

describe('options.invalidRowAttrs on role=row when a descendant of a table or a grid', function () {
it('should return false when provided a single aria-attribute is provided for a table', function () {
axe.configure({
checks: [
{
id: 'aria-allowed-attr',
options: {
validTreeRowAttrs: ['aria-posinset']
}
}
]
});

var options = {
validTreeRowAttrs: ['aria-posinset']
};
var vNode = queryFixture(
' <div role="table">' +
'<div id="target" role="row" aria-posinset="2"><div role="cell"></div></div>' +
'</div>'
);

assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
.call(checkContext, null, options, vNode)
);
assert.isNotNull(checkContext._data);
});

it('should return false when provided a single aria-attribute is provided for a grid', function () {
axe.configure({
checks: [
{
id: 'aria-allowed-attr',
options: {
validTreeRowAttrs: ['aria-level']
}
}
]
});

var options = {
validTreeRowAttrs: ['aria-level']
};
var vNode = queryFixture(
' <div role="grid">' +
'<div id="target" role="row" aria-level="2"><div role="gridcell"></div></div>' +
'</div>'
);

assert.isFalse(
axe.testUtils
.getCheckEvaluate('aria-allowed-attr')
.call(checkContext, null, options, vNode)
);
assert.isNotNull(checkContext._data);
});
});

describe('options', function () {
it('should allow provided attribute names for a role', function () {
axe.configure({
Expand Down