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
57 changes: 29 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,34 +178,35 @@ rules in templates can be disabled with eslint directives with mustache or html

### Accessibility

| Name                                       | Description | 💼 | 🔧 | 💡 |
| :----------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | |
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
| [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-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | | | |
| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | |
| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | | | |
| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | | | |
| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | | | |
| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | | | |
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |
| Name                                            | Description | 💼 | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | |
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
| [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-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | |
| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | | | |
| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | |
| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | | | |
| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | | | |
| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | | | |
| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | | | |
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | |
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |

### Best Practices

Expand Down
43 changes: 43 additions & 0 deletions docs/rules/template-require-aria-activedescendant-tabindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ember/template-require-aria-activedescendant-tabindex

🔧 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 -->

This rule requires all non-interactive HTML elements using the `aria-activedescendant` attribute to declare a `tabindex` of zero.

The `aria-activedescendant` attribute identifies the active descendant element of a composite widget, textbox, group, or application with document focus. This attribute is placed on the container element of the input control, and its value is set to the ID of the active child element. This allows screen readers to communicate information about the currently active element as if it has focus, while actual focus of the DOM remains on the container element.

Elements with `aria-activedescendant` must have a `tabindex` of zero in order to support keyboard navigation. Besides interactive elements, which are inherently keyboard-focusable, elements using the `aria-activedescendant` attribute must declare a `tabIndex` of zero with the `tabIndex` attribute.

## Examples

This rule **forbids** the following:

```gjs
<template>
<div aria-activedescendant='some-id'></div>
<div aria-activedescendant='some-id' tabindex='-1'></div>
<input aria-activedescendant={{some-id}} tabindex='-1' />
</template>
```

This rule **allows** the following:

```gjs
<template>
<CustomComponent />
<CustomComponent aria-activedescendant={{some-id}} />
<CustomComponent aria-activedescendant={{some-id}} tabindex={{0}} />
<div aria-activedescendant='some-id' tabindex='0'></div>
<input />
<input aria-activedescendant={{some-id}} />
<input aria-activedescendant={{some-id}} tabindex={{0}} />
</template>
```

## References

- [MDN, Using the aria-activedescendant attribute(property)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-activedescendant_attribute)
- [WAI-aria: aria-activedescendant(property](https://www.digitala11y.com/aria-activedescendant-properties/)
- [aria-activedescendant-has-tabindex - eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/aria-activedescendant-has-tabindex.md)
117 changes: 117 additions & 0 deletions lib/rules/template-require-aria-activedescendant-tabindex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const { dom } = require('aria-query');

const HTML_TAGS = new Set(dom.keys());
const INTERACTIVE_ELEMENTS = new Set(['input', 'button', 'select', 'textarea']);
const ERROR_MESSAGE =
'A generic element using the aria-activedescendant attribute must have a tabindex';
const FIXED_TABINDEX = 'tabindex="0"';

function isInteractiveElement(node) {
if (INTERACTIVE_ELEMENTS.has(node.tag)) {
return true;
}
// <a> with href is interactive
if (node.tag === 'a' && node.attributes?.some((a) => a.name === 'href')) {
return true;
}
return false;
}

function getTabindexNumericValue(tabindexAttr) {
if (!tabindexAttr) {
return Number.nan;
}

const value = tabindexAttr.value;

if (value.type === 'GlimmerMustacheStatement' && value.path) {
if (
['GlimmerBooleanLiteral', 'GlimmerNumberLiteral', 'GlimmerStringLiteral'].includes(
value.path.type
)
) {
return Number(value.path.value);
}

return Number.nan;
}

if (value.type === 'GlimmerTextNode') {
return Number.parseInt(value.chars, 10);
}

return Number.nan;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require non-interactive elements with aria-activedescendant to have tabindex',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-aria-activedescendant-tabindex.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
missingTabindex: ERROR_MESSAGE,
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-aria-activedescendant-tabindex.js',
docs: 'docs/rule/require-aria-activedescendant-tabindex.md',
tests: 'test/unit/rules/require-aria-activedescendant-tabindex-test.js',
},
},

create(context) {
return {
GlimmerElementNode(node) {
const hasActiveDescendant = node.attributes?.some(
(attr) => attr.name === 'aria-activedescendant'
);

if (!hasActiveDescendant) {
return;
}

if (!HTML_TAGS.has(node.tag)) {
return;
}

const tabindexAttr = node.attributes?.find(
(attr) => attr.name === 'tabindex' || attr.name === 'tabIndex'
);

if (!tabindexAttr && isInteractiveElement(node)) {
return;
}

const tabindexValue = getTabindexNumericValue(tabindexAttr);

if (!Number.isFinite(tabindexValue) || tabindexValue < 0) {
context.report({
node,
messageId: 'missingTabindex',
fix(fixer) {
if (!tabindexAttr) {
const lastAttribute = node.attributes.at(-1);

if (lastAttribute) {
return fixer.insertTextAfterRange(lastAttribute.range, ` ${FIXED_TABINDEX}`);
}

const insertPos = node.parts.at(-1)?.range[1] ?? node.range[0] + '<div'.length;
return fixer.insertTextAfterRange([insertPos, insertPos], ` ${FIXED_TABINDEX}`);
}

return fixer.replaceTextRange(tabindexAttr.range, FIXED_TABINDEX);
},
});
}
},
};
},
};
Loading
Loading