Skip to content

Commit d924e2a

Browse files
committed
feat: add template-interactive-supports-focus
1 parent 414d6d5 commit d924e2a

4 files changed

Lines changed: 711 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
258258

259259
| Name                                            | Description | 💼 | 🔧 | 💡 |
260260
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
261+
| [template-interactive-supports-focus](docs/rules/template-interactive-supports-focus.md) | require elements with an interactive ARIA role to be focusable | | | |
261262
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
262263
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
263264
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# ember/template-interactive-supports-focus
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Require elements with an interactive ARIA role to be focusable.
6+
7+
When an author adds `role="button"` (or any other interactive widget role) to a `<div>`, they promise keyboard and screen-reader users that the element behaves like that widget. That promise only holds if the element is reachable by keyboard — either because it is inherently focusable (a real `<button>`, an anchor with `href`, a form control, etc.) or because it has a `tabindex`.
8+
9+
This rule flags elements that carry an interactive ARIA role but have no focus affordance.
10+
11+
## ⚠️ Divergence from peer plugins — role-gated, not handler-gated
12+
13+
All three peer plugins implement the equivalent rule as **handler-gated** — they only flag `<div role="button">` when an interactive event handler (`onClick` / `@click` / `(click)`) is also present:
14+
15+
- [`jsx-a11y/interactive-supports-focus`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/interactive-supports-focus.md)
16+
- [`vuejs-accessibility/interactive-supports-focus`](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/interactive-supports-focus.md)
17+
- [`@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)
18+
19+
**This rule is role-gated — it flags on role alone**, regardless of handler presence. Shapes like `<div role="button">x</div>` 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).
20+
21+
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.
22+
23+
## Examples
24+
25+
This rule **forbids** the following:
26+
27+
```gjs
28+
<template>
29+
{{! role without tabindex on a non-focusable host }}
30+
<div role="button">Click</div>
31+
<span role="link">Visit</span>
32+
<div role="checkbox" {{on "click" this.toggle}}></div>
33+
34+
{{! anchor / area without href is not inherently focusable }}
35+
<a role="button">x</a>
36+
37+
{{! hidden input loses its focus affordance }}
38+
<input type="hidden" role="button" />
39+
40+
{{! contenteditable="false" explicitly opts out of focus }}
41+
<div role="textbox" contenteditable="false">x</div>
42+
</template>
43+
```
44+
45+
This rule **allows** the following:
46+
47+
```gjs
48+
<template>
49+
{{! Inherently focusable hosts }}
50+
<button role="button">x</button>
51+
<a href="/next" role="link">Next</a>
52+
<input role="combobox" />
53+
54+
{{! Any tabindex satisfies the focus requirement }}
55+
<div role="button" tabindex="0"></div>
56+
<div role="menuitem" tabindex="-1"></div>
57+
<div role="button" tabindex={{this.ti}}></div>
58+
59+
{{! contenteditable makes an element focusable }}
60+
<div role="textbox" contenteditable="true">Edit</div>
61+
62+
{{! Dynamic role — conservatively skipped }}
63+
<div role={{this.role}}></div>
64+
65+
{{! Non-widget roles are outside scope }}
66+
<div role="region"></div>
67+
68+
{{! Component invocations — out of scope }}
69+
<MyButton role="button" />
70+
</template>
71+
```
72+
73+
## Scope notes
74+
75+
- **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).
76+
- **Component invocations** (PascalCase, `@arg`, `this.x`, `foo.bar`, `foo::bar`) are skipped — their rendered output is opaque to the linter.
77+
- **Custom elements** not present in aria-query's DOM map are skipped.
78+
- **Dynamic role values** (`role={{this.role}}`) are conservatively skipped.
79+
- **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.
80+
81+
## References
82+
83+
- [WAI-ARIA Authoring Practices — Keyboard Interaction](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
84+
- [MDN — ARIA: button role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/button_role)
85+
- [`interactive-supports-focus` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/interactive-supports-focus.md)
86+
- [`interactive-supports-focus` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/interactive-supports-focus.md)
87+
- [`interactive-supports-focus` — angular-eslint](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/interactive-supports-focus.md)
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
'use strict';
2+
3+
const { dom, roles } = require('aria-query');
4+
5+
// Interactive ARIA roles — non-abstract roles that descend from `widget`, plus
6+
// `toolbar` (per jsx-a11y's convention: toolbar behaves as a widget even
7+
// though it is modelled as `structure` in the ARIA taxonomy).
8+
const INTERACTIVE_ROLES = buildInteractiveRoleSet();
9+
10+
function buildInteractiveRoleSet() {
11+
const result = new Set();
12+
for (const [role, def] of roles) {
13+
if (def.abstract) {
14+
continue;
15+
}
16+
const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget'));
17+
if (descendsFromWidget) {
18+
result.add(role);
19+
}
20+
}
21+
result.add('toolbar');
22+
return result;
23+
}
24+
25+
// Tags whose *default* semantics expose focus. `a`/`area` also need `href`,
26+
// and `audio`/`video` need `controls`; these are handled as special cases.
27+
const ALWAYS_FOCUSABLE_TAGS = new Set([
28+
'button',
29+
'select',
30+
'textarea',
31+
'summary',
32+
'iframe',
33+
'object',
34+
'embed',
35+
]);
36+
37+
function findAttr(node, name) {
38+
// HTML attribute names are case-insensitive. Normalize both sides so that
39+
// `TABINDEX` / `Role` etc. match the same lookup as lowercase.
40+
const target = name.toLowerCase();
41+
return node.attributes?.find((a) => a.name?.toLowerCase() === target);
42+
}
43+
44+
function getTextAttrValue(attr) {
45+
if (attr?.value?.type === 'GlimmerTextNode') {
46+
return attr.value.chars;
47+
}
48+
return undefined;
49+
}
50+
51+
// PascalCase (`Foo`), argument-invocation (`@foo`), path on `this.`, dotted
52+
// path (`foo.bar`), or named-block-style (`foo::bar`). Mirrors the pattern
53+
// used across other template-* rules until a shared utility lands.
54+
function isComponentInvocation(tag) {
55+
if (!tag) {
56+
return false;
57+
}
58+
return (
59+
/^[A-Z]/.test(tag) ||
60+
tag.startsWith('@') ||
61+
tag.startsWith('this.') ||
62+
tag.includes('.') ||
63+
tag.includes('::')
64+
);
65+
}
66+
67+
// Form controls that accept a `disabled` attribute. Per HTML spec a disabled
68+
// form control is not keyboard-focusable, so `disabled` suppresses the
69+
// inherent-focusability we'd otherwise grant the tag.
70+
const DISABLABLE_FORM_CONTROLS = new Set(['button', 'input', 'select', 'textarea', 'fieldset']);
71+
72+
// Is the element inherently focusable without needing tabindex?
73+
function isInherentlyFocusable(node) {
74+
const tag = node.tag?.toLowerCase();
75+
76+
// Disabled form controls are not keyboard-focusable per HTML spec.
77+
if (DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled')) {
78+
return false;
79+
}
80+
81+
if (ALWAYS_FOCUSABLE_TAGS.has(tag)) {
82+
return true;
83+
}
84+
85+
if (tag === 'input') {
86+
const type = getTextAttrValue(findAttr(node, 'type'));
87+
// type="hidden" has no focus affordance; everything else is focusable.
88+
// HTML type values are ASCII case-insensitive and may carry incidental
89+
// whitespace; normalize before comparison.
90+
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
91+
}
92+
93+
if ((tag === 'a' || tag === 'area') && findAttr(node, 'href')) {
94+
return true;
95+
}
96+
97+
if ((tag === 'audio' || tag === 'video') && findAttr(node, 'controls')) {
98+
return true;
99+
}
100+
101+
return false;
102+
}
103+
104+
// Does the element have a `contenteditable` attribute that is truthy?
105+
// Bare attribute (no value) and anything other than explicit "false" counts
106+
// as truthy, matching HTML semantics.
107+
function isContentEditable(node) {
108+
const attr = findAttr(node, 'contenteditable');
109+
if (!attr) {
110+
return false;
111+
}
112+
// Valueless attribute: parser models this as no `value` or a null value.
113+
if (attr.value === null || attr.value === undefined) {
114+
return true;
115+
}
116+
// Dynamic value (mustache/concat) — treat as truthy; we cannot prove otherwise.
117+
if (attr.value.type !== 'GlimmerTextNode') {
118+
return true;
119+
}
120+
return attr.value.chars.toLowerCase() !== 'false';
121+
}
122+
123+
// Return the first RECOGNISED role token that's interactive — or null if no
124+
// recognised interactive role appears in the list. Per WAI-ARIA §4.1, space-
125+
// separated role tokens are a fallback list: UAs walk the list for the first
126+
// role they implement, skipping unknown tokens. So `role="xxyxyz button"`
127+
// resolves to `button`; the rule should treat that as interactive even
128+
// though the author put an unknown token first. Dynamic role values return
129+
// `{ dynamic: true }` so the caller can conservatively skip.
130+
function getInteractiveRole(node) {
131+
const attr = findAttr(node, 'role');
132+
if (!attr) {
133+
return { role: null };
134+
}
135+
if (attr.value?.type !== 'GlimmerTextNode') {
136+
return { dynamic: true };
137+
}
138+
const tokens = attr.value.chars.trim().toLowerCase().split(/\s+/u);
139+
for (const token of tokens) {
140+
if (!roles.has(token)) {
141+
continue; // unknown token — UAs skip per §4.1 fallback semantics
142+
}
143+
if (INTERACTIVE_ROLES.has(token)) {
144+
return { role: token };
145+
}
146+
// First recognised role is non-interactive — subsequent tokens are just
147+
// graceful-degradation fallbacks for this non-interactive intent.
148+
return { role: null };
149+
}
150+
return { role: null };
151+
}
152+
153+
/** @type {import('eslint').Rule.RuleModule} */
154+
module.exports = {
155+
meta: {
156+
type: 'problem',
157+
docs: {
158+
description: 'require elements with an interactive ARIA role to be focusable',
159+
category: 'Accessibility',
160+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-interactive-supports-focus.md',
161+
templateMode: 'both',
162+
},
163+
fixable: null,
164+
schema: [],
165+
messages: {
166+
focusable:
167+
'Element <{{tag}}> has interactive role "{{role}}" but is not focusable — add a `tabindex` or use an inherently focusable element.',
168+
},
169+
},
170+
171+
create(context) {
172+
return {
173+
GlimmerElementNode(node) {
174+
const tag = node.tag?.toLowerCase();
175+
if (!tag) {
176+
return;
177+
}
178+
179+
// Skip component invocations — they may render anything.
180+
if (isComponentInvocation(node.tag)) {
181+
return;
182+
}
183+
184+
// Skip unknown / custom elements (not in aria-query's DOM map).
185+
if (!dom.has(tag)) {
186+
return;
187+
}
188+
189+
const { role, dynamic } = getInteractiveRole(node);
190+
if (dynamic) {
191+
return;
192+
}
193+
if (!role) {
194+
return;
195+
}
196+
197+
// Already focusable by default?
198+
if (isInherentlyFocusable(node)) {
199+
return;
200+
}
201+
202+
// Any tabindex — static or dynamic — satisfies the focus requirement,
203+
// EXCEPT on elements that HTML removes from the tab order regardless:
204+
// - disabled form controls (HTML §4.10.18.5) — the disabled state
205+
// removes the element from sequential focus navigation.
206+
// - <input type="hidden"> — not visible, not focusable.
207+
// tabindex on these is ignored by the UA; the a11y conflict the rule
208+
// targets still exists.
209+
// HTML attribute names are case-insensitive, so accept `tabindex` or
210+
// any other casing (e.g. `tabIndex`, the React-style camelCase).
211+
const hasTabindex = node.attributes?.some((a) => a.name?.toLowerCase() === 'tabindex');
212+
if (hasTabindex) {
213+
const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
214+
let hiddenInput = false;
215+
if (tag === 'input') {
216+
const type = getTextAttrValue(findAttr(node, 'type'));
217+
hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
218+
}
219+
if (!disabled && !hiddenInput) {
220+
return;
221+
}
222+
}
223+
224+
// contenteditable also makes an element focusable, with the same
225+
// HTML-spec carve-outs as tabindex: the UA ignores it on disabled
226+
// form controls (HTML §4.10.18.5) and on <input type="hidden">
227+
// (no rendered element to edit), so the a11y conflict still stands.
228+
if (isContentEditable(node)) {
229+
const disabled = DISABLABLE_FORM_CONTROLS.has(tag) && findAttr(node, 'disabled');
230+
let hiddenInput = false;
231+
if (tag === 'input') {
232+
const type = getTextAttrValue(findAttr(node, 'type'));
233+
hiddenInput = typeof type === 'string' && type.trim().toLowerCase() === 'hidden';
234+
}
235+
if (!disabled && !hiddenInput) {
236+
return;
237+
}
238+
}
239+
240+
context.report({
241+
node,
242+
messageId: 'focusable',
243+
data: { tag: node.tag, role },
244+
});
245+
},
246+
};
247+
},
248+
};

0 commit comments

Comments
 (0)