Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | | 🔧 | |
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | 🔧 | |
| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | | | |
| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | |
Expand Down
66 changes: 66 additions & 0 deletions docs/rules/template-no-redundant-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ember/template-no-redundant-role

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

The rule checks for redundancy between any semantic HTML element with a default/implicit ARIA role and the role provided.

For example, if a landmark element is used, any role provided will either be redundant or incorrect. This rule ensures that no role attribute is placed on any of the landmark elements, with the following exceptions:

- a `nav` element with the `navigation` role to [make the structure of the page more accessible to user agents](https://www.w3.org/WAI/GL/wiki/Using_HTML5_nav_element#Example:The_.3Cnav.3E_element)
- a `form` element with the `search` role to [identify the form's search functionality](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/search_role#examples)
- a `input` element with `combobox` role to [identify the input as a combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/)

## Examples

This rule **forbids** the following:

```hbs
<header role='banner'></header>
```

```hbs
<main role='main'></main>
```

```hbs
<aside role='complementary'></aside>
```

```hbs
<footer role='contentinfo'></footer>
```

```hbs
<form role='form'></form>
```

This rule **allows** the following:

```hbs
<form role='search'></form>
```

```hbs
<nav role='navigation'></nav>
```

```hbs
<input role='combobox' />
```

## Configuration

- boolean -- if `true`, default configuration is applied

- object -- containing the following property:
- boolean -- `checkAllHTMLElements` -- if `true`, the rule checks for redundancy between any semantic HTML element with a default/implicit ARIA role and the role provided, instead of just landmark roles (default: `true`)

## References

- [Landmark Roles (WAI-ARIA spec)](https://www.w3.org/WAI/PF/aria/roles#landmark_roles)
- [Using ARIA landmarks to identify regions of a page](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA11)
- [Document conformance requirements for use of ARIA attributes in HTML](https://www.w3.org/TR/html-aria/#docconformance)
- [ARIA Spec, ARIA Adds Nothing to Default Semantics of Most HTML Elements](https://www.w3.org/TR/using-aria/#aria-does-nothing)
- [Disabling a link](https://www.scottohara.me/blog/2021/05/28/disabled-links.html)
174 changes: 174 additions & 0 deletions lib/rules/template-no-redundant-role.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
const DEFAULT_CONFIG = {
checkAllHTMLElements: true,
};

function parseConfig(config) {
if (config === true) {
return DEFAULT_CONFIG;
}
return { ...DEFAULT_CONFIG, ...config };
}

function createErrorMessageLandmarkElement(element, role) {
return `Use of redundant or invalid role: ${role} on <${element}> detected. If a landmark element is used, any role provided will either be redundant or incorrect.`;
}

function createErrorMessageAnyElement(element, role) {
return `Use of redundant or invalid role: ${role} on <${element}> detected.`;
}

// https://www.w3.org/TR/html-aria/#docconformance
const LANDMARK_ROLES = new Set([
'banner',
'main',
'complementary',
'search',
'form',
'navigation',
'contentinfo',
]);

const ALLOWED_ELEMENT_ROLES = [
{ name: 'nav', role: 'navigation' },
{ name: 'form', role: 'search' },
{ name: 'ol', role: 'list' },
{ name: 'ul', role: 'list' },
{ name: 'a', role: 'link' },
{ name: 'input', role: 'combobox' },
];

// Mapping of roles to their corresponding HTML elements
// From https://www.w3.org/TR/html-aria/
const ROLE_TO_ELEMENTS = {
article: ['article'],
banner: ['header'],
button: ['button'],
cell: ['td'],
checkbox: ['input'],
columnheader: ['th'],
complementary: ['aside'],
contentinfo: ['footer'],
definition: ['dd'],
dialog: ['dialog'],
document: ['body'],
figure: ['figure'],
form: ['form'],
grid: ['table'],
gridcell: ['td'],
group: ['details', 'fieldset', 'optgroup'],
heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
img: ['img'],
link: ['a'],
list: ['ol', 'ul'],
listbox: ['select'],
listitem: ['li'],
main: ['main'],
navigation: ['nav'],
option: ['option'],
radio: ['input'],
region: ['section'],
row: ['tr'],
rowgroup: ['tbody', 'tfoot', 'thead'],
rowheader: ['th'],
search: ['search'],
searchbox: ['input'],
separator: ['hr'],
slider: ['input'],
spinbutton: ['input'],
status: ['output'],
table: ['table'],
term: ['dfn', 'dt'],
textbox: ['input', 'textarea'],
};

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow redundant role attributes',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-redundant-role.md',
templateMode: 'both',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
checkAllHTMLElements: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
messages: {},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-redundant-role.js',
docs: 'docs/rule/no-redundant-role.md',
tests: 'test/unit/rules/no-redundant-role-test.js',
},
},

create(context) {
const config = parseConfig(context.options[0]);

return {
GlimmerElementNode(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');

if (!roleAttr) {
return;
}

let roleValue;
if (roleAttr.value && roleAttr.value.type === 'GlimmerTextNode') {
roleValue = roleAttr.value.chars || '';
} else {
// Skip dynamic role values
return;
}

const isLandmarkRole = LANDMARK_ROLES.has(roleValue);
if (!config.checkAllHTMLElements && !isLandmarkRole) {
return;
}

const elementsWithRole = ROLE_TO_ELEMENTS[roleValue];
if (!elementsWithRole) {
return;
}

const isRedundant =
elementsWithRole.includes(node.tag) &&
!ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue);

if (isRedundant) {
const errorMessage = isLandmarkRole
? createErrorMessageLandmarkElement(node.tag, roleValue)
: createErrorMessageAnyElement(node.tag, roleValue);

context.report({
node,
message: errorMessage,
fix(fixer) {
const sourceCode = context.getSourceCode();
const elementText = sourceCode.getText(node);
const roleAttrText = sourceCode.getText(roleAttr);

// Find the role attribute in the element text and remove it along with preceding space
const roleAttrPattern = new RegExp(
`\\s+${roleAttrText.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}`
);
const fixedText = elementText.replace(roleAttrPattern, '');

return fixer.replaceText(node, fixedText);
},
});
}
},
};
},
};
Loading
Loading