` when an interactive event handler (`onClick` / `@click` / `(click)`) is also present:
+
+- [`jsx-a11y/interactive-supports-focus`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/interactive-supports-focus.md)
+- [`vuejs-accessibility/interactive-supports-focus`](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/interactive-supports-focus.md)
+- [`@angular-eslint/template/interactive-supports-focus`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/interactive-supports-focus.md)
+
+**This rule is role-gated — it flags on role alone**, regardless of handler presence. Shapes like `
x
` with no handler will flag here but not in jsx-a11y / vue-a11y / angular-eslint. That's a deliberate choice: an authored interactive role promises operability irrespective of whether the handler is wired up at the current site (the role is the public contract; the handler is an implementation detail that may move).
+
+If you want peer-parity handler-gated behavior, use [`template-no-invalid-interactive`](./template-no-invalid-interactive.md) instead (see also [#33](https://github.com/ember-cli/eslint-plugin-ember/pull/33)), which flags interactive event handlers on non-interactive hosts and honors the `role="presentation"` / `aria-hidden` escape hatches.
+
+## Examples
+
+This rule **forbids** the following:
+
+```gjs
+
+ {{! role without tabindex on a non-focusable host }}
+ Click
+ Visit
+
+
+ {{! anchor / area without href is not inherently focusable }}
+ x
+
+ {{! hidden input loses its focus affordance }}
+
+
+ {{! contenteditable="false" explicitly opts out of focus }}
+ x
+
+```
+
+This rule **allows** the following:
+
+```gjs
+
+ {{! Inherently focusable hosts }}
+
+ Next
+
+
+ {{! Any tabindex satisfies the focus requirement }}
+
+
+
+
+ {{! contenteditable makes an element focusable }}
+ Edit
+
+ {{! Dynamic role — conservatively skipped }}
+
+
+ {{! Non-widget roles are outside scope }}
+
+
+ {{! Component invocations — out of scope }}
+
+
+```
+
+## Scope notes
+
+- **Interactive ARIA roles** are derived from [`aria-query`](https://www.npmjs.com/package/aria-query): non-abstract roles that descend from `widget`, plus `toolbar` (matching jsx-a11y's convention).
+- **Component invocations** (PascalCase, `@arg`, `this.x`, `foo.bar`, `foo::bar`) are skipped — their rendered output is opaque to the linter.
+- **Custom elements** not present in aria-query's DOM map are skipped.
+- **Dynamic role values** (`role={{this.role}}`) are conservatively skipped.
+- **Related rule:** [`template-no-invalid-interactive`](./template-no-invalid-interactive.md) covers a different concern — it flags interactive event handlers on non-interactive elements. This rule enforces the inverse: when an interactive ARIA role has been declared, the element must also be focusable. The two rules are complementary and can both fire on the same element when appropriate.
+
+## References
+
+- [WAI-ARIA Authoring Practices — Keyboard Interaction](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
+- [MDN — ARIA: button role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/button_role)
+- [`interactive-supports-focus` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/interactive-supports-focus.md)
+- [`interactive-supports-focus` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/interactive-supports-focus.md)
+- [`interactive-supports-focus` — angular-eslint](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/interactive-supports-focus.md)
diff --git a/docs/rules/template-require-input-type.md b/docs/rules/template-require-input-type.md
new file mode 100644
index 0000000000..7fc59e28b0
--- /dev/null
+++ b/docs/rules/template-require-input-type.md
@@ -0,0 +1,66 @@
+# ember/template-require-input-type
+
+🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
+
+
+
+This rule rejects `
` values that are not one of the input
+types defined by the HTML spec, and (optionally) requires every `
` to
+declare a `type` attribute.
+
+An invalid value like `
` silently falls back to the Text
+state — the browser reports no error, but the author's intent (validation,
+inputmode hint, platform keyboard) is lost. That's a genuine silent-failure
+class, which this rule always flags and auto-fixes to `type="text"`.
+
+A missing `type` attribute (`
`) is _spec-compliant_ — the
+missing-value default is the Text state — so flagging it is a style /
+consistency choice, not a correctness one. Opt in with `requireExplicit: true`
+if your team wants parity with `template-require-button-type`.
+
+## Examples
+
+This rule **forbids** the following (always):
+
+```hbs
+
+
+
+```
+
+With `requireExplicit: true` the rule **also forbids**:
+
+```hbs
+
+
+```
+
+This rule **allows** the following:
+
+```hbs
+
+
+
+
+```
+
+Dynamic values such as `type={{this.inputType}}` are not flagged at lint time.
+
+## Configuration
+
+- `requireExplicit` (`boolean`, default `false`): when true, also flag
+ `
` elements that have no `type` attribute. Auto-fix inserts
+ `type="text"`.
+
+```js
+module.exports = {
+ rules: {
+ 'ember/template-require-input-type': ['error', { requireExplicit: true }],
+ },
+};
+```
+
+## References
+
+- [HTML spec — the input element](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
+- Adapted from [`html-validate`'s `no-implicit-input-type`](https://html-validate.org/rules/no-implicit-input-type.html) (MIT).
diff --git a/lib/rules/template-block-indentation.js b/lib/rules/template-block-indentation.js
index 660fd1a94f..e42831516f 100644
--- a/lib/rules/template-block-indentation.js
+++ b/lib/rules/template-block-indentation.js
@@ -1,25 +1,9 @@
'use strict';
+const { htmlVoidElements } = require('html-void-elements');
const editorConfigUtil = require('../utils/editorconfig');
-const VOID_TAGS = new Set([
- 'area',
- 'base',
- 'br',
- 'col',
- 'command',
- 'embed',
- 'hr',
- 'img',
- 'input',
- 'keygen',
- 'link',
- 'meta',
- 'param',
- 'source',
- 'track',
- 'wbr',
-]);
+const VOID_TAGS = new Set(htmlVoidElements);
const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);
function isControlChar(char) {
diff --git a/lib/rules/template-interactive-supports-focus.js b/lib/rules/template-interactive-supports-focus.js
new file mode 100644
index 0000000000..99f34f3456
--- /dev/null
+++ b/lib/rules/template-interactive-supports-focus.js
@@ -0,0 +1,257 @@
+'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
+// though it is modelled as `structure` in the ARIA taxonomy).
+const INTERACTIVE_ROLES = buildInteractiveRoleSet();
+
+function buildInteractiveRoleSet() {
+ const result = new Set();
+ for (const [role, def] of roles) {
+ if (def.abstract) {
+ continue;
+ }
+ const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget'));
+ if (descendsFromWidget) {
+ result.add(role);
+ }
+ }
+ result.add('toolbar');
+ return result;
+}
+
+// Tags whose *default* semantics expose focus. `a`/`area` also need `href`,
+// and `audio`/`video` need `controls`; these are handled as special cases.
+const ALWAYS_FOCUSABLE_TAGS = new Set([
+ 'button',
+ 'select',
+ 'textarea',
+ 'summary',
+ 'iframe',
+ 'object',
+ 'embed',
+]);
+
+function findAttr(node, name) {
+ // HTML attribute names are case-insensitive. Normalize both sides so that
+ // `TABINDEX` / `Role` etc. match the same lookup as lowercase.
+ const target = name.toLowerCase();
+ return node.attributes?.find((a) => a.name?.toLowerCase() === target);
+}
+
+function getTextAttrValue(attr) {
+ if (attr?.value?.type === 'GlimmerTextNode') {
+ return attr.value.chars;
+ }
+ return undefined;
+}
+
+// PascalCase (`Foo`), argument-invocation (`@foo`), path on `this.`, dotted
+// path (`foo.bar`), or named-block-style (`foo::bar`). Mirrors the pattern
+// used across other template-* rules until a shared utility lands.
+function isComponentInvocation(tag) {
+ if (!tag) {
+ return false;
+ }
+ return (
+ /^[A-Z]/.test(tag) ||
+ tag.startsWith('@') ||
+ tag.startsWith('this.') ||
+ tag.includes('.') ||
+ tag.includes('::')
+ );
+}
+
+// Form controls that accept a `disabled` attribute. Per HTML spec a disabled
+// form control is not keyboard-focusable, so `disabled` suppresses the
+// 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();
+
+ // Disabled form controls are not keyboard-focusable per HTML spec.
+ if (DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled')) {
+ return false;
+ }
+
+ if (ALWAYS_FOCUSABLE_TAGS.has(tag)) {
+ return true;
+ }
+
+ if (tag === 'input') {
+ const type = getTextAttrValue(findAttr(node, 'type'));
+ // type="hidden" has no focus affordance; everything else is focusable.
+ // HTML type values are ASCII case-insensitive and may carry incidental
+ // whitespace; normalize before comparison.
+ return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
+ }
+
+ if ((tag === 'a' || tag === 'area') && findAttr(node, 'href')) {
+ return true;
+ }
+
+ if ((tag === 'audio' || tag === 'video') && findAttr(node, 'controls')) {
+ return true;
+ }
+
+ return false;
+}
+
+// Does the element have a `contenteditable` attribute that is truthy?
+// Bare attribute (no value) and anything other than explicit "false" counts
+// as truthy, matching HTML semantics.
+function isContentEditable(node) {
+ const attr = findAttr(node, 'contenteditable');
+ if (!attr) {
+ return false;
+ }
+ // Valueless attribute: parser models this as no `value` or a null value.
+ if (attr.value === null || attr.value === undefined) {
+ return true;
+ }
+ // Dynamic value (mustache/concat) — treat as truthy; we cannot prove otherwise.
+ if (attr.value.type !== 'GlimmerTextNode') {
+ return true;
+ }
+ return attr.value.chars.trim().toLowerCase() !== 'false';
+}
+
+// Return the first RECOGNISED role token that's interactive — or null if no
+// recognised interactive role appears in the list. Per WAI-ARIA §4.1, space-
+// separated role tokens are a fallback list: UAs walk the list for the first
+// role they implement, skipping unknown tokens. So `role="xxyxyz button"`
+// resolves to `button`; the rule should treat that as interactive even
+// though the author put an unknown token first. Dynamic role values return
+// `{ dynamic: true }` so the caller can conservatively skip.
+function getInteractiveRole(node) {
+ const attr = findAttr(node, 'role');
+ if (!attr) {
+ return { role: null };
+ }
+ if (attr.value?.type !== 'GlimmerTextNode') {
+ return { dynamic: true };
+ }
+ const tokens = attr.value.chars.trim().toLowerCase().split(/\s+/u);
+ for (const token of tokens) {
+ if (!roles.has(token)) {
+ continue; // unknown token — UAs skip per §4.1 fallback semantics
+ }
+ if (INTERACTIVE_ROLES.has(token)) {
+ return { role: token };
+ }
+ // First recognised role is non-interactive — subsequent tokens are just
+ // graceful-degradation fallbacks for this non-interactive intent.
+ return { role: null };
+ }
+ return { role: null };
+}
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'require elements with an interactive ARIA role to be focusable',
+ category: 'Accessibility',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-interactive-supports-focus.md',
+ templateMode: 'both',
+ },
+ fixable: null,
+ schema: [],
+ messages: {
+ focusable:
+ 'Element <{{tag}}> has interactive role "{{role}}" but is not focusable — add a `tabindex` or use an inherently focusable element.',
+ },
+ },
+
+ create(context) {
+ return {
+ GlimmerElementNode(node) {
+ const tag = node.tag?.toLowerCase();
+ if (!tag) {
+ return;
+ }
+
+ // Skip component invocations — they may render anything.
+ if (isComponentInvocation(node.tag)) {
+ return;
+ }
+
+ // Skip unknown / custom elements (not in aria-query's DOM map).
+ if (!dom.has(tag)) {
+ return;
+ }
+
+ const { role, dynamic } = getInteractiveRole(node);
+ if (dynamic) {
+ return;
+ }
+ if (!role) {
+ return;
+ }
+
+ // Already focusable by default?
+ if (isInherentlyFocusable(node)) {
+ return;
+ }
+
+ // Any tabindex — static or dynamic — satisfies the focus requirement,
+ // EXCEPT on elements that HTML removes from the tab order regardless:
+ // - disabled form controls (HTML §4.10.18.5) — the disabled state
+ // removes the element from sequential focus navigation.
+ // -
— not visible, not focusable.
+ // tabindex on these is ignored by the UA; the a11y conflict the rule
+ // targets still exists.
+ // HTML attribute names are case-insensitive, so accept `tabindex` or
+ // any other casing (e.g. `tabIndex`, the React-style camelCase).
+ // 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;
+ }
+
+ // 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) && !isSuppressedFromFocus(node, tag, getTextAttrValue)) {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'focusable',
+ data: { tag: node.tag, role },
+ });
+ },
+ };
+ },
+};
diff --git a/lib/rules/template-require-input-type.js b/lib/rules/template-require-input-type.js
new file mode 100644
index 0000000000..270d5433fe
--- /dev/null
+++ b/lib/rules/template-require-input-type.js
@@ -0,0 +1,145 @@
+'use strict';
+
+// See html-validate (https://html-validate.org) for the peer rule concept.
+
+const { isNativeElement } = require('../utils/is-native-element');
+
+const VALID_TYPES = new Set([
+ 'button',
+ 'checkbox',
+ 'color',
+ 'date',
+ 'datetime-local',
+ 'email',
+ 'file',
+ 'hidden',
+ 'image',
+ 'month',
+ 'number',
+ 'password',
+ 'radio',
+ 'range',
+ 'reset',
+ 'search',
+ 'submit',
+ 'tel',
+ 'text',
+ 'time',
+ 'url',
+ 'week',
+]);
+
+/** @type {import('eslint').Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'require input elements to have a valid type attribute',
+ category: 'Best Practices',
+ url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-type.md',
+ templateMode: 'both',
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ requireExplicit: {
+ type: 'boolean',
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ missing: 'All `
` elements should have a `type` attribute',
+ invalid: '`
` is not a valid input type',
+ },
+ },
+
+ create(context) {
+ // Flagging a missing `type` is a style/consistency check, not a correctness
+ // one: `
` without `type` is spec-compliant (defaults to the Text
+ // state). Opt-in so teams that want parity with template-require-button-
+ // type can enable it without imposing it on others.
+ const requireExplicit = Boolean(context.options[0]?.requireExplicit);
+ const sourceCode = context.sourceCode || context.getSourceCode();
+
+ return {
+ GlimmerElementNode(node) {
+ if (node.tag !== 'input') {
+ return;
+ }
+ // In strict GJS, a lowercase local binding can shadow the native
+ // `
` element. `isNativeElement` consults html/svg/mathml tag
+ // lists and checks bindings in the scope chain to filter out
+ // scope-shadowed cases.
+ if (!isNativeElement(node, sourceCode)) {
+ return;
+ }
+
+ const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
+
+ if (!typeAttr) {
+ if (!requireExplicit) {
+ return;
+ }
+ context.report({
+ node,
+ messageId: 'missing',
+ fix(fixer) {
+ // Insert right after `
`) — per HTML spec, a
+ // present-but-empty type attribute resolves to the missing-value
+ // default ("Text state"). That's the same runtime result as
+ // `type=""`, which we already flag. Treat them consistently:
+ // flag as invalid('') and autofix to `type="text"`.
+ if (!value) {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: '' },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ return;
+ }
+
+ if (value.type === 'GlimmerTextNode') {
+ const typeValue = value.chars.toLowerCase();
+ if (typeValue === '') {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: '' },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ } else if (!VALID_TYPES.has(typeValue)) {
+ context.report({
+ node: typeAttr,
+ messageId: 'invalid',
+ data: { value: value.chars },
+ fix(fixer) {
+ return fixer.replaceText(typeAttr, 'type="text"');
+ },
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/lib/rules/template-self-closing-void-elements.js b/lib/rules/template-self-closing-void-elements.js
index 6b194979c7..2cc40f813a 100644
--- a/lib/rules/template-self-closing-void-elements.js
+++ b/lib/rules/template-self-closing-void-elements.js
@@ -1,3 +1,9 @@
+'use strict';
+
+const { htmlVoidElements } = require('html-void-elements');
+
+const VOID_ELEMENTS = new Set(htmlVoidElements);
+
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
@@ -27,25 +33,6 @@ module.exports = {
},
create(context) {
- const VOID_ELEMENTS = new Set([
- 'area',
- 'base',
- 'br',
- 'col',
- 'command',
- 'embed',
- 'hr',
- 'img',
- 'input',
- 'keygen',
- 'link',
- 'meta',
- 'param',
- 'source',
- 'track',
- 'wbr',
- ]);
-
const sourceCode = context.sourceCode;
const config = context.options[0] ?? true;
diff --git a/lib/utils/glimmer-attr-presence.js b/lib/utils/glimmer-attr-presence.js
new file mode 100644
index 0000000000..1d8479a28e
--- /dev/null
+++ b/lib/utils/glimmer-attr-presence.js
@@ -0,0 +1,202 @@
+'use strict';
+
+const { find, html } = require('property-information');
+const { getStaticAttrValue } = require('./static-attr-value');
+
+// `colspan` is a positive-integer attribute per WHATWG, but property-information
+// 7.1.0 doesn't mark it as `number: true` (likely upstream gap — `rowspan`,
+// `cols`, etc. do have it). Override locally; remove if upstream fixes.
+const NUMERIC_OVERRIDES = new Set(['colspan']);
+
+/**
+ * Infer the attribute kind from its name. Used when the caller doesn't pass
+ * `options.kind` explicitly.
+ *
+ * Returns one of: 'boolean' | 'aria' | 'numeric' | 'plain-string'.
+ *
+ * Classification flows from the `property-information` package, which encodes
+ * attribute type info per WHATWG HTML / WAI-ARIA. ARIA prefix is checked first
+ * because Glimmer's rendering for `aria-*` attrs diverges from HTML booleans
+ * (e.g., `aria-hidden={{true}}` renders empty per h5, but `disabled={{true}}`
+ * renders `disabled=""` per d2). `role` falls through to plain-string because
+ * Glimmer does not falsy-coerce it (the doc's cross-attribute observations
+ * confirm this — `role={{false}}` renders `role="false"`).
+ */
+function inferAttrKind(name) {
+ // HTML attribute names are case-insensitive; normalize before lookup so
+ // `Disabled`, `ARIA-Hidden`, etc. classify the same as the lowercase form.
+ const lower = name.toLowerCase();
+ if (lower.startsWith('aria-')) {
+ return 'aria';
+ }
+ const info = find(html, lower);
+ // boolean: standard HTML boolean attrs (disabled, muted, …).
+ // overloadedBoolean: hidden, download — boolean-like with extra string values,
+ // but Glimmer's falsy-omit coercion still applies (verified for `hidden`-style).
+ if (info.boolean || info.overloadedBoolean) {
+ return 'boolean';
+ }
+ if (info.number || NUMERIC_OVERRIDES.has(lower)) {
+ return 'numeric';
+ }
+ // Everything else (plain strings, booleanish HTML attrs like contenteditable
+ // and draggable whose Glimmer behavior isn't verified in the doc) routes to
+ // plain-string. Conservative: no falsy-omit coercion, render the literal.
+ return 'plain-string';
+}
+
+/**
+ * Classify a Glimmer attribute against the verified rendering model in
+ * docs/glimmer-attribute-behavior.md.
+ *
+ * Result shape: { presence, value }
+ *
+ * presence: 'absent' | 'present' | 'unknown'
+ * - 'absent' — attribute will not be on the rendered element.
+ * Either attrNode is null/undefined, OR the source is
+ * bare {{false}}/{{null}}/{{undefined}} (or {{0}} for
+ * `boolean` kind) on a falsy-coerced attribute kind
+ * (boolean / aria / numeric). Doc rows: m6, m9, m10, m12,
+ * d3, d6, h6, h9, h10, t6, t7.
+ * - 'present' — attribute will be present at runtime. `value` is the
+ * resolved static string when known, or null when the
+ * value is dynamic (e.g., bare {{this.x}} on a plain-string
+ * attribute).
+ * - 'unknown' — cannot determine statically (dynamic mustache / dynamic
+ * concat part on a falsy-coerced kind, since the runtime
+ * value could be falsy and thus omit the attribute).
+ *
+ * value: string | null
+ * The resolved HTML attribute value when statically known. null when:
+ * - presence is 'absent' or 'unknown'
+ * - presence is 'present' but the value is dynamic
+ *
+ * @param {object|null|undefined} attrNode - The AttrNode, or null/undefined when not found.
+ * @param {object} [options]
+ * @param {'boolean'|'aria'|'numeric'|'plain-string'} [options.kind] - Override inferred kind.
+ * @returns {{presence: 'absent'|'present'|'unknown', value: string|null}}
+ */
+function classifyAttribute(attrNode, options = {}) {
+ if (!attrNode) {
+ return { presence: 'absent', value: null };
+ }
+
+ const kind = options.kind || inferAttrKind(attrNode.name);
+ const isFalsyCoerced = kind === 'boolean' || kind === 'aria' || kind === 'numeric';
+ const value = attrNode.value;
+
+ // Valueless attribute:
,
+ // Renders as `attr=""`. Doc rows: d1, h1.
+ if (value === null || value === undefined) {
+ return { presence: 'present', value: '' };
+ }
+
+ // Static text: attr="anything". Renders the literal chars.
+ // Doc rows: m1-m4, h2-h4, d1, t-static, i1.
+ if (value.type === 'GlimmerTextNode') {
+ return { presence: 'present', value: value.chars };
+ }
+
+ // Bare-mustache: attr={{X}}
+ if (value.type === 'GlimmerMustacheStatement') {
+ return classifyBareMustache(value, kind, isFalsyCoerced);
+ }
+
+ // Concat-mustache: attr="...{{X}}..." — never falsy.
+ // Doc cross-attribute observation: "Concat is never falsy."
+ if (value.type === 'GlimmerConcatStatement') {
+ // For boolean attrs, the IDL property is set true regardless of inner
+ // literal (rows m13–m19, d7–d10). Report the canonical "on" value so
+ // callers comparing `value === 'false'` to detect "off" don't get a
+ // wrong answer from the inner literal of `attr="{{false}}"`.
+ if (kind === 'boolean') {
+ return { presence: 'present', value: 'true' };
+ }
+ // For aria/numeric/plain-string, the rendered HTML value is the
+ // stringified concatenation of parts (h12–h15, i3, i5). If any part
+ // is dynamic, the resolved value is unknown but presence is still 'present'.
+ const resolved = getStaticAttrValue(value);
+ return { presence: 'present', value: resolved === undefined ? null : resolved };
+ }
+
+ // Unknown AST shape (e.g., a future Glimmer node type) — be conservative.
+ return { presence: 'unknown', value: null };
+}
+
+function classifyBareMustache(value, kind, isFalsyCoerced) {
+ const path = value.path;
+ if (!path) {
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{true}} / {{false}}
+ if (path.type === 'GlimmerBooleanLiteral') {
+ if (path.value === false) {
+ // {{false}} on falsy-coerced kind → omitted (m6, d3, h6, t6 verified).
+ // {{false}} on plain-string → renders "false" (i4 verified for autocomplete).
+ if (isFalsyCoerced) {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'present', value: 'false' };
+ }
+ // {{true}}: behavior diverges by kind.
+ // - boolean: verified (m5, d2). HTML may be empty (d2) or omitted (m5),
+ // but the attribute is conceptually "on". Surface 'true' so callers
+ // can string-compare like for {{"true"}}.
+ // - aria: verified (h5). Renders aria-hidden="" — empty, NOT "true".
+ // Callers comparing aria-hidden to "true" must not match this row.
+ // - numeric / plain-string: untested in the verification doc. Be
+ // conservative — return 'unknown' rather than guess.
+ if (kind === 'boolean') {
+ return { presence: 'present', value: 'true' };
+ }
+ if (kind === 'aria') {
+ return { presence: 'present', value: '' };
+ }
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{null}} / {{undefined}}
+ if (path.type === 'GlimmerNullLiteral' || path.type === 'GlimmerUndefinedLiteral') {
+ // Verified for falsy-coerced kinds via cross-attribute observation
+ // (rows m9, m10, h9, h10, d6, t7).
+ // For plain-string, behavior is not yet verified — return 'unknown' to
+ // avoid claiming behavior the doc doesn't guarantee.
+ if (isFalsyCoerced) {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'unknown', value: null };
+ }
+
+ // {{"string"}}
+ // Bare-mustache string literals never coerce — render literal value.
+ // Doc rows: m7, m8, h7, h8, d4, d5, i2.
+ if (path.type === 'GlimmerStringLiteral') {
+ return { presence: 'present', value: path.value };
+ }
+
+ // {{0}}, {{1}}, {{-1}}, etc.
+ if (path.type === 'GlimmerNumberLiteral') {
+ // {{0}} for boolean kind → omitted (m12 verified for muted).
+ // For numeric kind, t1 verifies {{0}} renders "0" (focusable).
+ // For plain-string, untested.
+ if (path.value === 0 && kind === 'boolean') {
+ return { presence: 'absent', value: null };
+ }
+ return { presence: 'present', value: String(path.value) };
+ }
+
+ // Dynamic path: {{this.x}}, {{x}}, {{(some-helper)}}, etc.
+ // For falsy-coerced kinds, runtime value could be falsy → attribute omitted.
+ // For plain-string, the attribute renders something (even null/undefined coerce
+ // via stringification), but the value isn't statically known.
+ if (isFalsyCoerced) {
+ return { presence: 'unknown', value: null };
+ }
+ return { presence: 'present', value: null };
+}
+
+module.exports = {
+ classifyAttribute,
+ inferAttrKind,
+};
diff --git a/lib/utils/is-native-element.js b/lib/utils/is-native-element.js
index 190374d9bb..ebdad3b9c4 100644
--- a/lib/utils/is-native-element.js
+++ b/lib/utils/is-native-element.js
@@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
* `mathml-tag-names` packages). It is NOT the same as:
*
- * - "native accessibility" / "widget-ness" — see `interactive-roles.js`
- * (aria-query widget taxonomy; an ARIA-tree-semantics question)
- * - "native interactive content" / "focus behavior" — see
- * `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
- * question about which tags can be nested inside what)
+ * - "native accessibility" / "widget-ness" — an ARIA-tree-semantics
+ * question (for example, whether something maps to a widget role)
+ * - "native interactive content" / "focus behavior" — an HTML content-model
+ * question about which elements are considered interactive in the spec
* - "natively focusable" / sequential-focus — see HTML §6.6.3
*
* This util answers only: "is this tag a first-class built-in element of one
* of the three markup-language standards, rather than a component invocation
- * or a shadowed local binding?" Callers compose it with the other utils
- * above when they need a more specific question (see e.g. `template-no-
- * noninteractive-tabindex`, which consults both this and
- * `html-interactive-content`).
+ * or a shadowed local binding?" Callers should combine it with whatever
+ * accessibility, interactivity, or focusability checks they need for more
+ * specific questions.
*
* Returns false for:
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
diff --git a/package.json b/package.json
index ea76c590a3..b75bb22bed 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-ember",
- "version": "13.1.3",
+ "version": "13.2.0",
"description": "ESLint plugin for Ember.js apps",
"keywords": [
"eslint",
@@ -67,15 +67,17 @@
"axobject-query": "^4.1.0",
"css-tree": "^3.0.1",
"editorconfig": "^3.0.2",
- "ember-eslint-parser": "^0.11.2",
+ "ember-eslint-parser": "^0.11.3",
"ember-rfc176-data": "^0.3.18",
"eslint-utils": "^3.0.0",
"estraverse": "^5.3.0",
"html-tags": "^3.3.1",
+ "html-void-elements": "^3.0.0",
"language-tags": "^1.0.9",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"mathml-tag-names": "^4.0.0",
+ "property-information": "^7.1.0",
"requireindex": "^1.2.0",
"snake-case": "^3.0.3",
"svg-tags": "^1.0.0"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3df7119c27..6140b319e9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -24,8 +24,8 @@ importers:
specifier: ^3.0.2
version: 3.0.2
ember-eslint-parser:
- specifier: ^0.11.2
- version: 0.11.2(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3)
+ specifier: ^0.11.3
+ version: 0.11.3(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3)
ember-rfc176-data:
specifier: ^0.3.18
version: 0.3.18
@@ -38,6 +38,9 @@ importers:
html-tags:
specifier: ^3.3.1
version: 3.3.1
+ html-void-elements:
+ specifier: ^3.0.0
+ version: 3.0.0
language-tags:
specifier: ^1.0.9
version: 1.0.9
@@ -50,6 +53,9 @@ importers:
mathml-tag-names:
specifier: ^4.0.0
version: 4.0.0
+ property-information:
+ specifier: ^7.1.0
+ version: 7.1.0
requireindex:
specifier: ^1.2.0
version: 1.2.0
@@ -1755,8 +1761,8 @@ packages:
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
- ember-eslint-parser@0.11.2:
- resolution: {integrity: sha512-q38xuVA6OAYJU9zEyUazW9snG7igRWp62KfvJYoF191DHwzLnYnZzVd8u4iFS86s5RHPH1AAhVHJ+oqVTM3oOQ==}
+ ember-eslint-parser@0.11.3:
+ resolution: {integrity: sha512-tGLDVyemseglpXyt3MMnggWL2ROYglRGoL0lKVy+GySFcrD4YQbt5iyvrY9hcEnN62gmkFLIFRinIq5niqYKXg==}
engines: {node: '>=16.0.0'}
peerDependencies:
'@babel/eslint-parser': ^7.28.6
@@ -1767,8 +1773,8 @@ packages:
'@typescript-eslint/parser':
optional: true
- ember-estree@0.6.2:
- resolution: {integrity: sha512-CeKa6FZ95jp6de+vjhmv2XmOGBB2V95iiH7RqI2av1nU0m1XhFZEG8npoEciklQqPEzFO5OXS41b0mGvXBsJCA==}
+ ember-estree@0.6.3:
+ resolution: {integrity: sha512-76NgApjUCvGHd6eC5BueOSwDdXtyO/+zSrZuKaWdRm3EyIbtE9ysvCOsm1cft8zl6dxdqeJpNpTJ5a1ljVIFMg==}
ember-rfc176-data@0.3.18:
resolution: {integrity: sha512-JtuLoYGSjay1W3MQAxt3eINWXNYYQliK90tLwtb8aeCuQK8zKGCRbBodVIrkcTqshULMnRuTOS6t1P7oQk3g6Q==}
@@ -2313,6 +2319,9 @@ packages:
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
engines: {node: '>=20.10'}
+ html-void-elements@3.0.0:
+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3394,6 +3403,9 @@ packages:
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
engines: {node: '>=10'}
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -5707,12 +5719,12 @@ snapshots:
electron-to-chromium@1.5.344: {}
- ember-eslint-parser@0.11.2(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3):
+ ember-eslint-parser@0.11.3(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@glimmer/syntax': 0.95.0
'@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3)
content-tag: 4.1.1
- ember-estree: 0.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ ember-estree: 0.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
eslint-scope: 9.1.2
html-tags: 5.1.0
mathml-tag-names: 4.0.0
@@ -5725,7 +5737,7 @@ snapshots:
- '@emnapi/runtime'
- typescript
- ember-estree@0.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
+ ember-estree@0.6.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@glimmer/env': 0.1.7
'@glimmer/syntax': 0.95.0
@@ -6447,6 +6459,8 @@ snapshots:
html-tags@5.1.0: {}
+ html-void-elements@3.0.0: {}
+
http-cache-semantics@4.2.0: {}
http-proxy-agent@4.0.1:
@@ -7693,6 +7707,8 @@ snapshots:
err-code: 2.0.3
retry: 0.12.0
+ property-information@7.1.0: {}
+
proto-list@1.2.4: {}
psl@1.15.0:
diff --git a/tests/lib/rules/template-interactive-supports-focus.js b/tests/lib/rules/template-interactive-supports-focus.js
new file mode 100644
index 0000000000..10fc6c911c
--- /dev/null
+++ b/tests/lib/rules/template-interactive-supports-focus.js
@@ -0,0 +1,397 @@
+'use strict';
+
+const rule = require('../../../lib/rules/template-interactive-supports-focus');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-interactive-supports-focus', rule, {
+ valid: [
+ // === Base cases — nothing to flag when there is no interactive role. ===
+ '
',
+ '
',
+ '
hi',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+
+ // === Inherently focusable elements with an interactive role — valid. ===
+ '
',
+ '
x',
+ '
x',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
x',
+ '
',
+ '
',
+ '
',
+
+ // === Interactive role on a non-focusable host but tabindex is present. ===
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ // tabindex="-1" is also sufficient — the role still has a focus target.
+ '
',
+ // 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. ===
+ '
edit
',
+ '
edit
',
+ '
edit
',
+
+ // === Dynamic role — conservatively skipped. ===
+ '
',
+ '
',
+
+ // === Non-interactive roles — outside scope. ===
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ // Unknown / typo roles are not widget-descended → not flagged here
+ // (covered by template-no-invalid-role).
+ '
',
+
+ // === Component invocations — out of scope. ===
+ '
',
+ '
',
+ '
',
+ '
<@foo role="button" />',
+ '
',
+
+ // === Custom / non-DOM elements — not in aria-query's DOM map. ===
+ '
',
+ '
hi',
+
+ // === Non-widget roles (composite, section, etc.) — not in scope here. ===
+ // `form` (role) is structure, not widget; `dialog` is window, not widget.
+ '
',
+ '
',
+
+ // === Role with extra whitespace / multi-token is handled via tabindex. ===
+ '
x
',
+
+ // === Role-fallback resolution per WAI-ARIA §4.1. UAs walk for the first
+ // RECOGNISED role, skipping unknown tokens. So:
+ // - First recognised is non-interactive → rule exempts (no focus req).
+ // - All tokens unknown → no recognised role → rule exempts.
+ // - First recognised is interactive → rule requires focusability.
+ // (That case is tested in invalid[] below.)
+ '
',
+ '
',
+ // All-unknown token list — no recognised role, rule skips.
+ '
',
+
+ // === HTML attribute names are case-insensitive — React-style
+ // `tabIndex` is accepted too. ===
+ '
',
+ '
',
+
+ // === Disabled form controls are not keyboard-focusable per HTML spec,
+ // but `