Skip to content

Commit b8c282a

Browse files
committed
feat: recurse into focusable descendants for aria-hidden check
Flag `aria-hidden="true"` on an element whose subtree contains a focusable descendant, not just on the element itself. Per WAI-ARIA 1.2 §aria-hidden ("SHOULD NOT use aria-hidden='true' on any element that has focus or may receive focus"), "may receive focus" includes focusable descendants: aria-hidden hides the subtree from assistive tech, but descendants remain keyboard-reachable — a keyboard trap onto AT-invisible content. - Add `hasFocusableDescendant(node)` that walks GlimmerElementNode children and recurses; uses the existing `isFocusable` helper. Skips opaque tags (PascalCase components, `@`-prefixed, `this.`-prefixed, path-/namespace- pathed) and non-element AST nodes (TextNode, mustache) — bias toward no-FP. - New messageId `noAriaHiddenOnAncestorOfFocusable` distinguishes the descendant case from the carrier case in reports. - Rule doc adds spec citation and opaque-descendant caveat. - Rule tests: add invalid cases for descendant buttons, links, inputs, tabindex descendants, and nested depth; add valid cases for text-only descendants, component descendants, hidden inputs, dynamic mustaches. - Audit fixture: descendant-focusable case moves from DIVERGENCE to parity with vue-a11y; `tabindex="-1"` on descendants documented as an extension of the existing tabindex="-1" divergence (we flag, they don't).
1 parent e0e07d0 commit b8c282a

4 files changed

Lines changed: 209 additions & 47 deletions

File tree

docs/rules/template-no-aria-hidden-on-focusable.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
<!-- end auto-generated rule header -->
44

5-
Disallow `aria-hidden="true"` on focusable elements.
5+
Disallow `aria-hidden="true"` on focusable elements or elements containing focusable descendants.
66

7-
An element with `aria-hidden="true"` is removed from the accessibility tree but remains keyboard-focusable. This creates a keyboard trap — users reach the element via Tab but can't perceive it.
7+
An element with `aria-hidden="true"` is removed from the accessibility tree but remains keyboard-focusable. This creates a keyboard trap — users reach the element via Tab but can't perceive it. The same applies to focusable descendants of an `aria-hidden` ancestor, since `aria-hidden` does not remove elements from the tab order.
88

99
Per [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden):
1010

1111
> Authors SHOULD NOT use `aria-hidden="true"` on any element that has focus or may receive focus, either directly via interaction with the user or indirectly via programmatic means such as JavaScript-based event handling.
1212
13+
The phrase "may receive focus" is interpreted to include focusable descendants: `aria-hidden` cascades to hide the entire subtree from assistive tech, while any focusable descendant within that subtree remains reachable via Tab — landing keyboard users on AT-invisible content.
14+
1315
## Examples
1416

1517
This rule **forbids** the following:
@@ -19,6 +21,11 @@ This rule **forbids** the following:
1921
<button aria-hidden="true">Trapped</button>
2022
<a href="/x" aria-hidden="true">Link</a>
2123
<div tabindex="0" aria-hidden="true">Focusable but hidden</div>
24+
25+
{{! Focusable descendant inside an aria-hidden ancestor — classic modal backdrop trap }}
26+
<div aria-hidden="true">
27+
<button>Close</button>
28+
</div>
2229
</template>
2330
```
2431

@@ -34,11 +41,25 @@ This rule **allows** the following:
3441
3542
{{! input type="hidden" is not focusable }}
3643
<input type="hidden" aria-hidden="true" />
44+
45+
{{! Component/dynamic descendants are opaque — conservatively not flagged }}
46+
<div aria-hidden="true"><CustomBtn /></div>
3747
</template>
3848
```
3949

50+
## Caveats
51+
52+
Component invocations, argument/`this`/path-based tags, and namespace-pathed
53+
tags are "opaque" — we can't statically know what they render. The descendant
54+
check skips these branches to avoid false positives. If a component renders a
55+
focusable element beneath an `aria-hidden` ancestor, the keyboard trap still
56+
exists at runtime; this rule can't detect it.
57+
58+
Dynamic content inside `{{...}}` mustache statements is similarly not inspected.
59+
4060
## References
4161

4262
- [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
4363
- [WebAIM — Hiding content from assistive tech](https://webaim.org/techniques/css/invisiblecontent/)
4464
- [`no-aria-hidden-on-focusable` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-aria-hidden-on-focusable.md)
65+
- [`no-aria-hidden-on-focusable` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-aria-hidden-on-focusable.md)

lib/rules/template-no-aria-hidden-on-focusable.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,56 @@ function isFocusable(node) {
7979
return false;
8080
}
8181

82+
// A tag name is "opaque" if we cannot statically know what element it renders.
83+
// This covers component invocations (PascalCase), argument/this/path-based
84+
// dynamic tags, and namespace-pathed tags. Per WAI-ARIA 1.2 §aria-hidden, we
85+
// conservatively do not descend into these branches (bias toward no-FP).
86+
function isOpaqueTag(tag) {
87+
if (!tag) {
88+
return true;
89+
}
90+
if (/^[A-Z]/.test(tag)) {
91+
return true;
92+
}
93+
if (tag.startsWith('@') || tag.startsWith('this.')) {
94+
return true;
95+
}
96+
if (tag.includes('.') || tag.includes('::')) {
97+
return true;
98+
}
99+
return false;
100+
}
101+
102+
// Per WAI-ARIA 1.2 §aria-hidden: "Authors SHOULD NOT use aria-hidden='true' on
103+
// any element that has focus or may receive focus". A focusable descendant of
104+
// an aria-hidden ancestor can still receive focus (aria-hidden does not remove
105+
// elements from the tab order), so the ancestor hides AT-visible content that
106+
// remains keyboard-reachable — a keyboard trap.
107+
function hasFocusableDescendant(node) {
108+
const children = node.children;
109+
if (!children || children.length === 0) {
110+
return false;
111+
}
112+
for (const child of children) {
113+
if (child.type !== 'GlimmerElementNode') {
114+
// Skip TextNode, GlimmerMustacheStatement (dynamic content), yield
115+
// expressions, and anything else whose rendered element we can't inspect.
116+
continue;
117+
}
118+
if (isOpaqueTag(child.tag)) {
119+
// Component / dynamic tag — opaque. Don't recurse.
120+
continue;
121+
}
122+
if (isFocusable(child)) {
123+
return true;
124+
}
125+
if (hasFocusableDescendant(child)) {
126+
return true;
127+
}
128+
}
129+
return false;
130+
}
131+
82132
/** @type {import('eslint').Rule.RuleModule} */
83133
module.exports = {
84134
meta: {
@@ -94,6 +144,8 @@ module.exports = {
94144
messages: {
95145
noAriaHiddenOnFocusable:
96146
'aria-hidden="true" must not be set on focusable elements — it creates a keyboard trap (element reachable via Tab but hidden from assistive tech).',
147+
noAriaHiddenOnAncestorOfFocusable:
148+
'aria-hidden="true" must not be set on an element that contains focusable descendants — the descendants remain keyboard-reachable but are hidden from assistive tech.',
97149
},
98150
},
99151

@@ -105,6 +157,10 @@ module.exports = {
105157
}
106158
if (isFocusable(node)) {
107159
context.report({ node, messageId: 'noAriaHiddenOnFocusable' });
160+
return;
161+
}
162+
if (hasFocusableDescendant(node)) {
163+
context.report({ node, messageId: 'noAriaHiddenOnAncestorOfFocusable' });
108164
}
109165
},
110166
};

tests/audit/no-aria-hidden-on-focusable/peer-parity.js

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,6 @@ ruleTester.run('audit:no-aria-hidden-on-focusable (gts)', rule, {
4444
// so it's still valid.
4545
'<template><div {{on "click" this.handler}} aria-hidden="true"></div></template>',
4646

47-
// === Upstream parity with vue-a11y (valid in both) ===
48-
// vue-a11y: valid — descendant focusable with its own tabindex="-1" is
49-
// "escorted out" of the tab order. Our rule only inspects the element
50-
// that carries aria-hidden; we agree by not descending.
51-
`<template>
52-
<div aria-hidden="true">
53-
<button tabindex="-1">Some text</button>
54-
</div>
55-
</template>`,
56-
57-
// vue-a11y: valid — `<a href tabindex="-1">` is escorted out of tab order.
58-
// We don't flag because the aria-hidden is on the non-focusable <div>.
59-
`<template>
60-
<div aria-hidden="true">
61-
<a href="#" tabindex="-1">Link</a>
62-
</div>
63-
</template>`,
64-
6547
// vue-a11y: valid — no aria-hidden anywhere.
6648
`<template>
6749
<div>
@@ -131,6 +113,18 @@ ruleTester.run('audit:no-aria-hidden-on-focusable (gts)', rule, {
131113
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
132114
},
133115

116+
// === Upstream parity with vue-a11y descendant-focusable check (G5.1) ===
117+
// vue-a11y: INVALID when aria-hidden is on an ancestor and a focusable
118+
// descendant exists. Our rule now matches this via hasFocusableDescendant.
119+
// Per WAI-ARIA 1.2 §aria-hidden "may receive focus", a focusable
120+
// descendant beneath an aria-hidden ancestor is keyboard-reachable while
121+
// hidden from AT — a keyboard trap.
122+
{
123+
code: '<template><div aria-hidden="true"><button>Submit</button></div></template>',
124+
output: null,
125+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
126+
},
127+
134128
// === DIVERGENCE — `tabindex="-1"` on an inherently focusable element ===
135129
// This is the load-bearing, intentional divergence that PR #19 encodes.
136130
// jsx-a11y: VALID — `<button aria-hidden="true" tabIndex="-1" />` is
@@ -155,6 +149,30 @@ ruleTester.run('audit:no-aria-hidden-on-focusable (gts)', rule, {
155149
output: null,
156150
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
157151
},
152+
153+
// === DIVERGENCE (extended by G5.1) — tabindex="-1" on a DESCENDANT ===
154+
// Same rationale as above, applied through hasFocusableDescendant: our
155+
// `isFocusable` treats any tabindex (including "-1") as programmatically
156+
// focusable. vue-a11y considers these VALID (descendant is "escorted out"
157+
// of tab order). We flag.
158+
{
159+
code: `<template>
160+
<div aria-hidden="true">
161+
<button tabindex="-1">Some text</button>
162+
</div>
163+
</template>`,
164+
output: null,
165+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
166+
},
167+
{
168+
code: `<template>
169+
<div aria-hidden="true">
170+
<a href="#" tabindex="-1">Link</a>
171+
</div>
172+
</template>`,
173+
output: null,
174+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
175+
},
158176
],
159177
});
160178

@@ -168,31 +186,17 @@ ruleTester.run('audit:no-aria-hidden-on-focusable (gts)', rule, {
168186
// jsx-a11y: has no explicit case for this. Our rule special-cases
169187
// `<input type="hidden">` as non-focusable. Captured in main rule tests.
170188

171-
// === DIVERGENCE — vue-a11y descendant-focusable check ===
189+
// === PARITY — vue-a11y descendant-focusable check (G5.1, PR #19 follow-up) ===
172190
// vue-a11y: INVALID when aria-hidden is on an ancestor and a focusable
173191
// descendant exists:
174192
// `<div aria-hidden="true"><button>Submit</button></div>` → flagged
175193
// Its rule descends into children and fires on the aria-hidden ancestor
176194
// if any descendant is focusable.
177-
// Our rule: VALID — we only inspect the element that carries aria-hidden.
178-
// A div is not focusable, so we do not flag. This is a deliberate scope
179-
// decision (match jsx-a11y) and leaves vue-a11y's descendant-check behavior
180-
// as a capture-only difference. The PR's doc should note that users relying
181-
// on vue-a11y's broader semantic should pair this rule with an additional
182-
// check or use `aria-hidden` sparingly on wrappers.
183-
//
184-
// Captured below as valid cases (reflecting OUR non-flagging behavior).
185-
ruleTester.run('audit:no-aria-hidden-on-focusable descendant-only (gts)', rule, {
186-
valid: [
187-
// vue-a11y flags this; we don't.
188-
`<template>
189-
<div aria-hidden="true">
190-
<button>Submit</button>
191-
</div>
192-
</template>`,
193-
],
194-
invalid: [],
195-
});
195+
// Our rule: now INVALID — matches vue-a11y. Per WAI-ARIA 1.2 §aria-hidden
196+
// "may receive focus" we flag via `hasFocusableDescendant` under the
197+
// `noAriaHiddenOnAncestorOfFocusable` messageId. The parity case is in the
198+
// invalid[] block above. Component/dynamic descendants remain opaque
199+
// (no-FP bias).
196200

197201
// === AUDIT-SKIP — curly-literal aria-hidden value forms ===
198202
// jsx-a11y: tests use string-literal `aria-hidden="true"`. It also recognizes
@@ -215,13 +219,6 @@ hbsRuleTester.run('audit:no-aria-hidden-on-focusable (hbs)', rule, {
215219
'<a aria-hidden="false" href="#"></a>',
216220
'<button></button>',
217221
'<a href="/"></a>',
218-
219-
// vue-a11y parity — descendant has its own tabindex="-1" opt-out.
220-
'<div aria-hidden="true"><button tabindex="-1">Some text</button></div>',
221-
'<div aria-hidden="true"><a href="#" tabindex="-1">Link</a></div>',
222-
223-
// DIVERGENCE captured in hbs — we don't descend (vue-a11y does).
224-
'<div aria-hidden="true"><button>Submit</button></div>',
225222
],
226223
invalid: [
227224
{
@@ -266,5 +263,22 @@ hbsRuleTester.run('audit:no-aria-hidden-on-focusable (hbs)', rule, {
266263
output: null,
267264
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
268265
},
266+
// G5.1 parity — aria-hidden ancestor with focusable descendant.
267+
{
268+
code: '<div aria-hidden="true"><button>Submit</button></div>',
269+
output: null,
270+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
271+
},
272+
// G5.1 — DIVERGENCE extended: descendant with tabindex="-1".
273+
{
274+
code: '<div aria-hidden="true"><button tabindex="-1">Some text</button></div>',
275+
output: null,
276+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
277+
},
278+
{
279+
code: '<div aria-hidden="true"><a href="#" tabindex="-1">Link</a></div>',
280+
output: null,
281+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
282+
},
269283
],
270284
});

tests/lib/rules/template-no-aria-hidden-on-focusable.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@ ruleTester.run('template-no-aria-hidden-on-focusable', rule, {
3131

3232
// Components — we don't know if they render a focusable element.
3333
'<template><CustomBtn aria-hidden="true" /></template>',
34+
35+
// Descendant-focusable check — valid cases.
36+
// No focusable descendant.
37+
'<template><div aria-hidden="true"><span>Just text</span></div></template>',
38+
// Component descendants are opaque — conservatively not flagged.
39+
'<template><div aria-hidden="true"><Button>X</Button></div></template>',
40+
// No focusable descendants (alt-less img is decorative, not focusable).
41+
'<template><div aria-hidden="true"><img alt="static" /></div></template>',
42+
// <input type="hidden"> is non-focusable per isFocusable.
43+
'<template><div aria-hidden="true"><input type="hidden" /></div></template>',
44+
// Dynamic mustache descendants are not inspected.
45+
'<template><div aria-hidden="true">{{this.label}}</div></template>',
46+
// `@arg`-prefixed tag is opaque.
47+
'<template><div aria-hidden="true"><@thing /></div></template>',
48+
// `this.`-prefixed tag is opaque.
49+
'<template><div aria-hidden="true"><this.Item /></div></template>',
3450
],
3551
invalid: [
3652
// Native interactive elements.
@@ -89,6 +105,46 @@ ruleTester.run('template-no-aria-hidden-on-focusable', rule, {
89105
output: null,
90106
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
91107
},
108+
109+
// Descendant-focusable check (G5.1). Per WAI-ARIA 1.2 §aria-hidden
110+
// "may receive focus" — focusable descendants are keyboard-reachable
111+
// under an aria-hidden ancestor, creating a keyboard trap.
112+
{
113+
// Classic modal-backdrop trap.
114+
code: '<template><div aria-hidden="true"><button>Close</button></div></template>',
115+
output: null,
116+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
117+
},
118+
{
119+
// Deeper descendant.
120+
code: '<template><div aria-hidden="true"><span><button>Deep</button></span></div></template>',
121+
output: null,
122+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
123+
},
124+
{
125+
code: '<template><div aria-hidden="true"><a href="/x">Link</a></div></template>',
126+
output: null,
127+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
128+
},
129+
{
130+
code: '<template><div aria-hidden="true"><input /></div></template>',
131+
output: null,
132+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
133+
},
134+
{
135+
// Depth check — focusable descendant two levels deep.
136+
// (Our isFocusable doesn't treat <video controls> as focusable because
137+
// it's not in INHERENTLY_FOCUSABLE_TAGS; use <textarea> instead.)
138+
code: '<template><section aria-hidden="true"><div><textarea></textarea></div></section></template>',
139+
output: null,
140+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
141+
},
142+
{
143+
// tabindex on a descendant makes it focusable.
144+
code: '<template><div aria-hidden="true"><span tabindex="0">x</span></div></template>',
145+
output: null,
146+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
147+
},
92148
],
93149
});
94150

@@ -103,6 +159,10 @@ hbsRuleTester.run('template-no-aria-hidden-on-focusable', rule, {
103159
'<button>Click me</button>',
104160
'<input type="hidden" aria-hidden="true" />',
105161
'<CustomBtn aria-hidden="true" />',
162+
// Descendant-focusable — non-focusable child.
163+
'<div aria-hidden="true"><span>Just text</span></div>',
164+
// Component descendant is opaque.
165+
'<div aria-hidden="true"><Button>X</Button></div>',
106166
],
107167
invalid: [
108168
{
@@ -115,5 +175,16 @@ hbsRuleTester.run('template-no-aria-hidden-on-focusable', rule, {
115175
output: null,
116176
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
117177
},
178+
// Descendant-focusable check (G5.1).
179+
{
180+
code: '<div aria-hidden="true"><button>Close</button></div>',
181+
output: null,
182+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
183+
},
184+
{
185+
code: '<section aria-hidden="true"><div><a href="/x">Link</a></div></section>',
186+
output: null,
187+
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
188+
},
118189
],
119190
});

0 commit comments

Comments
 (0)