Skip to content

Commit d7f6223

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 81c5acb commit d7f6223

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
@@ -20,32 +20,19 @@ function getTextAttr(node, name) {
2020
return undefined;
2121
}
2222

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

5845
const ariaHidden = findAttr(node, 'aria-hidden');
59-
if (ariaHidden?.value?.type === 'GlimmerTextNode') {
60-
if (ariaHidden.value.chars.trim().toLowerCase() === 'true') {
46+
if (ariaHidden) {
47+
if (!ariaHidden.value) {
6148
return true;
6249
}
63-
}
64-
if (isMustacheLiteralTrue(ariaHidden)) {
65-
return true;
50+
if (ariaHidden.value.type === 'GlimmerTextNode') {
51+
const chars = ariaHidden.value.chars.trim().toLowerCase();
52+
if (chars === '' || chars === 'true') {
53+
return true;
54+
}
55+
}
56+
if (ariaHidden.value.type === 'GlimmerMustacheStatement') {
57+
const path = ariaHidden.value.path;
58+
if (path?.type === 'GlimmerBooleanLiteral' && path.value === true) {
59+
return true;
60+
}
61+
if (path?.type === 'GlimmerStringLiteral' && path.value.toLowerCase() === 'true') {
62+
return true;
63+
}
64+
}
6665
}
6766

6867
return false;

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

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

121121
// Non-interactive escape hatches:
122-
// - role="presentation"/"none" (per WAI-ARIA 1.2 §4.6 presentation-role
123-
// semantics — decorative element, no exposed semantics);
124-
// - aria-hidden="true" or {{true}} (per WAI-ARIA 1.2 §aria-hidden).
125-
// Valueless/empty aria-hidden resolves to default `undefined` per
126-
// §6.6 and does NOT qualify — flagged below in invalid.
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.
127127
'<template><div role="presentation" onclick={{this.h}}></div></template>',
128128
'<template><div role="none" onclick={{this.h}}></div></template>',
129129
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
130130
'<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>',
131133
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
134+
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
132135
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
136+
'<template><div aria-hidden={{"true"}} onclick={{this.h}}></div></template>',
133137
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
134-
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
135138
// Case-insensitive / whitespace tolerance on role values.
136139
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
137140
'<template><div role="NONE" onclick={{this.h}}></div></template>',
@@ -237,21 +240,13 @@ ruleTester.run('template-no-invalid-interactive', rule, {
237240
],
238241
},
239242
{
240-
// `aria-hidden="false"` does NOT opt out — element is still exposed.
243+
// Explicit `aria-hidden="false"` is the unambiguous opt-out — still flags.
241244
code: '<template><div aria-hidden="false" onclick={{this.h}}></div></template>',
242245
output: null,
243246
errors: [{ messageId: 'noInvalidInteractive' }],
244247
},
245248
{
246-
// Valueless aria-hidden resolves to default `undefined` per WAI-ARIA
247-
// 1.2 §6.6 — not an opt-out.
248-
code: '<template><div aria-hidden onclick={{this.h}}></div></template>',
249-
output: null,
250-
errors: [{ messageId: 'noInvalidInteractive' }],
251-
},
252-
{
253-
// Empty-string aria-hidden — same default-undefined resolution.
254-
code: '<template><div aria-hidden="" onclick={{this.h}}></div></template>',
249+
code: '<template><div aria-hidden={{false}} onclick={{this.h}}></div></template>',
255250
output: null,
256251
errors: [{ messageId: 'noInvalidInteractive' }],
257252
},

0 commit comments

Comments
 (0)