Skip to content

Commit 0d0b352

Browse files
committed
feat: add template-no-aria-hidden-on-focusable
Flags <button aria-hidden="true">, <a href aria-hidden>, etc. — focusable elements that are hidden from assistive tech create a keyboard trap: users reach them via Tab but can't perceive them. Per WAI-ARIA 1.2 (aria-hidden): "Authors SHOULD NOT use aria-hidden='true' on any element that has focus or may receive focus." Detection: - aria-hidden is truthy (value "true", valueless boolean attr, {{true}}, or "{{true}}" — matches the treatment introduced in template-no-empty-headings B4 fix) - element is focusable: - inherently focusable tag (button/input/select/textarea/a[href]/ details/summary/iframe/embed), EXCEPT input[type=hidden] - or has a tabindex attribute (including tabindex="-1" — the element is still programmatically focusable) Skips components (non-DOM tags — rendering may vary). Shape modelled on eslint-plugin-jsx-a11y/no-aria-hidden-on-focusable and eslint-plugin-vuejs-accessibility/no-aria-hidden-on-focusable.
1 parent 24882a3 commit 0d0b352

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# ember/template-no-aria-hidden-on-focusable
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow `aria-hidden="true"` on focusable elements.
6+
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.
8+
9+
Per [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden):
10+
11+
> 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.
12+
13+
## Examples
14+
15+
This rule **forbids** the following:
16+
17+
```gjs
18+
<template>
19+
<button aria-hidden="true">Trapped</button>
20+
<a href="/x" aria-hidden="true">Link</a>
21+
<div tabindex="0" aria-hidden="true">Focusable but hidden</div>
22+
</template>
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```gjs
28+
<template>
29+
{{! Non-focusable decorative content }}
30+
<div aria-hidden="true"><svg class="decoration" /></div>
31+
32+
{{! Explicit opt-out }}
33+
<button aria-hidden="false">Click me</button>
34+
35+
{{! input type="hidden" is not focusable }}
36+
<input type="hidden" aria-hidden="true" />
37+
</template>
38+
```
39+
40+
## References
41+
42+
- [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
43+
- [WebAIM — Hiding content from assistive tech](https://webaim.org/techniques/css/invisiblecontent/)
44+
- [`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)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Native interactive elements that are focusable by default.
2+
const INHERENTLY_FOCUSABLE_TAGS = new Set([
3+
'button',
4+
'details',
5+
'embed',
6+
'iframe',
7+
'input',
8+
'select',
9+
'summary',
10+
'textarea',
11+
]);
12+
13+
function findAttr(node, name) {
14+
return node.attributes?.find((a) => a.name === name);
15+
}
16+
17+
function getTextAttrValue(attr) {
18+
if (attr?.value?.type === 'GlimmerTextNode') {
19+
return attr.value.chars;
20+
}
21+
return undefined;
22+
}
23+
24+
function isAriaHiddenTruthy(node) {
25+
const attr = findAttr(node, 'aria-hidden');
26+
if (!attr) {
27+
return false;
28+
}
29+
const value = attr.value;
30+
// Valueless or empty-string → truthy boolean attr. Matches jsx-a11y/vue-a11y.
31+
if (!value || (value.type === 'GlimmerTextNode' && value.chars === '')) {
32+
return true;
33+
}
34+
if (value.type === 'GlimmerTextNode') {
35+
return value.chars === 'true';
36+
}
37+
if (value.type === 'GlimmerMustacheStatement' && value.path) {
38+
if (value.path.type === 'GlimmerBooleanLiteral') {
39+
return value.path.value === true;
40+
}
41+
if (value.path.type === 'GlimmerStringLiteral') {
42+
return value.path.value === 'true';
43+
}
44+
}
45+
return false;
46+
}
47+
48+
function isFocusable(node) {
49+
const tag = node.tag?.toLowerCase();
50+
if (!tag) {
51+
return false;
52+
}
53+
54+
// Opt-out via tabindex="-1" makes the element programmatically focusable
55+
// (still reachable via .focus()) but removes it from the tab order.
56+
// `aria-hidden` on such an element is still problematic — if it can receive
57+
// focus, assistive tech should be able to see it. Match jsx-a11y: flag any
58+
// tabindex that's not "undefined" (i.e. any tabindex attribute at all).
59+
const tabindex = findAttr(node, 'tabindex');
60+
if (tabindex) {
61+
return true;
62+
}
63+
64+
if (INHERENTLY_FOCUSABLE_TAGS.has(tag)) {
65+
// <input type="hidden"> is not focusable.
66+
if (tag === 'input') {
67+
const type = getTextAttrValue(findAttr(node, 'type'));
68+
if (type === 'hidden') {
69+
return false;
70+
}
71+
}
72+
return true;
73+
}
74+
75+
if (tag === 'a' && findAttr(node, 'href')) {
76+
return true;
77+
}
78+
79+
return false;
80+
}
81+
82+
/** @type {import('eslint').Rule.RuleModule} */
83+
module.exports = {
84+
meta: {
85+
type: 'problem',
86+
docs: {
87+
description: 'disallow aria-hidden="true" on focusable elements',
88+
category: 'Accessibility',
89+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-aria-hidden-on-focusable.md',
90+
templateMode: 'both',
91+
},
92+
fixable: null,
93+
schema: [],
94+
messages: {
95+
noAriaHiddenOnFocusable:
96+
'aria-hidden="true" must not be set on focusable elements — it creates a keyboard trap (element reachable via Tab but hidden from assistive tech).',
97+
},
98+
},
99+
100+
create(context) {
101+
return {
102+
GlimmerElementNode(node) {
103+
if (!isAriaHiddenTruthy(node)) {
104+
return;
105+
}
106+
if (isFocusable(node)) {
107+
context.report({ node, messageId: 'noAriaHiddenOnFocusable' });
108+
}
109+
},
110+
};
111+
},
112+
};
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use strict';
2+
3+
const rule = require('../../../lib/rules/template-no-aria-hidden-on-focusable');
4+
const RuleTester = require('eslint').RuleTester;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('ember-eslint-parser'),
8+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
9+
});
10+
11+
ruleTester.run('template-no-aria-hidden-on-focusable', rule, {
12+
valid: [
13+
// aria-hidden on non-focusable elements — fine.
14+
'<template><div aria-hidden="true"></div></template>',
15+
'<template><span aria-hidden="true">decorative</span></template>',
16+
'<template><img src="/x.png" alt="" aria-hidden="true" /></template>',
17+
18+
// Focusable elements without aria-hidden — fine.
19+
'<template><button>Click me</button></template>',
20+
'<template><a href="/x">Link</a></template>',
21+
'<template><input type="text" /></template>',
22+
23+
// aria-hidden="false" — explicit opt-out. Not flagged.
24+
'<template><button aria-hidden="false">Click me</button></template>',
25+
26+
// <input type="hidden"> isn't focusable, so aria-hidden on it is fine.
27+
'<template><input type="hidden" aria-hidden="true" /></template>',
28+
29+
// <a> without href isn't focusable by default.
30+
'<template><a aria-hidden="true">Not a link</a></template>',
31+
32+
// Components — we don't know if they render a focusable element.
33+
'<template><CustomBtn aria-hidden="true" /></template>',
34+
],
35+
invalid: [
36+
// Native interactive elements.
37+
{
38+
code: '<template><button aria-hidden="true">Trapped</button></template>',
39+
output: null,
40+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
41+
},
42+
{
43+
code: '<template><a href="/x" aria-hidden="true">Link</a></template>',
44+
output: null,
45+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
46+
},
47+
{
48+
code: '<template><input type="text" aria-hidden="true" /></template>',
49+
output: null,
50+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
51+
},
52+
{
53+
code: '<template><select aria-hidden="true"><option /></select></template>',
54+
output: null,
55+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
56+
},
57+
{
58+
code: '<template><textarea aria-hidden="true"></textarea></template>',
59+
output: null,
60+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
61+
},
62+
63+
// Non-interactive element made focusable via tabindex.
64+
{
65+
code: '<template><div tabindex="0" aria-hidden="true"></div></template>',
66+
output: null,
67+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
68+
},
69+
{
70+
// tabindex="-1" still makes it programmatically focusable — still flag.
71+
code: '<template><div tabindex="-1" aria-hidden="true"></div></template>',
72+
output: null,
73+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
74+
},
75+
76+
// Boolean / valueless / mustache-boolean aria-hidden — all truthy.
77+
{
78+
code: '<template><button aria-hidden></button></template>',
79+
output: null,
80+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
81+
},
82+
{
83+
code: '<template><button aria-hidden=""></button></template>',
84+
output: null,
85+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
86+
},
87+
{
88+
code: '<template><button aria-hidden={{true}}></button></template>',
89+
output: null,
90+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
91+
},
92+
],
93+
});
94+
95+
const hbsRuleTester = new RuleTester({
96+
parser: require.resolve('ember-eslint-parser/hbs'),
97+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
98+
});
99+
100+
hbsRuleTester.run('template-no-aria-hidden-on-focusable', rule, {
101+
valid: [
102+
'<div aria-hidden="true"></div>',
103+
'<button>Click me</button>',
104+
'<input type="hidden" aria-hidden="true" />',
105+
'<CustomBtn aria-hidden="true" />',
106+
],
107+
invalid: [
108+
{
109+
code: '<button aria-hidden="true">Trapped</button>',
110+
output: null,
111+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
112+
},
113+
{
114+
code: '<div tabindex="0" aria-hidden="true"></div>',
115+
output: null,
116+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
117+
},
118+
],
119+
});

0 commit comments

Comments
 (0)