Skip to content

Commit 84108aa

Browse files
committed
fix(template-no-invalid-interactive): lean toward fewer positives on valueless aria-hidden
The valueless / empty-string aria-hidden case is genuinely contested in the ecosystem — four positions exist (jsx-a11y / vue-a11y / axe-core / WAI-ARIA spec), see PR body. Rather than pick one interpretation and accept its false positives, this rule leans fewer-false-positives: any aria-hidden form that could plausibly mean "hide this" opts the element out of the interactive-handler check. Escape hatch includes: - valueless `<div aria-hidden onclick>` - empty `<div aria-hidden="" onclick>` - `aria-hidden="true"` / "TRUE" / "True" (ASCII case-insensitive) - `aria-hidden={{true}}` / `{{"true"}}` (case-insensitive) Not an escape hatch (still flags): - `aria-hidden="false"` / `{{false}}` / `{{"false"}}` — explicit opt-in to the check. Reverses the previous spec-first direction on the valueless/empty case. Rationale: flagging a handler on an author-decorated element creates friction more often than it catches real bugs. The explicit `aria-hidden="true"` cases, which ARE clearly hidden per spec, remain escape hatches. Drops unused `isMustacheLiteralTrue` helper (inlined into the escape- hatch function).
1 parent 44f0e94 commit 84108aa

2 files changed

Lines changed: 41 additions & 47 deletions

File tree

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

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,19 @@ function getTextAttr(node, name) {
1616
return undefined;
1717
}
1818

