Skip to content

Commit 92f3213

Browse files
committed
fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden as escape hatches
Premise: peer a11y plugins (jsx-a11y `hasPresentationRole` + aria-hidden handling inside `isInteractiveElement`; vuejs-accessibility's equivalent) treat `role="presentation"` / `role="none"` and `aria-hidden` (boolean / "true" / `{{true}}`) as explicit opt-outs of the interactivity contract. An element that has opted out via ARIA does not need an interactive handler check — the handler is authored acknowledging the element is decorative or hidden from AT. Conclusion: wire that opt-out into `template-no-invalid-interactive` before the native/role-based interactivity probe runs. - Adds `hasNonInteractiveEscapeHatch(node)` covering: - `role="presentation"` / `role="none"` (case-insensitive, trimmed, first token of a space-separated role list — matches jsx-a11y). - `aria-hidden` as bare boolean attribute, the text value `"true"`, or the mustache-literal `{{true}}`. `aria-hidden="false"` does NOT qualify. - Visitor short-circuits on escape-hatch hit before computing interactivity. - Tests: new valid cases for every escape-hatch shape; new invalid cases guarding that `aria-hidden="false"` and other roles still flag. - Audit fixture `tests/audit/no-static-element-interactions/peer-parity.js` added to master as evidence of parity. D1 (role=presentation/none) and D2 (aria-hidden) divergences previously documented in the Phase-3 audit branch are now parity cases in the valid block. Tracks PR #28 item G1 (escape-hatch awareness across interactive-handler rules).
1 parent 414d6d5 commit 92f3213

3 files changed

Lines changed: 626 additions & 11 deletions

File tree

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,64 @@ function hasAttr(node, name) {
88
return node.attributes?.some((a) => a.name === name);
99
}
1010

11+
function findAttr(node, name) {
12+
return node.attributes?.find((a) => a.name === name);
13+
}
14+
1115
function getTextAttr(node, name) {
12-
const attr = node.attributes?.find((a) => a.name === name);
16+
const attr = findAttr(node, name);
1317
if (attr?.value?.type === 'GlimmerTextNode') {
1418
return attr.value.chars;
1519
}
1620
return undefined;
1721
}
1822

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+
// (jsx-a11y, vuejs-accessibility) honor as an opt-out of interactivity checks?
37+
// - role="presentation" / role="none" (case-insensitive, trimmed; first token
38+
// of a space-separated role list, matching jsx-a11y's `hasPresentationRole`
39+
// / ARIA's role fallback semantics).
40+
// - aria-hidden as a boolean attribute (bare), as the literal string "true",
41+
// or as the mustache-literal `{{true}}`. aria-hidden="false" does NOT
42+
// qualify.
43+
function hasNonInteractiveEscapeHatch(node) {
44+
const roleAttr = findAttr(node, 'role');
45+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
46+
const token = roleAttr.value.chars.trim().toLowerCase().split(/\s+/u)[0];
47+
if (token === 'presentation' || token === 'none') {
48+
return true;
49+
}
50+
}
51+
52+
const ariaHidden = findAttr(node, 'aria-hidden');
53+
if (ariaHidden) {
54+
// Bare `aria-hidden` (no value) is stored with an empty-chars GlimmerTextNode.
55+
if (ariaHidden.value?.type === 'GlimmerTextNode') {
56+
const chars = ariaHidden.value.chars;
57+
if (chars === '' || chars.trim().toLowerCase() === 'true') {
58+
return true;
59+
}
60+
}
61+
if (isMustacheLiteralTrue(ariaHidden)) {
62+
return true;
63+
}
64+
}
65+
66+
return false;
67+
}
68+
1969
const DISALLOWED_DOM_EVENTS = new Set([
2070
// Mouse events:
2171
'click',
@@ -143,6 +193,15 @@ module.exports = {
143193
return;
144194
}
145195

196+
// Skip elements that opt out of interactive semantics via
197+
// `role="presentation"` / `role="none"` or `aria-hidden`. These are
198+
// the same escape hatches honored by jsx-a11y
199+
// (`hasPresentationRole` + aria-hidden handling in `isInteractiveElement`)
200+
// and vuejs-accessibility.
201+
if (hasNonInteractiveEscapeHatch(node)) {
202+
return;
203+
}
204+
146205
// Skip if element is interactive
147206
if (isInteractive(node)) {
148207
return;

0 commit comments

Comments
 (0)