Skip to content

Commit ec91ed0

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 24882a3 commit ec91ed0

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
@@ -2,14 +2,64 @@ function hasAttr(node, name) {
22
return node.attributes?.some((a) => a.name === name);
33
}
44

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

17+
// True iff the attribute's mustache value is the literal boolean `true` —
18+
// e.g. `aria-hidden={{true}}`. We only treat the unambiguous boolean-literal
19+
// path; any other expression (helper call, path reference, etc.) is left to
20+
// runtime and not considered a static escape hatch.
21+
function isMustacheLiteralTrue(attr) {
22+
if (attr?.value?.type !== 'GlimmerMustacheStatement') {
23+
return false;
24+
}
25+
const path = attr.value.path;
26+
return path?.type === 'GlimmerBooleanLiteral' && path.value === true;
27+
}
28+
29+
// Does this element carry a non-interactive escape hatch that peer a11y rules
30+
// (jsx-a11y, vuejs-accessibility) honor as an opt-out of interactivity checks?
31+
// - role="presentation" / role="none" (case-insensitive, trimmed; first token
32+
// of a space-separated role list, matching jsx-a11y's `hasPresentationRole`
33+
// / ARIA's role fallback semantics).
34+
// - aria-hidden as a boolean attribute (bare), as the literal string "true",
35+
// or as the mustache-literal `{{true}}`. aria-hidden="false" does NOT
36+
// qualify.
37+
function hasNonInteractiveEscapeHatch(node) {
38+
const roleAttr = findAttr(node, 'role');
39+
if (roleAttr?.value?.type === 'GlimmerTextNode') {
40+
const token = roleAttr.value.chars.trim().toLowerCase().split(/\s+/u)[0];
41+
if (token === 'presentation' || token === 'none') {
42+
return true;
43+
}
44+
}
45+
46+
const ariaHidden = findAttr(node, 'aria-hidden');
47+
if (ariaHidden) {
48+
// Bare `aria-hidden` (no value) is stored with an empty-chars GlimmerTextNode.
49+
if (ariaHidden.value?.type === 'GlimmerTextNode') {
50+
const chars = ariaHidden.value.chars;
51+
if (chars === '' || chars.trim().toLowerCase() === 'true') {
52+
return true;
53+
}
54+
}
55+
if (isMustacheLiteralTrue(ariaHidden)) {
56+
return true;
57+
}
58+
}
59+
60+
return false;
61+
}
62+
1363
const DISALLOWED_DOM_EVENTS = new Set([
1464
// Mouse events:
1565
'click',
@@ -174,6 +224,15 @@ module.exports = {
174224
return;
175225
}
176226

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

0 commit comments

Comments
 (0)