without handler still
+ // focus-required. But jsx-a11y gates on the handler, so no-handler is
+ // valid there. Our rule is closer to angular-eslint (role-driven, not
+ // handler-driven); however for parity with jsx-a11y's common case we
+ // rely on the authored role alone. Document the behavior: static
+ // role="button" with no tabindex IS flagged (see invalid section).
+ ],
+
+ invalid: [
+ // === Elements with an interactive role but no tabindex and no inherent
+ // focus — flagged by this rule on role alone (no event handler required). ===
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
+ // Role-fallback: UAs walk past unknown leading tokens to the first
+ // recognised role (`button` here). Rule should require focusability.
+ // LLM guardrail: models sometimes emit speculative unknown-first lists.
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'checkbox' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'link' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'menuitem' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'switch' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'tab' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'textbox' } }],
+ },
+
+ // === With handlers attached — canonical peer pattern. ===
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
x',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+
+ // ===
without href is NOT inherently focusable — an interactive
+ // role without tabindex must still be flagged. ===
+ {
+ code: 'x',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'a', role: 'button' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'area', role: 'button' } }],
+ },
+
+ // === type="hidden" input loses inherent focus; an interactive role
+ // without tabindex is flagged. (Vanishingly rare in practice.) ===
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'input', role: 'button' } }],
+ },
+
+ // === tabindex does NOT override disabled / type=hidden ===
+ // Disabled form controls are removed from the tab order (HTML §4.10.18.5)
+ // regardless of tabindex. Hidden inputs aren't focusable either.
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'button', role: 'menuitem' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'input', role: 'button' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'select', role: 'combobox' } }],
+ },
+
+ // === audio / video without controls is not focusable. ===
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'audio', role: 'slider' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'video', role: 'slider' } }],
+ },
+
+ // === contenteditable="false" is explicit opt-out — not focusable. ===
+ {
+ code: 'x
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'textbox' } }],
+ },
+
+ // === Multi-token role where the FIRST token is interactive (WAI-ARIA
+ // §4.1: only the first valid role applies; the rest are fallbacks). ===
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
+
+ // === Disabled form control with an interactive role: disabled removes
+ // inherent focusability, so without tabindex the role has no focus
+ // target and we flag it. ===
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'button', role: 'button' } }],
+ },
+
+ // === Other widget-descended roles (combobox, scrollbar, toolbar). ===
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'combobox' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'scrollbar' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'toolbar' } }],
+ },
+ ],
+});
+
+// =============================================================================
+// HBS (non-strict) mode
+// =============================================================================
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+hbsRuleTester.run('template-interactive-supports-focus', rule, {
+ valid: [
+ '',
+ '',
+ 'x',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
x
',
+ '
',
+ '
',
+ '
',
+ '
',
+ ],
+ invalid: [
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
+ {
+ code: '
x',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'span', role: 'link' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable' }],
+ },
+ {
+ code: '
x',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'a', role: 'button' } }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'input', role: 'button' } }],
+ },
+ {
+ code: '
x
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'textbox' } }],
+ },
+ ],
+});
From d200c0ba8a7571bde838f344d398c072b95bb9f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Johan=20R=C3=B8ed?=
Date: Tue, 28 Apr 2026 10:11:23 +0200
Subject: [PATCH 13/15] fix(template-interactive-supports-focus): trim
contenteditable before false comparison
---
lib/rules/template-interactive-supports-focus.js | 2 +-
tests/lib/rules/template-interactive-supports-focus.js | 7 +++++++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/lib/rules/template-interactive-supports-focus.js b/lib/rules/template-interactive-supports-focus.js
index 7991bd06dd..eafebe67d6 100644
--- a/lib/rules/template-interactive-supports-focus.js
+++ b/lib/rules/template-interactive-supports-focus.js
@@ -117,7 +117,7 @@ function isContentEditable(node) {
if (attr.value.type !== 'GlimmerTextNode') {
return true;
}
- return attr.value.chars.toLowerCase() !== 'false';
+ return attr.value.chars.trim().toLowerCase() !== 'false';
}
// Return the first RECOGNISED role token that's interactive — or null if no
diff --git a/tests/lib/rules/template-interactive-supports-focus.js b/tests/lib/rules/template-interactive-supports-focus.js
index eedfb93918..57f4325722 100644
--- a/tests/lib/rules/template-interactive-supports-focus.js
+++ b/tests/lib/rules/template-interactive-supports-focus.js
@@ -279,6 +279,13 @@ ruleTester.run('template-interactive-supports-focus', rule, {
output: null,
errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'textbox' } }],
},
+ // Whitespace around "false" — trim before comparison so " false " is still
+ // an explicit opt-out (HTML treats the value as case-insensitive trimmed).
+ {
+ code: 'x
',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'textbox' } }],
+ },
// === Multi-token role where the FIRST token is interactive (WAI-ARIA
// §4.1: only the first valid role applies; the rest are fallbacks). ===
From daad13233ea18fdb33f1bcca097b219eed3d67c6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Johan=20R=C3=B8ed?=
Date: Tue, 28 Apr 2026 11:59:55 +0200
Subject: [PATCH 14/15] refactor(template-interactive-supports-focus): extract
isSuppressedFromFocus helper to deduplicate tabindex/contenteditable
carve-outs
---
.../template-interactive-supports-focus.js | 42 ++++++++++---------
1 file changed, 22 insertions(+), 20 deletions(-)
diff --git a/lib/rules/template-interactive-supports-focus.js b/lib/rules/template-interactive-supports-focus.js
index eafebe67d6..c9e62eba78 100644
--- a/lib/rules/template-interactive-supports-focus.js
+++ b/lib/rules/template-interactive-supports-focus.js
@@ -69,6 +69,24 @@ function isComponentInvocation(tag) {
// inherent-focusability we'd otherwise grant the tag.
const DISABLABLE_FORM_CONTROLS = new Set(['button', 'input', 'select', 'textarea', 'fieldset']);
+// True when the UA ignores otherwise-focusing attributes (`tabindex`,
+// `contenteditable`) on this element because the element is itself removed
+// from sequential focus navigation by HTML semantics:
+// - disabled form controls (HTML §4.10.18.5)
+// - (no rendered element)
+function isSuppressedFromFocus(node, tag, getTextAttrValueFn) {
+ if (DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled')) {
+ return true;
+ }
+ if (tag === 'input') {
+ const type = getTextAttrValueFn(findAttr(node, 'type'));
+ if (typeof type === 'string' && type.trim().toLowerCase() === 'hidden') {
+ return true;
+ }
+ }
+ return false;
+}
+
// Is the element inherently focusable without needing tabindex?
function isInherentlyFocusable(node) {
const tag = node.tag?.toLowerCase();
@@ -209,32 +227,16 @@ module.exports = {
// HTML attribute names are case-insensitive, so accept `tabindex` or
// any other casing (e.g. `tabIndex`, the React-style camelCase).
const hasTabindex = node.attributes?.some((a) => a.name?.toLowerCase() === 'tabindex');
- if (hasTabindex) {
- const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
- let hiddenInput = false;
- if (tag === 'input') {
- const type = getTextAttrValue(findAttr(node, 'type'));
- hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
- }
- if (!disabled && !hiddenInput) {
- return;
- }
+ if (hasTabindex && !isSuppressedFromFocus(node, tag, getTextAttrValue)) {
+ return;
}
// contenteditable also makes an element focusable, with the same
// HTML-spec carve-outs as tabindex: the UA ignores it on disabled
// form controls (HTML §4.10.18.5) and on
// (no rendered element to edit), so the a11y conflict still stands.
- if (isContentEditable(node)) {
- const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
- let hiddenInput = false;
- if (tag === 'input') {
- const type = getTextAttrValue(findAttr(node, 'type'));
- hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
- }
- if (!disabled && !hiddenInput) {
- return;
- }
+ if (isContentEditable(node) && !isSuppressedFromFocus(node, tag, getTextAttrValue)) {
+ return;
}
context.report({
From 99774f760e0e2da0eef069e7880d190071abdcd4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Johan=20R=C3=B8ed?=
Date: Tue, 28 Apr 2026 14:07:44 +0200
Subject: [PATCH 15/15] fix: use classifyAttribute for tabindex (rows t6, t7)
---
.../template-interactive-supports-focus.js | 9 ++++++++-
.../template-interactive-supports-focus.js | 17 ++++++++++++++++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/lib/rules/template-interactive-supports-focus.js b/lib/rules/template-interactive-supports-focus.js
index c9e62eba78..99f34f3456 100644
--- a/lib/rules/template-interactive-supports-focus.js
+++ b/lib/rules/template-interactive-supports-focus.js
@@ -1,6 +1,7 @@
'use strict';
const { dom, roles } = require('aria-query');
+const { classifyAttribute } = require('../utils/glimmer-attr-presence');
// Interactive ARIA roles — non-abstract roles that descend from `widget`, plus
// `toolbar` (per jsx-a11y's convention: toolbar behaves as a widget even
@@ -226,7 +227,13 @@ module.exports = {
// targets still exists.
// HTML attribute names are case-insensitive, so accept `tabindex` or
// any other casing (e.g. `tabIndex`, the React-style camelCase).
- const hasTabindex = node.attributes?.some((a) => a.name?.toLowerCase() === 'tabindex');
+ // Use classifyAttribute so bare `{{false}}` / `{{null}}` /
+ // `{{undefined}}` (rows t6, t7) — which Glimmer omits at runtime —
+ // are NOT treated as satisfying the focus requirement. Dynamic
+ // values (`tabindex={{this.x}}` → 'unknown') keep the previous
+ // benefit-of-the-doubt: the runtime value could be a valid number.
+ const tabindexAttr = node.attributes?.find((a) => a.name?.toLowerCase() === 'tabindex');
+ const hasTabindex = classifyAttribute(tabindexAttr).presence !== 'absent';
if (hasTabindex && !isSuppressedFromFocus(node, tag, getTextAttrValue)) {
return;
}
diff --git a/tests/lib/rules/template-interactive-supports-focus.js b/tests/lib/rules/template-interactive-supports-focus.js
index 57f4325722..10fc6c911c 100644
--- a/tests/lib/rules/template-interactive-supports-focus.js
+++ b/tests/lib/rules/template-interactive-supports-focus.js
@@ -52,7 +52,8 @@ ruleTester.run('template-interactive-supports-focus', rule, {
'',
// tabindex="-1" is also sufficient — the role still has a focus target.
'',
- // Dynamic tabindex satisfies the check (the attribute is present).
+ // Dynamic tabindex satisfies the check (runtime value unknown — give
+ // benefit of the doubt; the runtime value may be a valid number).
'',
// === Interactive role on a non-focusable host but contenteditable is truthy. ===
@@ -130,6 +131,20 @@ ruleTester.run('template-interactive-supports-focus', rule, {
output: null,
errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
},
+ // Bare-mustache falsy on tabindex (rows t6, t7) — Glimmer omits the
+ // attribute at runtime, so tabindex is NOT actually present and does not
+ // satisfy the focus requirement. AST-presence check would have missed
+ // these (false negatives — rule silently let invalid templates through).
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'focusable', data: { tag: 'div', role: 'button' } }],
+ },
// Role-fallback: UAs walk past unknown leading tokens to the first
// recognised role (`button` here). Rule should require focusability.
// LLM guardrail: models sometimes emit speculative unknown-first lists.