From 01fbb79121f67b9b4419b7a47246e0c0215bc0a8 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 18 Mar 2026 18:09:50 -0400
Subject: [PATCH 1/2] Extract rule:
template-require-aria-activedescendant-tabindex
---
README.md | 57 ++++----
...-require-aria-activedescendant-tabindex.md | 55 +++++++
...-require-aria-activedescendant-tabindex.js | 135 ++++++++++++++++++
...-require-aria-activedescendant-tabindex.js | 126 ++++++++++++++++
4 files changed, 345 insertions(+), 28 deletions(-)
create mode 100644 docs/rules/template-require-aria-activedescendant-tabindex.md
create mode 100644 lib/rules/template-require-aria-activedescendant-tabindex.js
create mode 100644 tests/lib/rules/template-require-aria-activedescendant-tabindex.js
diff --git a/README.md b/README.md
index df9c97731c..94ac8500fd 100644
--- a/README.md
+++ b/README.md
@@ -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 elements with aria-activedescendant to be tabbable (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
diff --git a/docs/rules/template-require-aria-activedescendant-tabindex.md b/docs/rules/template-require-aria-activedescendant-tabindex.md
new file mode 100644
index 0000000000..6ef8553b5c
--- /dev/null
+++ b/docs/rules/template-require-aria-activedescendant-tabindex.md
@@ -0,0 +1,55 @@
+# ember/template-require-aria-activedescendant-tabindex
+
+
+
+Requires elements with `aria-activedescendant` to be tabbable (have tabindex attribute).
+
+When using `aria-activedescendant` to manage focus within a composite widget, the element with this attribute must be focusable. This is achieved by adding a `tabindex` attribute.
+
+## Rule Details
+
+This rule ensures that any element with the `aria-activedescendant` attribute also has a `tabindex` attribute, making it keyboard accessible.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```gjs
+
+
+
+```
+
+```gjs
+
+
+
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+
+
+
+```
+
+```gjs
+
+
+
+```
+
+## References
+
+- [ARIA: aria-activedescendant](https://www.w3.org/TR/wai-aria-1.2/#aria-activedescendant)
+- [Managing Focus with aria-activedescendant](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant)
+- [eslint-plugin-ember template-require-aria-activedescendant-tabindex](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-require-aria-activedescendant-tabindex.md)
diff --git a/lib/rules/template-require-aria-activedescendant-tabindex.js b/lib/rules/template-require-aria-activedescendant-tabindex.js
new file mode 100644
index 0000000000..150ac290d4
--- /dev/null
+++ b/lib/rules/template-require-aria-activedescendant-tabindex.js
@@ -0,0 +1,135 @@
+const INTERACTIVE_ELEMENTS = new Set(['input', 'button', 'select', 'textarea']);
+
+function isInteractiveElement(node) {
+ if (INTERACTIVE_ELEMENTS.has(node.tag)) {
+ return true;
+ }
+ // with href is interactive
+ if (node.tag === 'a' && node.attributes?.some((a) => a.name === 'href')) {
+ return true;
+ }
+ return false;
+}
+
+function isCustomComponent(tag) {
+ if (!tag) {
+ return false;
+ }
+ // PascalCase or dotted path → custom component
+ return /^[A-Z]/.test(tag) || tag.includes('.');
+}
+
+function getTabindexNumericValue(tabindexAttr) {
+ if (!tabindexAttr || !tabindexAttr.value) {
+ return { exists: false };
+ }
+
+ const val = tabindexAttr.value;
+
+ if (val.type === 'GlimmerTextNode') {
+ const num = Number.parseInt(val.chars, 10);
+ if (Number.isNaN(num)) {
+ return { exists: true, known: false };
+ }
+ return { exists: true, known: true, value: num, isText: true };
+ }
+
+ if (val.type === 'GlimmerMustacheStatement') {
+ if (val.path?.type === 'GlimmerNumberLiteral') {
+ return { exists: true, known: true, value: val.path.value, isText: false };
+ }
+ // Try to resolve from path.original (e.g., {{-1}} as PathExpression)
+ if (val.path?.original !== null && val.path?.original !== undefined) {
+ const num = Number.parseInt(String(val.path.original), 10);
+ if (!Number.isNaN(num)) {
+ return { exists: true, known: true, value: num, isText: false };
+ }
+ }
+ return { exists: true, known: false };
+ }
+
+ return { exists: true, known: false };
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'require elements with aria-activedescendant to be tabbable (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: null,
+ schema: [],
+ messages: {
+ missingTabindex:
+ 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ },
+ 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) {
+ // Skip custom components
+ if (isCustomComponent(node.tag)) {
+ return;
+ }
+
+ const hasActiveDescendant = node.attributes?.some(
+ (attr) => attr.name === 'aria-activedescendant'
+ );
+
+ if (!hasActiveDescendant) {
+ return;
+ }
+
+ const tabindexAttr = node.attributes?.find(
+ (attr) => attr.name === 'tabindex' || attr.name === 'tabIndex'
+ );
+
+ if (!tabindexAttr) {
+ // No tabindex - allow interactive elements
+ if (isInteractiveElement(node)) {
+ return;
+ }
+ context.report({
+ node,
+ messageId: 'missingTabindex',
+ });
+ return;
+ }
+
+ // Tabindex exists - check its value
+ const result = getTabindexNumericValue(tabindexAttr);
+ if (result.known) {
+ if (result.isText) {
+ // TextNode: allow -1 and above
+ if (result.value < -1) {
+ context.report({
+ node,
+ messageId: 'missingTabindex',
+ });
+ }
+ } else {
+ // MustacheStatement: only allow non-negative
+ if (result.value < 0) {
+ context.report({
+ node,
+ messageId: 'missingTabindex',
+ });
+ }
+ }
+ }
+ // Unknown dynamic values are assumed valid
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-require-aria-activedescendant-tabindex.js b/tests/lib/rules/template-require-aria-activedescendant-tabindex.js
new file mode 100644
index 0000000000..d643f252dc
--- /dev/null
+++ b/tests/lib/rules/template-require-aria-activedescendant-tabindex.js
@@ -0,0 +1,126 @@
+const rule = require('../../../lib/rules/template-require-aria-activedescendant-tabindex');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-require-aria-activedescendant-tabindex', rule, {
+ valid: [
+ 'List
',
+ 'List
',
+ 'Normal div
',
+
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ],
+
+ invalid: [
+ {
+ code: 'List
',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ {
+ code: 'Container',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'missingTabindex' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-require-aria-activedescendant-tabindex', rule, {
+ valid: [
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ],
+ invalid: [
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ },
+ ],
+ },
+ ],
+});
From 97be79ee6a22c5f8b81288b51713eef8c72895aa Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Fri, 20 Mar 2026 15:28:09 -0400
Subject: [PATCH 2/2] Sync with template-lint
---
README.md | 2 +-
...-require-aria-activedescendant-tabindex.md | 52 +++---
...-require-aria-activedescendant-tabindex.js | 116 ++++++-------
...-require-aria-activedescendant-tabindex.js | 162 ++++++------------
4 files changed, 125 insertions(+), 207 deletions(-)
diff --git a/README.md b/README.md
index 94ac8500fd..beae20dc68 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +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-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require elements with aria-activedescendant to be tabbable (have tabindex) | | | |
+| [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 | | | |
diff --git a/docs/rules/template-require-aria-activedescendant-tabindex.md b/docs/rules/template-require-aria-activedescendant-tabindex.md
index 6ef8553b5c..56285e503a 100644
--- a/docs/rules/template-require-aria-activedescendant-tabindex.md
+++ b/docs/rules/template-require-aria-activedescendant-tabindex.md
@@ -1,55 +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).
-Requires elements with `aria-activedescendant` to be tabbable (have tabindex attribute).
+
-When using `aria-activedescendant` to manage focus within a composite widget, the element with this attribute must be focusable. This is achieved by adding a `tabindex` attribute.
+This rule requires all non-interactive HTML elements using the `aria-activedescendant` attribute to declare a `tabindex` of zero.
-## Rule Details
+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.
-This rule ensures that any element with the `aria-activedescendant` attribute also has a `tabindex` attribute, making it keyboard accessible.
+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
-Examples of **incorrect** code for this rule:
-
-```gjs
-
-
-
-```
+This rule **forbids** the following:
```gjs
-
+
+
+
```
-Examples of **correct** code for this rule:
-
-```gjs
-
-
-
-```
+This rule **allows** the following:
```gjs
-
+
+
+
+
+
+
+
```
## References
-- [ARIA: aria-activedescendant](https://www.w3.org/TR/wai-aria-1.2/#aria-activedescendant)
-- [Managing Focus with aria-activedescendant](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant)
-- [eslint-plugin-ember template-require-aria-activedescendant-tabindex](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-require-aria-activedescendant-tabindex.md)
+- [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)
diff --git a/lib/rules/template-require-aria-activedescendant-tabindex.js b/lib/rules/template-require-aria-activedescendant-tabindex.js
index 150ac290d4..1b6194adab 100644
--- a/lib/rules/template-require-aria-activedescendant-tabindex.js
+++ b/lib/rules/template-require-aria-activedescendant-tabindex.js
@@ -1,4 +1,10 @@
+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)) {
@@ -11,44 +17,30 @@ function isInteractiveElement(node) {
return false;
}
-function isCustomComponent(tag) {
- if (!tag) {
- return false;
- }
- // PascalCase or dotted path → custom component
- return /^[A-Z]/.test(tag) || tag.includes('.');
-}
-
function getTabindexNumericValue(tabindexAttr) {
- if (!tabindexAttr || !tabindexAttr.value) {
- return { exists: false };
+ if (!tabindexAttr) {
+ return Number.nan;
}
- const val = tabindexAttr.value;
+ const value = tabindexAttr.value;
- if (val.type === 'GlimmerTextNode') {
- const num = Number.parseInt(val.chars, 10);
- if (Number.isNaN(num)) {
- return { exists: true, known: false };
+ if (value.type === 'GlimmerMustacheStatement' && value.path) {
+ if (
+ ['GlimmerBooleanLiteral', 'GlimmerNumberLiteral', 'GlimmerStringLiteral'].includes(
+ value.path.type
+ )
+ ) {
+ return Number(value.path.value);
}
- return { exists: true, known: true, value: num, isText: true };
+
+ return Number.nan;
}
- if (val.type === 'GlimmerMustacheStatement') {
- if (val.path?.type === 'GlimmerNumberLiteral') {
- return { exists: true, known: true, value: val.path.value, isText: false };
- }
- // Try to resolve from path.original (e.g., {{-1}} as PathExpression)
- if (val.path?.original !== null && val.path?.original !== undefined) {
- const num = Number.parseInt(String(val.path.original), 10);
- if (!Number.isNaN(num)) {
- return { exists: true, known: true, value: num, isText: false };
- }
- }
- return { exists: true, known: false };
+ if (value.type === 'GlimmerTextNode') {
+ return Number.parseInt(value.chars, 10);
}
- return { exists: true, known: false };
+ return Number.nan;
}
/** @type {import('eslint').Rule.RuleModule} */
@@ -56,16 +48,15 @@ module.exports = {
meta: {
type: 'problem',
docs: {
- description: 'require elements with aria-activedescendant to be tabbable (have tabindex)',
+ 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: null,
+ fixable: 'code',
schema: [],
messages: {
- missingTabindex:
- 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
+ missingTabindex: ERROR_MESSAGE,
},
originallyFrom: {
name: 'ember-template-lint',
@@ -78,11 +69,6 @@ module.exports = {
create(context) {
return {
GlimmerElementNode(node) {
- // Skip custom components
- if (isCustomComponent(node.tag)) {
- return;
- }
-
const hasActiveDescendant = node.attributes?.some(
(attr) => attr.name === 'aria-activedescendant'
);
@@ -91,44 +77,40 @@ module.exports = {
return;
}
+ if (!HTML_TAGS.has(node.tag)) {
+ return;
+ }
+
const tabindexAttr = node.attributes?.find(
(attr) => attr.name === 'tabindex' || attr.name === 'tabIndex'
);
- if (!tabindexAttr) {
- // No tabindex - allow interactive elements
- if (isInteractiveElement(node)) {
- return;
- }
+ if (!tabindexAttr && isInteractiveElement(node)) {
+ return;
+ }
+
+ const tabindexValue = getTabindexNumericValue(tabindexAttr);
+
+ if (!Number.isFinite(tabindexValue) || tabindexValue < 0) {
context.report({
node,
messageId: 'missingTabindex',
- });
- return;
- }
+ fix(fixer) {
+ if (!tabindexAttr) {
+ const lastAttribute = node.attributes.at(-1);
+
+ if (lastAttribute) {
+ return fixer.insertTextAfterRange(lastAttribute.range, ` ${FIXED_TABINDEX}`);
+ }
- // Tabindex exists - check its value
- const result = getTabindexNumericValue(tabindexAttr);
- if (result.known) {
- if (result.isText) {
- // TextNode: allow -1 and above
- if (result.value < -1) {
- context.report({
- node,
- messageId: 'missingTabindex',
- });
- }
- } else {
- // MustacheStatement: only allow non-negative
- if (result.value < 0) {
- context.report({
- node,
- messageId: 'missingTabindex',
- });
- }
- }
+ const insertPos = node.parts.at(-1)?.range[1] ?? node.range[0] + '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+];
+
+const invalidHbs = [
+ {
+ code: '',
+ output: '',
+ errors: [{ message: ERROR_MESSAGE }],
+ },
+ {
+ code: '',
+ output: '',
+ errors: [{ message: ERROR_MESSAGE }],
+ },
+ {
+ code: '',
+ output: '',
+ errors: [{ message: ERROR_MESSAGE }],
+ },
+ {
+ code: '',
+ output: '',
+ errors: [{ message: ERROR_MESSAGE }],
+ },
+];
-ruleTester.run('template-require-aria-activedescendant-tabindex', rule, {
- valid: [
- 'List
',
- 'List
',
- 'Normal div
',
+function wrapTemplate(entry) {
+ if (typeof entry === 'string') {
+ return `${entry}`;
+ }
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- ],
+ return {
+ ...entry,
+ code: `${entry.code}`,
+ output: entry.output ? `${entry.output}` : entry.output,
+ };
+}
- invalid: [
- {
- code: 'List
',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- {
- code: '',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- {
- code: 'Container',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
+const gjsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
- {
- code: '',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- {
- code: '',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- {
- code: '',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- {
- code: '',
- output: null,
- errors: [{ messageId: 'missingTabindex' }],
- },
- ],
+gjsRuleTester.run('template-require-aria-activedescendant-tabindex', rule, {
+ valid: validHbs.map(wrapTemplate),
+ invalid: invalidHbs.map(wrapTemplate),
});
const hbsRuleTester = new RuleTester({
@@ -71,56 +69,6 @@ const hbsRuleTester = new RuleTester({
});
hbsRuleTester.run('template-require-aria-activedescendant-tabindex', rule, {
- valid: [
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- '',
- ],
- invalid: [
- {
- code: '',
- output: null,
- errors: [
- {
- message:
- 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
- },
- ],
- },
- {
- code: '',
- output: null,
- errors: [
- {
- message:
- 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
- },
- ],
- },
- {
- code: '',
- output: null,
- errors: [
- {
- message:
- 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
- },
- ],
- },
- {
- code: '',
- output: null,
- errors: [
- {
- message:
- 'Elements with aria-activedescendant must have tabindex attribute to be keyboard accessible.',
- },
- ],
- },
- ],
+ valid: validHbs,
+ invalid: invalidHbs,
});