Skip to content

Commit 44f0e94

Browse files
committed
fix(template-no-invalid-interactive): align escape-hatch with ARIA spec + correct peer-plugin claims
Two corrections to the previous revision: 1. Valueless / empty-string `aria-hidden` is no longer treated as a non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string value resolves to the default `undefined` — NOT `true`. Only an explicit `aria-hidden="true"` (ASCII case-insensitive) or mustache-literal `{{true}}` opts out. This matches ember-cli#2717 / #19's spec-first resolution. 2. Code comment corrections. jsx-a11y's util is named `isPresentationRole`, not `hasPresentationRole`. The comment also claimed jsx-a11y's `isPresentationRole` does "first token of a space-separated role list" — it does not (jsx-a11y does plain `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our first-token behavior is a deliberate superset, not parity. Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from the valid section to invalid. Added `<div aria-hidden="TRUE">` as additional valid coverage for the case-insensitive path.
1 parent 4d5e8a0 commit 44f0e94

2 files changed

Lines changed: 38 additions & 21 deletions

File tree

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

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,19 @@ function isMustacheLiteralTrue(attr) {
2929
}
3030

3131
// 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.
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.
3945
function hasNonInteractiveEscapeHatch(node) {
4046
const roleAttr = findAttr(node, 'role');
4147
if (roleAttr?.value?.type === 'GlimmerTextNode') {
@@ -46,18 +52,14 @@ function hasNonInteractiveEscapeHatch(node) {
4652
}
4753

4854
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)) {
55+
if (ariaHidden?.value?.type === 'GlimmerTextNode') {
56+
if (ariaHidden.value.chars.trim().toLowerCase() === 'true') {
5857
return true;
5958
}
6059
}
60+
if (isMustacheLiteralTrue(ariaHidden)) {
61+
return true;
62+
}
6163

6264
return false;
6365
}

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

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,20 @@ ruleTester.run('template-no-invalid-interactive', rule, {
112112
'<template><my-element onclick={{this.handler}}></my-element></template>',
113113
'<template><x-foo {{on "click" this.handler}}></x-foo></template>',
114114

115-
// Non-interactive escape hatches — `role="presentation"` / `role="none"`
116-
// / `aria-hidden` opt the element out of interactive-role semantics.
117-
// Matches jsx-a11y (`hasPresentationRole`, aria-hidden handling in
118-
// `isInteractiveElement`) and vuejs-accessibility.
115+
// 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.
119121
'<template><div role="presentation" onclick={{this.h}}></div></template>',
120122
'<template><div role="none" onclick={{this.h}}></div></template>',
121123
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
122124
'<template><div role="none" {{action "foo"}}></div></template>',
123125
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
124-
'<template><div aria-hidden onclick={{this.h}}></div></template>',
125126
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
126127
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
128+
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
127129
// Case-insensitive / whitespace tolerance on role values.
128130
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
129131
'<template><div role="NONE" onclick={{this.h}}></div></template>',
@@ -234,6 +236,19 @@ ruleTester.run('template-no-invalid-interactive', rule, {
234236
output: null,
235237
errors: [{ messageId: 'noInvalidInteractive' }],
236238
},
239+
{
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>',
249+
output: null,
250+
errors: [{ messageId: 'noInvalidInteractive' }],
251+
},
237252
{
238253
// `role="note"` is neither presentation/none nor an interactive role.
239254
code: '<template><div role="note" onclick={{this.h}}></div></template>',

0 commit comments

Comments
 (0)