-
Notifications
You must be signed in to change notification settings - Fork 743
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
Changes from 1 commit
68cd550
08304ca
f55ff1e
0e43e43
be3deb0
1f61ecb
b4b8141
478a14e
654a211
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
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; | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guess I can put them into commons/aria then. |
||
} |
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}" | ||
} | ||
} | ||
} | ||
} |
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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
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 ownerRole = getRole(getRowOwner(virtualNode)); | ||
if (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); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
"any": [], | ||
"none": ["aria-unsupported-attr", "aria-prohibited-attr"] | ||
} |
There was a problem hiding this comment.
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.