-
Notifications
You must be signed in to change notification settings - Fork 235
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
Add new rule no-invalid-aria-attributes
#2276
Changes from 10 commits
0763126
ed6d1ad
2f56850
d241316
7658a0b
729fafe
25ecb72
6db7ef2
7e31004
f1748a7
4d29ca3
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 |
---|---|---|
@@ -0,0 +1,27 @@ | ||
# no-invalid-aria-attributes | ||
|
||
ARIA attributes are used to provide an element with specific accessibility functions. An ARIA attribute is invalid if its name or values are either misspelled or do not currently exist in the [WAI-ARIA States and Properties spec](https://www.w3.org/WAI/PF/aria-1.1/states_and_properties). | ||
|
||
This rule disallows the use of invalid ARIA attributes. | ||
|
||
## Examples | ||
|
||
This rule **forbids** the following: | ||
|
||
```hbs | ||
<input type="text" aria-not-real="true" /> | ||
<div role="region" aria-live="bogus">Inaccessible live region</div>', | ||
<button type="submit" aria-invalid={{if this.foo "true" "woosh"}}>Submit</button> | ||
``` | ||
|
||
This rule **allows** the following: | ||
|
||
```hbs | ||
<input type="text" aria-required="true" /> | ||
<div role="region" aria-live="polite">Accessible live region</div>', | ||
<button type="submit" aria-invalid={{if this.hasNoSpellingErrors "false" "spelling"}}>Send now</button> | ||
``` | ||
|
||
## References | ||
|
||
- [Using ARIA, Roles, States, and Properties](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ export default { | |
'no-duplicate-landmark-elements': 'error', | ||
'no-empty-headings': 'error', | ||
'no-heading-inside-button': 'error', | ||
'no-invalid-aria-attributes': 'error', | ||
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 imagine we should add this to |
||
'no-invalid-interactive': 'error', | ||
'no-invalid-link-text': 'error', | ||
'no-invalid-link-title': 'error', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
import { aria } from 'aria-query'; | ||
|
||
import Rule from './_base.js'; | ||
|
||
const VALID_ARIA_ATTRIBUTES = aria; | ||
|
||
function createInvalidAriaAttributeMessage(name) { | ||
return `${name} is an unrecognized ARIA attribute.`; | ||
} | ||
|
||
function createInvalidAttributeTypeErrorMessage(name, type, permittedValues) { | ||
switch (type) { | ||
case 'tristate': | ||
return `The value for ${name} must be a boolean or the string "mixed".`; | ||
case 'token': | ||
return `The value for ${name} must be a single token from the following: ${permittedValues.join( | ||
', ' | ||
)}.`; | ||
case 'tokenlist': | ||
return `The value for ${name} must be a list of one or more tokens from the following: ${permittedValues.join( | ||
', ' | ||
)}.`; | ||
case 'idlist': | ||
return `The value for ${name} must be a list of strings that represent DOM element IDs (idlist)`; | ||
case 'id': | ||
return `The value for ${name} must be a string that represents a DOM element ID`; | ||
case 'integer': | ||
return `The value for ${name} must be an integer.`; | ||
default: | ||
return `The value for ${name} must be a ${type}.`; | ||
} | ||
} | ||
|
||
function isBoolean(value) { | ||
return typeof value === 'boolean' || value === 'true' || value === 'false'; | ||
} | ||
|
||
function isNumeric(value) { | ||
if (typeof value === 'string') { | ||
value = Number.parseInt(value, 10); | ||
judithhinlung marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return !isBoolean(value) && !Number.isNaN(value); | ||
} | ||
|
||
function validityCheck(expectedType, permittedValues, allowUndefined, value) { | ||
if (value === 'undefined' && !allowUndefined) { | ||
return false; | ||
} | ||
switch (expectedType) { | ||
case 'boolean': | ||
return isBoolean(value); | ||
case 'string': | ||
case 'id': | ||
return typeof value === 'string' && !isBoolean(value); | ||
case 'idlist': | ||
return ( | ||
typeof value === 'string' && | ||
value.split(' ').every((token) => validityCheck('id', [], false, token)) | ||
); | ||
case 'tristate': | ||
return isBoolean(value) || value === 'mixed'; | ||
case 'integer': | ||
case 'number': | ||
return isNumeric(value); | ||
case 'token': | ||
return typeof value === 'string' && permittedValues.includes(value); | ||
case 'tokenlist': | ||
return ( | ||
typeof value === 'string' && | ||
value.split(' ').every((token) => permittedValues.includes(token.toLowerCase())) | ||
); | ||
} | ||
} | ||
|
||
function getValuesFromMustache(mustacheNode) { | ||
let valuesList = []; | ||
if (['BooleanLiteral', 'NumberLiteral', 'StringLiteral'].includes(mustacheNode.path.type)) { | ||
valuesList.push(mustacheNode.path.original); | ||
} else if (mustacheNode.path.type === 'PathExpression') { | ||
if (mustacheNode.path.original === 'if' || mustacheNode.path.original === 'unless') { | ||
if (mustacheNode.params.length === 2 || mustacheNode.params.length === 3) { | ||
valuesList.push(mustacheNode.params[1].value); | ||
} | ||
if (mustacheNode.params.length === 3) { | ||
valuesList.push(mustacheNode.params[2].value); | ||
} | ||
} | ||
} | ||
return valuesList; | ||
} | ||
|
||
export default class NoInvalidAriaAttributes extends Rule { | ||
visitor() { | ||
return { | ||
ElementNode(node) { | ||
let foundAriaAttributes = []; | ||
for (const attribute of node.attributes) { | ||
if (attribute.name.startsWith('aria-')) { | ||
if (!VALID_ARIA_ATTRIBUTES.has(attribute.name)) { | ||
this.log({ | ||
message: createInvalidAriaAttributeMessage(attribute.name), | ||
node, | ||
}); | ||
return; | ||
} else { | ||
foundAriaAttributes.push(attribute); | ||
} | ||
} | ||
} | ||
for (let attribute of foundAriaAttributes) { | ||
let validAriaAttribute = VALID_ARIA_ATTRIBUTES.get(attribute.name); | ||
let expectedType = validAriaAttribute.type; | ||
let permittedValues = validAriaAttribute.values; | ||
let allowUndefined = validAriaAttribute.allowundefined || false; | ||
let isValidValue; | ||
if (attribute.value.type === 'MustacheStatement') { | ||
if (attribute.value.path) { | ||
let valuesList = getValuesFromMustache(attribute.value); | ||
if (valuesList.length === 0) { | ||
isValidValue = true; | ||
} else { | ||
for (let value of valuesList) { | ||
isValidValue = validityCheck( | ||
expectedType, | ||
permittedValues, | ||
allowUndefined, | ||
value | ||
); | ||
} | ||
} | ||
} | ||
} else { | ||
isValidValue = validityCheck( | ||
expectedType, | ||
permittedValues, | ||
allowUndefined, | ||
attribute.value.chars | ||
); | ||
} | ||
if (!isValidValue) { | ||
this.log({ | ||
message: createInvalidAttributeTypeErrorMessage( | ||
attribute.name, | ||
expectedType, | ||
permittedValues | ||
), | ||
node, | ||
}); | ||
} | ||
} | ||
}, | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -61,6 +61,7 @@ | |
] | ||
}, | ||
"dependencies": { | ||
"aria-query": "^5.0.0", | ||
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. @judithhinlung Looks like the build is failing because the listed deps are out of alphabetical order. |
||
"@lint-todo/utils": "^12.0.1", | ||
"chalk": "^4.1.2", | ||
"ci-info": "^3.3.0", | ||
|
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.
Is there anything else we can validate about ARIA attributes? Anything we validate about their values or what attributes they are used on? It's easier to make this rule comprehensive now and cover multiple things, since adding violations later will be a breaking change.