Skip to content

Commit 017c11b

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 f400aca commit 017c11b

3 files changed

Lines changed: 629 additions & 1 deletion

File tree

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

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

7+
function findAttr(node, name) {
8+
return node.attributes?.find((a) => a.name === name);
9+
}
10+
711
function getTextAttr(node, name) {
8-
const attr = node.attributes?.find((a) => a.name === name);
12+
const attr = findAttr(node, name);
913
if (attr?.value?.type === 'GlimmerTextNode') {
1014
return attr.value.chars;
1115
}
1216
return undefined;
1317
}
1418

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

230+
// Skip elements that opt out of interactive semantics via
231+
// `role="presentation"` / `role="none"` or `aria-hidden`. These are
232+
// the same escape hatches honored by jsx-a11y
233+
// (`hasPresentationRole` + aria-hidden handling in `isInteractiveElement`)
234+
// and vuejs-accessibility.
235+
if (hasNonInteractiveEscapeHatch(node)) {
236+
return;
237+
}
238+
180239
// Skip if element is interactive
181240
if (isInteractive(node)) {
182241
return;

0 commit comments

Comments
 (0)