Skip to content

Commit

Permalink
Merge pull request #2276 from judithhinlung/no-invalid-aria-attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
bmish committed Jan 18, 2022
2 parents 21371ac + 4d29ca3 commit fbf4790
Show file tree
Hide file tree
Showing 8 changed files with 581 additions and 0 deletions.
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:

```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',
'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);
}
return !isBoolean(value) && !Number.isNaN(value);
}

function validityCheck(expectedType, permittedValues, allowUndefined, value) {
if (value === 'undefined') {
return allowUndefined;
}
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 @@ -62,6 +62,7 @@
},
"dependencies": {
"@lint-todo/utils": "^12.0.1",
"aria-query": "^5.0.0",
"chalk": "^4.1.2",
"ci-info": "^3.3.0",
"date-fns": "^2.28.0",
Expand Down

0 comments on commit fbf4790

Please sign in to comment.