diff --git a/README.md b/README.md
index df9c97731c..beae20dc68 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 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
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..56285e503a
--- /dev/null
+++ b/docs/rules/template-require-aria-activedescendant-tabindex.md
@@ -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).
+
+
+
+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
+
+
+
+
+
+```
+
+This rule **allows** the following:
+
+```gjs
+
+
+
+
+
+
+
+
+
+```
+
+## 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)
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..1b6194adab
--- /dev/null
+++ b/lib/rules/template-require-aria-activedescendant-tabindex.js
@@ -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;
+ }
+ // 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] + '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+];
+
+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 }],
+ },
+];
+
+function wrapTemplate(entry) {
+ if (typeof entry === 'string') {
+ return `${entry}`;
+ }
+
+ return {
+ ...entry,
+ code: `${entry.code}`,
+ output: entry.output ? `${entry.output}` : entry.output,
+ };
+}
+
+const gjsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+gjsRuleTester.run('template-require-aria-activedescendant-tabindex', rule, {
+ valid: validHbs.map(wrapTemplate),
+ invalid: invalidHbs.map(wrapTemplate),
+});
+
+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: validHbs,
+ invalid: invalidHbs,
+});