Skip to content

Commit e17c1b9

Browse files
committed
fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden
1 parent 414d6d5 commit e17c1b9

3 files changed

Lines changed: 186 additions & 11 deletions

File tree

docs/rules/template-no-invalid-interactive.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ Examples of **correct** code for this rule:
4646
</template>
4747
```
4848

49+
## Escape hatches
50+
51+
An element opts out of this rule's handler-on-non-interactive check in two cases:
52+
53+
- **`aria-hidden="true"`** (including valueless / empty / `{{true}}` / `{{"true"}}` forms) — the author has explicitly removed the element from the accessibility tree, so a "non-interactive element with handler" is moot; AT users won't encounter it either way. Explicit `aria-hidden="false"` / `{{false}}` still flags.
54+
- **`role="presentation"` / `role="none"`** — the author asserts the element is decorative. We accept `role="presentation"` and `role="none"` (case-insensitive, first recognized token of a space-separated role list per WAI-ARIA first-recognized-token rule) — a deliberate superset of [jsx-a11y's exact-match `hasPresentationRole`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/hasPresentationRole.js) for consistency with [WAI-ARIA 1.2 §4.1](https://www.w3.org/TR/wai-aria-1.2/#host_general_role) role-fallback semantics.
55+
56+
The valueless `aria-hidden` case (e.g. `<div aria-hidden>`) is [genuinely contested](https://www.scottohara.me/blog/2018/05/05/hidden-vs-none.html) — jsx-a11y, vue-a11y, axe-core, and the WAI-ARIA spec take four different positions on whether it counts as "hidden". This rule leans toward fewer false positives: flagging a handler on an author-decorated element creates friction more often than it catches real bugs.
57+
58+
### Related checks outside this rule's scope
59+
60+
- **`role="presentation"` on focusable elements** — per [WAI-ARIA 1.2 §4.6 Conflict Resolution](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none), browsers ignore `role="presentation"` on focusable elements. [axe-core's `presentation-role-conflict`](https://dequeuniversity.com/rules/axe/4.10/presentation-role-conflict) flags this pattern as an authoring error. This rule does not detect the conflict: the escape hatch check runs before `isInteractive(node)`, so a focusable element with `role="presentation"` returns early and is silently exempted. Interactive-handler cases on plain `<button>` / `<a href>` etc. are still flagged normally when they lack the presentation/none opt-out. If you want to catch the authoring error itself (role=presentation on a focusable element, which has no effect at runtime), layer axe-core or a dedicated rule on top.
61+
- **Click handler on a non-focusable decorated element** — e.g. `<div role="presentation" {{on "click"}}>`. Our escape hatch silences this by design (jsx-a11y-compat). [axe-core's `click-events-have-key-events`](https://dequeuniversity.com/rules/axe/4.10/click-events-have-key-events) is the complementary check. If you want strictness, layer it on top.
62+
4963
## Options
5064

5165
| Name | Type | Default | Description |

lib/rules/template-no-invalid-interactive.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
const { roles } = require('aria-query');
34
const { isNativeElement } = require('../utils/is-native-element');
45
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
56
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
@@ -8,14 +9,71 @@ function hasAttr(node, name) {
89
return node.attributes?.some((a) => a.name === name);
910
}
1011

12+
function findAttr(node, name) {
13+
return node.attributes?.find((a) => a.name === name);
14+
}
15+
1116
function getTextAttr(node, name) {
12-
const attr = node.attributes?.find((a) => a.name === name);
17+
const attr = findAttr(node, name);
1318
if (attr?.value?.type === 'GlimmerTextNode') {
1419
return attr.value.chars;
1520
}
1621
return undefined;
1722
}
1823

24+
// Does this element carry a non-interactive escape hatch that opts it out
25+
// of the interactive-handler check?
26+
// - role="presentation" or role="none": author asserts the element is
27+
// decorative. We find the first recognized ARIA role token in the
28+
// space-separated list (per WAI-ARIA first-recognized-token rule) and
29+
// check whether it is "presentation" or "none" — matching jsx-a11y's
30+
// hasPresentationRole semantics while correctly handling unknown leading
31+
// tokens such as role="foo none".
32+
// - aria-hidden in any plausibly-"hide" form — valueless, empty-string,
33+
// "true" (case-insensitive), `{{true}}`, `{{"true"}}`.
34+
//
35+
// The valueless/empty case is genuinely contested in the ecosystem (see
36+
// PR body: four positions across jsx-a11y / vue-a11y / axe / spec). We
37+
// lean toward fewer false positives — flagging a handler on an author-
38+
// decorated element creates friction more often than it catches real bugs.
39+
// Explicit `aria-hidden="false"` / `{{false}}` still flags.
40+
function hasNonInteractiveEscapeHatch(node) {
41+
const roleAttr = findAttr(node, 'role');
42+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
43+
const tokens = roleAttr.value.chars.trim().toLowerCase().split(/\s+/u);
44+
// WAI-ARIA first-recognized-token: skip unknown tokens, use the first
45+
// one that aria-query recognizes as a valid role.
46+
const firstRecognized = tokens.find((t) => roles.get(t) !== undefined);
47+
if (firstRecognized === 'presentation' || firstRecognized === 'none') {
48+
return true;
49+
}
50+
}
51+
52+
const ariaHidden = findAttr(node, 'aria-hidden');
53+
if (ariaHidden) {
54+
if (!ariaHidden.value) {
55+
return true;
56+
}
57+
if (ariaHidden.value.type === 'GlimmerTextNode') {
58+
const chars = ariaHidden.value.chars.trim().toLowerCase();
59+
if (chars === '' || chars === 'true') {
60+
return true;
61+
}
62+
}
63+
if (ariaHidden.value.type === 'GlimmerMustacheStatement') {
64+
const path = ariaHidden.value.path;
65+
if (path?.type === 'GlimmerBooleanLiteral' && path.value === true) {
66+
return true;
67+
}
68+
if (path?.type === 'GlimmerStringLiteral' && path.value.toLowerCase() === 'true') {
69+
return true;
70+
}
71+
}
72+
}
73+
74+
return false;
75+
}
76+
1977
const DISALLOWED_DOM_EVENTS = new Set([
2078
// Mouse events:
2179
'click',
@@ -143,6 +201,15 @@ module.exports = {
143201
return;
144202
}
145203

204+
// Skip elements that opt out of interactive semantics via
205+
// `role="presentation"` / `role="none"` or `aria-hidden`. These are
206+
// the same escape hatches honored by jsx-a11y
207+
// (`hasPresentationRole` + aria-hidden handling in `isInteractiveElement`)
208+
// and vuejs-accessibility.
209+
if (hasNonInteractiveEscapeHatch(node)) {
210+
return;
211+
}
212+
146213
// Skip if element is interactive
147214
if (isInteractive(node)) {
148215
return;

tests/lib/rules/template-no-invalid-interactive.js

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,40 @@ ruleTester.run('template-no-invalid-interactive', rule, {
117117
// Their a11y contract is author-defined; ESLint can't introspect.
118118
'<template><my-element onclick={{this.handler}}></my-element></template>',
119119
'<template><x-foo {{on "click" this.handler}}></x-foo></template>',
120+
121+
// Non-interactive escape hatches:
122+
// - role="presentation" / role="none" (author-declared decorative);
123+
// - aria-hidden in any plausibly-"hide" form.
124+
// Valueless/empty aria-hidden is contested in the ecosystem (see PR body
125+
// for the four positions); we lean fewer-false-positives and treat it as
126+
// an escape hatch. Explicit aria-hidden="false" / {{false}} still flags.
127+
'<template><div role="presentation" onclick={{this.h}}></div></template>',
128+
'<template><div role="none" onclick={{this.h}}></div></template>',
129+
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
130+
'<template><div role="none" {{action "foo"}}></div></template>',
131+
'<template><div aria-hidden onclick={{this.h}}></div></template>',
132+
'<template><div aria-hidden="" onclick={{this.h}}></div></template>',
133+
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
134+
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
135+
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
136+
'<template><div aria-hidden={{"true"}} onclick={{this.h}}></div></template>',
137+
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
138+
// Case-insensitive / whitespace tolerance on role values.
139+
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
140+
'<template><div role="NONE" onclick={{this.h}}></div></template>',
141+
142+
// DIVERGENCE from jsx-a11y no-static: <a tabindex="0"> without href — jsx-a11y
143+
// still flags it because the anchor has no href. Our rule treats any tabindex
144+
// value as making the element interactive, so this is valid.
145+
'<template><a tabindex="0" onclick={{this.h}}>L</a></template>',
146+
147+
// Non-disallowed handlers — onmouseenter / onmouseleave / oncontextmenu /
148+
// ondrag* are NOT in DISALLOWED_DOM_EVENTS. Aligns with jsx-a11y recommended;
149+
// diverges from jsx-a11y strict (which flags these on non-interactive elements).
150+
'<template><div onmouseenter={{this.h}}></div></template>',
151+
'<template><div onmouseleave={{this.h}}></div></template>',
152+
'<template><div oncontextmenu={{this.h}}></div></template>',
153+
'<template><div ondrag={{this.h}}></div></template>',
120154
],
121155

122156
invalid: [
@@ -219,17 +253,77 @@ ruleTester.run('template-no-invalid-interactive', rule, {
219253
],
220254
},
221255
{
222-
// role="tooltip" is document-structure per WAI-ARIA 1.2 §5.3.3 — NOT
223-
// a widget, so a handler on it is as invalid as a handler on a bare div.
224-
filename: 'test.gjs',
225-
code: '<template><div role="tooltip" onclick={{this.show}}>Tip</div></template>',
256+
// aria-hidden="false" is opt-in to exposure — rule still flags non-interactive + handler.
257+
code: '<template><div aria-hidden="false" onclick={{this.h}}></div></template>',
226258
output: null,
227-
errors: [
228-
{
229-
messageId: 'noInvalidInteractive',
230-
data: { tagName: 'div', handler: 'onclick' },
231-
},
232-
],
259+
errors: [{ messageId: 'noInvalidInteractive' }],
260+
},
261+
{
262+
code: '<template><div aria-hidden={{false}} onclick={{this.h}}></div></template>',
263+
output: null,
264+
errors: [{ messageId: 'noInvalidInteractive' }],
265+
},
266+
{
267+
// `role="note"` is neither presentation/none nor an interactive role.
268+
code: '<template><div role="note" onclick={{this.h}}></div></template>',
269+
output: null,
270+
errors: [{ messageId: 'noInvalidInteractive' }],
271+
},
272+
{
273+
// DIVERGENCE from jsx-a11y no-static: aria-label on section makes it VALID
274+
// in jsx-a11y's no-static rule (treated as interactive-signal), but our rule
275+
// determines interactivity from element type / role alone, not aria-label.
276+
code: '<template><section onclick={{this.h}} aria-label="Nav area"></section></template>',
277+
output: null,
278+
errors: [{ messageId: 'noInvalidInteractive' }],
279+
},
280+
{
281+
// DIVERGENCE from jsx-a11y: menuitem and datalist are in jsx-a11y's
282+
// alwaysInteractive set but not in our NATIVE_INTERACTIVE_ELEMENTS — flagged.
283+
code: '<template><menuitem onclick={{this.h}}></menuitem></template>',
284+
output: null,
285+
errors: [{ messageId: 'noInvalidInteractive' }],
286+
},
287+
{
288+
code: '<template><datalist onclick={{this.h}}></datalist></template>',
289+
output: null,
290+
errors: [{ messageId: 'noInvalidInteractive' }],
291+
},
292+
{
293+
// DIVERGENCE from jsx-a11y: <input type="hidden"> is VALID in jsx-a11y
294+
// (treated as interactive). Our rule explicitly excludes hidden inputs from
295+
// native-interactive — no user-facing surface, so the handler is invalid.
296+
code: '<template><input type="hidden" onclick={{this.h}} /></template>',
297+
output: null,
298+
errors: [{ messageId: 'noInvalidInteractive' }],
299+
},
300+
],
301+
});
302+
303+
const hbsRuleTester = new RuleTester({
304+
parser: require.resolve('ember-eslint-parser/hbs'),
305+
});
306+
307+
hbsRuleTester.run('template-no-invalid-interactive', rule, {
308+
valid: [
309+
// Escape hatches: role="presentation" / aria-hidden suppresses the check.
310+
'<div role="presentation" onclick={{this.h}}></div>',
311+
'<div role="none" {{on "click" this.h}}></div>',
312+
'<div aria-hidden="true" onclick={{this.h}}></div>',
313+
'<div aria-hidden {{on "click" this.h}}></div>',
314+
],
315+
invalid: [
316+
{
317+
// role="note" is not presentation/none and not interactive — still flags.
318+
code: '<div role="note" onclick={{this.h}}></div>',
319+
output: null,
320+
errors: [{ messageId: 'noInvalidInteractive' }],
321+
},
322+
{
323+
// aria-hidden="false" opts in to exposure — handler still flagged.
324+
code: '<div aria-hidden="false" onclick={{this.h}}></div>',
325+
output: null,
326+
errors: [{ messageId: 'noInvalidInteractive' }],
233327
},
234328
],
235329
});

0 commit comments

Comments
 (0)