Skip to content

Commit 5f0a5da

Browse files
committed
feat: add template-no-aria-hidden-on-focusable
1 parent f400aca commit 5f0a5da

6 files changed

Lines changed: 837 additions & 0 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
262262
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
263263
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
264264
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | |
265+
| [template-no-aria-hidden-on-focusable](docs/rules/template-no-aria-hidden-on-focusable.md) | disallow aria-hidden="true" on focusable elements | | | |
265266
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | |
266267
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | |
267268
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | |
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# ember/template-no-aria-hidden-on-focusable
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow `aria-hidden="true"` on focusable elements or elements containing focusable descendants.
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. The same applies to focusable descendants of an `aria-hidden` ancestor, since `aria-hidden` does not remove elements from the tab order.
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+
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+
15+
## Examples
16+
17+
This rule **forbids** the following:
18+
19+
```gjs
20+
<template>
21+
<button aria-hidden="true">Trapped</button>
22+
<a href="/x" aria-hidden="true">Link</a>
23+
<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>
29+
</template>
30+
```
31+
32+
This rule **allows** the following:
33+
34+
```gjs
35+
<template>
36+
{{! Non-focusable decorative content }}
37+
<div aria-hidden="true"><svg class="decoration" /></div>
38+
39+
{{! Explicit opt-out }}
40+
<button aria-hidden="false">Click me</button>
41+
42+
{{! input type="hidden" is not focusable }}
43+
<input type="hidden" aria-hidden="true" />
44+
45+
{{! Component/dynamic descendants are opaque — conservatively not flagged }}
46+
<div aria-hidden="true"><CustomBtn /></div>
47+
</template>
48+
```
49+
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+
60+
## References
61+
62+
- [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
63+
- [WebAIM — Hiding content from assistive tech](https://webaim.org/techniques/css/invisiblecontent/)
64+
- [`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)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
'use strict';
2+
3+
const { isNativeElement } = require('../utils/is-native-element');
4+
const { getStaticAttrValue } = require('../utils/static-attr-value');
5+
6+
function findAttr(node, name) {
7+
return node.attributes?.find((a) => a.name === name);
8+
}
9+
10+
// Returns the statically-known string value of a named attribute, or
11+
// `undefined` when the attribute is absent or its value is dynamic.
12+
function getTextAttrValue(node, name) {
13+
const attr = findAttr(node, name);
14+
if (!attr) {
15+
return undefined;
16+
}
17+
return getStaticAttrValue(attr.value);
18+
}
19+
20+
// Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
21+
// aria-hidden resolves to the default `undefined` — NOT `true`. So only an
22+
// explicit `"true"` (ASCII case-insensitive per HTML enumerated-attribute
23+
// rules) hides the element. Mustache boolean-literal `{{true}}` and
24+
// string-literal `{{"true"}}` also qualify.
25+
function isAriaHiddenTrue(node) {
26+
const value = findAttr(node, 'aria-hidden')?.value;
27+
if (!value) {
28+
return false;
29+
}
30+
if (value.type === 'GlimmerTextNode') {
31+
return value.chars.trim().toLowerCase() === 'true';
32+
}
33+
if (value.type === 'GlimmerMustacheStatement' && value.path) {
34+
if (value.path.type === 'GlimmerBooleanLiteral') {
35+
return value.path.value === true;
36+
}
37+
if (value.path.type === 'GlimmerStringLiteral') {
38+
return value.path.value.trim().toLowerCase() === 'true';
39+
}
40+
}
41+
return false;
42+
}
43+
44+
// Tags with an unconditional default focusable UI (sequentially focusable per
45+
// HTML §6.6.3 "focusable area" + widget roles per HTML-AAM).
46+
// NOTE: <label> is HTML-interactive-content (§3.2.5.2.7) but NOT keyboard-
47+
// focusable by default — clicks on a label forward to its associated control,
48+
// but the label itself isn't in the tab order. So it's excluded here even
49+
// though `isHtmlInteractiveContent` would return true for it.
50+
const UNCONDITIONAL_FOCUSABLE_TAGS = new Set([
51+
'button',
52+
'select',
53+
'textarea',
54+
'iframe',
55+
'embed',
56+
'summary',
57+
'details',
58+
'option',
59+
'datalist',
60+
]);
61+
62+
// Form-control tags whose `disabled` attribute removes them from the tab order
63+
// (HTML §4.10.18.5 "disabled" + HTML §6.6.3 "focusable area").
64+
const DISABLEABLE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'fieldset']);
65+
66+
function isDisabledFormControl(node, tag) {
67+
if (!DISABLEABLE_TAGS.has(tag)) {
68+
return false;
69+
}
70+
return Boolean(findAttr(node, 'disabled'));
71+
}
72+
73+
// Narrow rule-local "keyboard-focusable" check. Intentionally distinct from
74+
// `isHtmlInteractiveContent` (HTML content-model) — we want the sequential-
75+
// focus + programmatic-focus axis only. See WAI-ARIA "focusable" definition
76+
// and HTML §6.6.3.
77+
function isKeyboardFocusable(node, getTextAttrValueFn) {
78+
const rawTag = node?.tag;
79+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
80+
return false;
81+
}
82+
const tag = rawTag.toLowerCase();
83+
84+
// Disabled form controls are not focusable.
85+
if (isDisabledFormControl(node, tag)) {
86+
return false;
87+
}
88+
89+
// Any tabindex (including "-1") makes the element at least programmatically
90+
// focusable — still a keyboard-trap risk under aria-hidden.
91+
if (findAttr(node, 'tabindex')) {
92+
return true;
93+
}
94+
95+
// contenteditable (truthy) makes the element focusable.
96+
const contentEditable = getTextAttrValueFn(node, 'contenteditable');
97+
if (contentEditable !== undefined && contentEditable !== null) {
98+
const normalized = contentEditable.trim().toLowerCase();
99+
// per HTML spec, "", "true", and "plaintext-only" all enable editing.
100+
if (normalized === '' || normalized === 'true' || normalized === 'plaintext-only') {
101+
return true;
102+
}
103+
}
104+
105+
if (UNCONDITIONAL_FOCUSABLE_TAGS.has(tag)) {
106+
return true;
107+
}
108+
109+
if (tag === 'input') {
110+
const type = getTextAttrValueFn(node, 'type');
111+
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
112+
}
113+
114+
if (tag === 'a' || tag === 'area') {
115+
return Boolean(findAttr(node, 'href'));
116+
}
117+
118+
if (tag === 'img') {
119+
return Boolean(findAttr(node, 'usemap'));
120+
}
121+
122+
if (tag === 'audio' || tag === 'video') {
123+
return Boolean(findAttr(node, 'controls'));
124+
}
125+
126+
return false;
127+
}
128+
129+
// A focusable descendant of an aria-hidden="true" ancestor can still receive
130+
// focus (aria-hidden does not remove elements from the tab order), so the
131+
// ancestor hides AT-visible content that remains keyboard-reachable — a
132+
// keyboard trap. This rule targets the anti-pattern flagged by axe's
133+
// `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`.
134+
// WAI-ARIA 1.2 says authors SHOULD NOT put aria-hidden on focusable content
135+
// (the spec normatively warns against this in the aria-hidden authoring note).
136+
function hasFocusableDescendant(node, sourceCode) {
137+
const children = node.children;
138+
if (!children || children.length === 0) {
139+
return false;
140+
}
141+
for (const child of children) {
142+
if (child.type !== 'GlimmerElementNode') {
143+
// Skip TextNode, GlimmerMustacheStatement (dynamic content), yield
144+
// expressions, and anything else whose rendered element we can't inspect.
145+
continue;
146+
}
147+
if (!isNativeElement(child, sourceCode)) {
148+
// Component / dynamic / shadowed tag — opaque. Don't recurse.
149+
continue;
150+
}
151+
if (isKeyboardFocusable(child, getTextAttrValue)) {
152+
return true;
153+
}
154+
if (hasFocusableDescendant(child, sourceCode)) {
155+
return true;
156+
}
157+
}
158+
return false;
159+
}
160+
161+
/** @type {import('eslint').Rule.RuleModule} */
162+
module.exports = {
163+
meta: {
164+
type: 'problem',
165+
docs: {
166+
description: 'disallow aria-hidden="true" on focusable elements',
167+
category: 'Accessibility',
168+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-aria-hidden-on-focusable.md',
169+
templateMode: 'both',
170+
},
171+
fixable: null,
172+
schema: [],
173+
messages: {
174+
noAriaHiddenOnFocusable:
175+
'aria-hidden="true" must not be set on focusable elements — it creates a keyboard trap (element reachable via Tab but hidden from assistive tech).',
176+
noAriaHiddenOnAncestorOfFocusable:
177+
'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.',
178+
},
179+
},
180+
181+
create(context) {
182+
const sourceCode = context.sourceCode ?? context.getSourceCode();
183+
return {
184+
GlimmerElementNode(node) {
185+
if (!isAriaHiddenTrue(node)) {
186+
return;
187+
}
188+
if (!isNativeElement(node, sourceCode)) {
189+
return;
190+
}
191+
if (isKeyboardFocusable(node, getTextAttrValue)) {
192+
context.report({ node, messageId: 'noAriaHiddenOnFocusable' });
193+
return;
194+
}
195+
if (hasFocusableDescendant(node, sourceCode)) {
196+
context.report({ node, messageId: 'noAriaHiddenOnAncestorOfFocusable' });
197+
}
198+
},
199+
};
200+
},
201+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
3+
/**
4+
* HTML "interactive content" classification, authoritative per
5+
* [HTML Living Standard §3.2.5.2.7 Interactive content]
6+
* (https://html.spec.whatwg.org/multipage/dom.html#interactive-content):
7+
*
8+
* a (if the href attribute is present), audio (if the controls attribute
9+
* is present), button, details, embed, iframe, img (if the usemap
10+
* attribute is present), input (if the type attribute is not in the
11+
* Hidden state), label, select, textarea, video (if the controls
12+
* attribute is present).
13+
*
14+
* Plus <summary>, which is not in §3.2.5.2.7 but is keyboard-activatable per
15+
* [§4.11.2 The summary element](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element).
16+
*
17+
* This is the HTML-content-model authority — it answers "does the HTML spec
18+
* prohibit nesting this inside an interactive parent?" It does NOT answer
19+
* "is this an ARIA widget for AT semantics?" (see `interactive-roles.js`
20+
* for that). The two questions diverge on rows like <label> (HTML: yes;
21+
* ARIA: structure role), <canvas> (HTML: no; ARIA: widget per axobject),
22+
* and <option>/<datalist> (HTML: no; ARIA: widgets). Rules that need
23+
* "interactive for any reason" should compose both authorities.
24+
*/
25+
26+
const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
27+
'button',
28+
'details',
29+
'embed',
30+
'iframe',
31+
'label',
32+
'select',
33+
'summary',
34+
'textarea',
35+
]);
36+
37+
/**
38+
* Determine whether a Glimmer element node is HTML-interactive content per
39+
* §3.2.5.2.7 (+ summary).
40+
*
41+
* @param {object} node Glimmer ElementNode (has a string `tag`).
42+
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
43+
* returning the static text value of an
44+
* attribute, or undefined for dynamic / missing.
45+
* @param {object} [options]
46+
* @param {boolean} [options.ignoreUsemap=false] Treat `<img usemap>` as NOT interactive.
47+
* Consumed by rules with an `ignoreUsemap`
48+
* config option that lets authors opt out
49+
* of image-map-based interactivity.
50+
* @returns {boolean}
51+
*/
52+
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
53+
const rawTag = node && node.tag;
54+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
55+
return false;
56+
}
57+
const tag = rawTag.toLowerCase();
58+
59+
if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
60+
return true;
61+
}
62+
63+
// input — interactive unless type="hidden"
64+
if (tag === 'input') {
65+
const type = getTextAttrValue(node, 'type');
66+
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
67+
}
68+
69+
// a — interactive only when href is present
70+
if (tag === 'a') {
71+
return hasAttribute(node, 'href');
72+
}
73+
74+
// img — interactive only when usemap is present (image map)
75+
if (tag === 'img') {
76+
if (options.ignoreUsemap) {
77+
return false;
78+
}
79+
return hasAttribute(node, 'usemap');
80+
}
81+
82+
// audio / video — interactive only when controls is present
83+
if (tag === 'audio' || tag === 'video') {
84+
return hasAttribute(node, 'controls');
85+
}
86+
87+
return false;
88+
}
89+
90+
function hasAttribute(node, name) {
91+
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
92+
}
93+
94+
module.exports = { isHtmlInteractiveContent };

0 commit comments

Comments
 (0)