19-
// True iff the attribute's mustache value is the literal boolean `true` —
20-
// e.g. `aria-hidden={{true}}`. We only treat the unambiguous boolean-literal
21-
// path; any other expression (helper call, path reference, etc.) is left to
22-
// runtime and not considered a static escape hatch.
23-
function isMustacheLiteralTrue(attr) {
24-
if (attr?.value?.type !== 'GlimmerMustacheStatement') {
25-
return false;
26-
}
27-
const path = attr.value.path;
28-
return path?.type === 'GlimmerBooleanLiteral' && path.value === true;
29-
}
30-
31-
// Does this element carry a non-interactive escape hatch that peer a11y rules
32-
// honor as an opt-out of interactivity checks?
33-
// - role="presentation" or role="none": the author has asserted the element
34-
// is decorative. Per WAI-ARIA 1.2 §4.6, user agents MUST ignore these
35-
// roles on focusable elements and expose the implicit role instead; but
36-
// for non-focusable static elements with handlers, the author's intent
37-
// to opt out of a11y semantics is what this rule honors. This rule
38-
// accepts the first token of a space-separated role list (a superset of
39-
// jsx-a11y's `isPresentationRole`, which does exact match).
40-
// - aria-hidden="true" (ASCII case-insensitive) or mustache-literal
41-
// `{{true}}`. Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, valueless
42-
// and empty-string aria-hidden resolve to default `undefined` — NOT true
43-
// — so bare `<div aria-hidden>` does NOT qualify. Only an explicit true
44-
// opts out.
19+
// Does this element carry a non-interactive escape hatch that opts it out
20+
// of the interactive-handler check?
21+
// - role="presentation" or role="none": author asserts the element is
22+
// decorative. We accept the first token of a space-separated role list
23+
// (a superset of jsx-a11y's exact-match `isPresentationRole`).
24+
// - aria-hidden in any plausibly-"hide" form — valueless, empty-string,
25+
// "true" (case-insensitive), `{{true}}`, `{{"true"}}`.
26+
//
27+
// The valueless/empty case is genuinely contested in the ecosystem (see
28+
// PR body: four positions across jsx-a11y / vue-a11y / axe / spec). We
29+
// lean toward fewer false positives — flagging a handler on an author-
30+
// decorated element creates friction more often than it catches real bugs.
31+
// Explicit `aria-hidden="false"` / `{{false}}` still flags.
4532
function hasNonInteractiveEscapeHatch(node) {
4633
const roleAttr = findAttr(node, 'role');
4734
if (roleAttr?.value?.type === 'GlimmerTextNode') {
@@ -52,13 +39,25 @@ function hasNonInteractiveEscapeHatch(node) {
5239
}
5340

5441
const ariaHidden = findAttr(node, 'aria-hidden');
55-
if (ariaHidden?.value?.type === 'GlimmerTextNode') {
56-
if (ariaHidden.value.chars.trim().toLowerCase() === 'true') {
42+
if (ariaHidden) {
43+
if (!ariaHidden.value) {
5744
return true;
5845
}
59-
}
60-
if (isMustacheLiteralTrue(ariaHidden)) {
61-
return true;
46+
if (ariaHidden.value.type === 'GlimmerTextNode') {
47+
const chars = ariaHidden.value.chars.trim().toLowerCase();
48+
if (chars === '' || chars === 'true') {
49+
return true;
50+
}
51+
}
52+
if (ariaHidden.value.type === 'GlimmerMustacheStatement') {
53+
const path = ariaHidden.value.path;
54+
if (path?.type === 'GlimmerBooleanLiteral' && path.value === true) {
55+
return true;
56+
}
57+
if (path?.type === 'GlimmerStringLiteral' && path.value.toLowerCase() === 'true') {
58+
return true;
59+
}
60+
}
6261
}
6362

6463
return false;

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

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,22 @@ ruleTester.run('template-no-invalid-interactive', rule, {
113113
'<template><x-foo {{on "click" this.handler}}></x-foo></template>',
114114

115115
// Non-interactive escape hatches:
116-
// - role="presentation"/"none" (per WAI-ARIA 1.2 §4.6 presentation-role
117-
// semantics — decorative element, no exposed semantics);
118-
// - aria-hidden="true" or {{true}} (per WAI-ARIA 1.2 §aria-hidden).
119-
// Valueless/empty aria-hidden resolves to default `undefined` per
120-
// §6.6 and does NOT qualify — flagged below in invalid.
116+
// - role="presentation" / role="none" (author-declared decorative);
117+
// - aria-hidden in any plausibly-"hide" form.
118+
// Valueless/empty aria-hidden is contested in the ecosystem (see PR body
119+
// for the four positions); we lean fewer-false-positives and treat it as
120+
// an escape hatch. Explicit aria-hidden="false" / {{false}} still flags.
121121
'<template><div role="presentation" onclick={{this.h}}></div></template>',
122122
'<template><div role="none" onclick={{this.h}}></div></template>',
123123
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
124124
'<template><div role="none" {{action "foo"}}></div></template>',
125+
'<template><div aria-hidden onclick={{this.h}}></div></template>',
126+
'<template><div aria-hidden="" onclick={{this.h}}></div></template>',
125127
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
128+
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
126129
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
130+
'<template><div aria-hidden={{"true"}} onclick={{this.h}}></div></template>',
127131
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
128-
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
129132
// Case-insensitive / whitespace tolerance on role values.
130133
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
131134
'<template><div role="NONE" onclick={{this.h}}></div></template>',
@@ -231,21 +234,13 @@ ruleTester.run('template-no-invalid-interactive', rule, {
231234
],
232235
},
233236
{
234-
// `aria-hidden="false"` does NOT opt out — element is still exposed.
237+
// Explicit `aria-hidden="false"` is the unambiguous opt-out — still flags.
235238
code: '<template><div aria-hidden="false" onclick={{this.h}}></div></template>',
236239
output: null,
237240
errors: [{ messageId: 'noInvalidInteractive' }],
238241
},
239242
{
240-
// Valueless aria-hidden resolves to default `undefined` per WAI-ARIA
241-
// 1.2 §6.6 — not an opt-out.
242-
code: '<template><div aria-hidden onclick={{this.h}}></div></template>',
243-
output: null,
244-
errors: [{ messageId: 'noInvalidInteractive' }],
245-
},
246-
{
247-
// Empty-string aria-hidden — same default-undefined resolution.
248-
code: '<template><div aria-hidden="" onclick={{this.h}}></div></template>',
243+
code: '<template><div aria-hidden={{false}} onclick={{this.h}}></div></template>',
249244
output: null,
250245
errors: [{ messageId: 'noInvalidInteractive' }],
251246
},

0 commit comments

Comments
 (0)