Skip to content

Commit 81c5acb

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

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
@@ -33,13 +33,19 @@ function isMustacheLiteralTrue(attr) {
3333
}
3434

3535
// 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.
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.
4349
function hasNonInteractiveEscapeHatch(node) {
4450
const roleAttr = findAttr(node, 'role');
4551
if (roleAttr?.value?.type === 'GlimmerTextNode') {
@@ -50,18 +56,14 @@ function hasNonInteractiveEscapeHatch(node) {
5056
}
5157

5258
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)) {
59+
if (ariaHidden?.value?.type === 'GlimmerTextNode') {
60+
if (ariaHidden.value.chars.trim().toLowerCase() === 'true') {
6261
return true;
6362
}
6463
}
64+
if (isMustacheLiteralTrue(ariaHidden)) {
65+
return true;
66+
}
6567

6668
return false;
6769
}

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

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

121-
// Non-interactive escape hatches — `role="presentation"` / `role="none"`
122-
// / `aria-hidden` opt the element out of interactive-role semantics.
123-
// Matches jsx-a11y (`hasPresentationRole`, aria-hidden handling in
124-
// `isInteractiveElement`) and vuejs-accessibility.
121+
// 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.
125127
'<template><div role="presentation" onclick={{this.h}}></div></template>',
126128
'<template><div role="none" onclick={{this.h}}></div></template>',
127129
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
128130
'<template><div role="none" {{action "foo"}}></div></template>',
129131
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
130-
'<template><div aria-hidden onclick={{this.h}}></div></template>',
131132
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
132133
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
134+
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
133135
// Case-insensitive / whitespace tolerance on role values.
134136
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
135137
'<template><div role="NONE" onclick={{this.h}}></div></template>',
@@ -240,6 +242,19 @@ ruleTester.run('template-no-invalid-interactive', rule, {
240242
output: null,
241243
errors: [{ messageId: 'noInvalidInteractive' }],
242244
},
245+
{
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>',
255+
output: null,
256+
errors: [{ messageId: 'noInvalidInteractive' }],
257+
},
243258
{
244259
// `role="note"` is neither presentation/none nor an interactive role.
245260
code: '<template><div role="note" onclick={{this.h}}></div></template>',

0 commit comments

Comments
 (0)