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

Add new rule no-invalid-aria-attributes #2276

Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Each rule has emojis denoting:
| [no-inline-styles](./docs/rule/no-inline-styles.md) | ✅ | | | |
| [no-input-block](./docs/rule/no-input-block.md) | ✅ | | | |
| [no-input-tagname](./docs/rule/no-input-tagname.md) | ✅ | | | |
| [no-invalid-aria-attributes](./docs/rule/no-invalid-aria-attributes.md) | | | ⌨️ | |
| [no-invalid-interactive](./docs/rule/no-invalid-interactive.md) | ✅ | | ⌨️ | |
| [no-invalid-link-text](./docs/rule/no-invalid-link-text.md) | ✅ | | ⌨️ | |
| [no-invalid-link-title](./docs/rule/no-invalid-link-title.md) | ✅ | | ⌨️ | |
Expand Down
27 changes: 27 additions & 0 deletions docs/rule/no-invalid-aria-attributes.md
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:
Copy link
Member

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.


```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)
1 change: 1 addition & 0 deletions lib/config/a11y.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Member

Choose a reason for hiding this comment

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

I imagine we should add this to recommended in the upcoming v4 release right?

'no-invalid-interactive': 'error',
'no-invalid-link-text': 'error',
'no-invalid-link-title': 'error',
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import noindexcomponentinvocation from './no-index-component-invocation.js';
import noinlinestyles from './no-inline-styles.js';
import noinputblock from './no-input-block.js';
import noinputtagname from './no-input-tagname.js';
import noinvalidariaattributes from './no-invalid-aria-attributes.js';
import noinvalidinteractive from './no-invalid-interactive.js';
import noinvalidlinktext from './no-invalid-link-text.js';
import noinvalidlinktitle from './no-invalid-link-title.js';
Expand Down Expand Up @@ -148,6 +149,7 @@ export default {
'no-inline-styles': noinlinestyles,
'no-input-block': noinputblock,
'no-input-tagname': noinputtagname,
'no-invalid-aria-attributes': noinvalidariaattributes,
'no-invalid-interactive': noinvalidinteractive,
'no-invalid-link-text': noinvalidlinktext,
'no-invalid-link-title': noinvalidlinktitle,
Expand Down
154 changes: 154 additions & 0 deletions lib/rules/no-invalid-aria-attributes.js
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,
});
}
}
},
};
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
]
},
"dependencies": {
"aria-query": "^5.0.0",
Copy link

Choose a reason for hiding this comment

The 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. aria-query should come after @lint-todo/utils.

"@lint-todo/utils": "^12.0.1",
"chalk": "^4.1.2",
"ci-info": "^3.3.0",
Expand Down