Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | |
| [template-no-aria-hidden-on-focusable](docs/rules/template-no-aria-hidden-on-focusable.md) | disallow aria-hidden="true" on focusable elements | | | |
| [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 | 📋 | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | |
| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | |
Expand Down
65 changes: 65 additions & 0 deletions docs/rules/template-no-aria-hidden-on-focusable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ember/template-no-aria-hidden-on-focusable

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

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

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.

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

> 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.

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.

Comment thread
johanrd marked this conversation as resolved.
## Examples

This rule **forbids** the following:

```gjs
<template>
<button aria-hidden="true">Trapped</button>
<a href="/x" aria-hidden="true">Link</a>
<div tabindex="0" aria-hidden="true">Focusable but hidden</div>

{{! Focusable descendant inside an aria-hidden ancestor — classic modal backdrop trap }}
<div aria-hidden="true">
<button>Close</button>
</div>
</template>
```

This rule **allows** the following:

```gjs
<template>
{{! Non-focusable decorative content }}
<div aria-hidden="true"><svg class="decoration" /></div>

{{! Explicit opt-out }}
<button aria-hidden="false">Click me</button>

{{! input type="hidden" is not focusable }}
<input type="hidden" aria-hidden="true" />

{{! Component/dynamic descendants are opaque — conservatively not flagged }}
<div aria-hidden="true"><CustomBtn /></div>
</template>
```

## Caveats

Component invocations, argument/`this`/path-based tags, and namespace-pathed
tags are "opaque" — we can't statically know what they render. The descendant
check skips these branches to avoid false positives. If a component renders a
focusable element beneath an `aria-hidden` ancestor, the keyboard trap still
exists at runtime; this rule can't detect it.
Comment on lines +52 to +56
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “Caveats” section lists several kinds of opaque tags but doesn’t mention custom elements (e.g. <my-widget>). Since the rule uses isNativeElement, custom elements are also skipped, which can surprise readers expecting tabindex-based focusability on custom elements to be reported. Consider explicitly mentioning custom elements in this caveat list to align docs with behavior.

Suggested change
Component invocations, argument/`this`/path-based tags, and namespace-pathed
tags are "opaque" — we can't statically know what they render. The descendant
check skips these branches to avoid false positives. If a component renders a
focusable element beneath an `aria-hidden` ancestor, the keyboard trap still
exists at runtime; this rule can't detect it.
Component invocations, argument/`this`/path-based tags, namespace-pathed tags,
and custom elements (for example, `<my-widget>`) are "opaque" — we can't
statically know what they render. The descendant check skips these branches to
avoid false positives. If one of these renders a focusable element beneath an
`aria-hidden` ancestor, the keyboard trap still exists at runtime; this rule
can't detect it.

Copilot uses AI. Check for mistakes.

Dynamic content inside `{{...}}` mustache statements is similarly not inspected.

## References

- [WAI-ARIA 1.2 — aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden)
- [WebAIM — Hiding content from assistive tech](https://webaim.org/techniques/css/invisiblecontent/)
- [`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)
- [`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)
201 changes: 201 additions & 0 deletions lib/rules/template-no-aria-hidden-on-focusable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
'use strict';

const { isNativeElement } = require('../utils/is-native-element');
const { getStaticAttrValue } = require('../utils/static-attr-value');

function findAttr(node, name) {
return node.attributes?.find((a) => a.name === name);
}

// Returns the statically-known string value of a named attribute, or
// `undefined` when the attribute is absent or its value is dynamic.
function getTextAttrValue(node, name) {
const attr = findAttr(node, name);
if (!attr) {
return undefined;
}
return getStaticAttrValue(attr.value);
}

Comment thread
johanrd marked this conversation as resolved.
// Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string
// aria-hidden resolves to the default `undefined` — NOT `true`. So only an
// explicit `"true"` (ASCII case-insensitive per HTML enumerated-attribute
// rules) hides the element. Mustache boolean-literal `{{true}}` and
// string-literal `{{"true"}}` also qualify.
function isAriaHiddenTrue(node) {
const value = findAttr(node, 'aria-hidden')?.value;
if (!value) {
return false;
}
if (value.type === 'GlimmerTextNode') {
return value.chars.trim().toLowerCase() === 'true';
}
if (value.type === 'GlimmerMustacheStatement' && value.path) {
if (value.path.type === 'GlimmerBooleanLiteral') {
return value.path.value === true;
}
if (value.path.type === 'GlimmerStringLiteral') {
return value.path.value.trim().toLowerCase() === 'true';
}
}
return false;
}

// Tags with an unconditional default focusable UI (sequentially focusable per
// HTML §6.6.3 "focusable area" + widget roles per HTML-AAM).
// NOTE: <label> is HTML-interactive-content (§3.2.5.2.7) but NOT keyboard-
// focusable by default — clicks on a label forward to its associated control,
// but the label itself isn't in the tab order. So it's excluded here even
// though `isHtmlInteractiveContent` would return true for it.
const UNCONDITIONAL_FOCUSABLE_TAGS = new Set([
'button',
'select',
'textarea',
'iframe',
'embed',
'summary',
'details',
'option',
'datalist',
]);
Comment thread
johanrd marked this conversation as resolved.

// Form-control tags whose `disabled` attribute removes them from the tab order
// (HTML §4.10.18.5 "disabled" + HTML §6.6.3 "focusable area").
const DISABLEABLE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'fieldset']);

function isDisabledFormControl(node, tag) {
if (!DISABLEABLE_TAGS.has(tag)) {
return false;
}
return Boolean(findAttr(node, 'disabled'));
}

// Narrow rule-local "keyboard-focusable" check. Intentionally distinct from
// `isHtmlInteractiveContent` (HTML content-model) — we want the sequential-
// focus + programmatic-focus axis only. See WAI-ARIA "focusable" definition
// and HTML §6.6.3.
function isKeyboardFocusable(node, getTextAttrValueFn) {
const rawTag = node?.tag;
if (typeof rawTag !== 'string' || rawTag.length === 0) {
return false;
}
const tag = rawTag.toLowerCase();

// Disabled form controls are not focusable.
if (isDisabledFormControl(node, tag)) {
return false;
}

// Any tabindex (including "-1") makes the element at least programmatically
// focusable — still a keyboard-trap risk under aria-hidden.
if (findAttr(node, 'tabindex')) {
return true;
}

// contenteditable (truthy) makes the element focusable.
const contentEditable = getTextAttrValueFn(node, 'contenteditable');
if (contentEditable !== undefined && contentEditable !== null) {
const normalized = contentEditable.trim().toLowerCase();
// per HTML spec, "", "true", and "plaintext-only" all enable editing.
if (normalized === '' || normalized === 'true' || normalized === 'plaintext-only') {
return true;
}
}

if (UNCONDITIONAL_FOCUSABLE_TAGS.has(tag)) {
return true;
}

if (tag === 'input') {
const type = getTextAttrValueFn(node, 'type');
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
}

if (tag === 'a' || tag === 'area') {
return Boolean(findAttr(node, 'href'));
}
Comment thread
johanrd marked this conversation as resolved.

if (tag === 'img') {
return Boolean(findAttr(node, 'usemap'));
}

if (tag === 'audio' || tag === 'video') {
return Boolean(findAttr(node, 'controls'));
}

return false;
}

// A focusable descendant of an aria-hidden="true" ancestor can still receive
// focus (aria-hidden does not remove elements from the tab order), so the
// ancestor hides AT-visible content that remains keyboard-reachable — a
// keyboard trap. This rule targets the anti-pattern flagged by axe's
// `aria-hidden-focus` check and by jsx-a11y's `no-aria-hidden-on-focusable`.
// WAI-ARIA 1.2 says authors SHOULD NOT put aria-hidden on focusable content
// (the spec normatively warns against this in the aria-hidden authoring note).
function hasFocusableDescendant(node, sourceCode) {
const children = node.children;
if (!children || children.length === 0) {
return false;
}
for (const child of children) {
if (child.type !== 'GlimmerElementNode') {
// Skip TextNode, GlimmerMustacheStatement (dynamic content), yield
// expressions, and anything else whose rendered element we can't inspect.
continue;
}
if (!isNativeElement(child, sourceCode)) {
// Component / dynamic / shadowed tag — opaque. Don't recurse.
continue;
}
if (isKeyboardFocusable(child, getTextAttrValue)) {
return true;
}
if (hasFocusableDescendant(child, sourceCode)) {
return true;
}
Comment thread
johanrd marked this conversation as resolved.
}
return false;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow aria-hidden="true" on focusable elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-aria-hidden-on-focusable.md',
templateMode: 'both',
},
Comment thread
johanrd marked this conversation as resolved.
fixable: null,
schema: [],
messages: {
noAriaHiddenOnFocusable:
'aria-hidden="true" must not be set on focusable elements — it creates a keyboard trap (element reachable via Tab but hidden from assistive tech).',
noAriaHiddenOnAncestorOfFocusable:
'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.',
},
},

create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
return {
GlimmerElementNode(node) {
if (!isAriaHiddenTrue(node)) {
return;
}
if (!isNativeElement(node, sourceCode)) {
return;
}
if (isKeyboardFocusable(node, getTextAttrValue)) {
context.report({ node, messageId: 'noAriaHiddenOnFocusable' });
return;
}
if (hasFocusableDescendant(node, sourceCode)) {
context.report({ node, messageId: 'noAriaHiddenOnAncestorOfFocusable' });
}
},
};
},
};
94 changes: 94 additions & 0 deletions lib/utils/html-interactive-content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict';

/**
* HTML "interactive content" classification, authoritative per
* [HTML Living Standard §3.2.5.2.7 Interactive content]
* (https://html.spec.whatwg.org/multipage/dom.html#interactive-content):
*
* a (if the href attribute is present), audio (if the controls attribute
* is present), button, details, embed, iframe, img (if the usemap
* attribute is present), input (if the type attribute is not in the
* Hidden state), label, select, textarea, video (if the controls
* attribute is present).
*
* Plus <summary>, which is not in §3.2.5.2.7 but is keyboard-activatable per
* [§4.11.2 The summary element](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element).
*
* This is the HTML-content-model authority — it answers "does the HTML spec
* prohibit nesting this inside an interactive parent?" It does NOT answer
* "is this an ARIA widget for AT semantics?" (see `interactive-roles.js`
* for that). The two questions diverge on rows like <label> (HTML: yes;
* ARIA: structure role), <canvas> (HTML: no; ARIA: widget per axobject),
* and <option>/<datalist> (HTML: no; ARIA: widgets). Rules that need
* "interactive for any reason" should compose both authorities.
*/

const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
'button',
'details',
'embed',
'iframe',
'label',
'select',
'summary',
'textarea',
]);

/**
* Determine whether a Glimmer element node is HTML-interactive content per
* §3.2.5.2.7 (+ summary).
*
* @param {object} node Glimmer ElementNode (has a string `tag`).
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
* returning the static text value of an
* attribute, or undefined for dynamic / missing.
* @param {object} [options]
* @param {boolean} [options.ignoreUsemap=false] Treat `<img usemap>` as NOT interactive.
* Consumed by rules with an `ignoreUsemap`
* config option that lets authors opt out
* of image-map-based interactivity.
* @returns {boolean}
*/
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
const rawTag = node && node.tag;
if (typeof rawTag !== 'string' || rawTag.length === 0) {
return false;
}
const tag = rawTag.toLowerCase();

if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
return true;
}

// input — interactive unless type="hidden"
if (tag === 'input') {
const type = getTextAttrValue(node, 'type');
return type === undefined || type === null || type.trim().toLowerCase() !== 'hidden';
}

// a — interactive only when href is present
if (tag === 'a') {
return hasAttribute(node, 'href');
}

// img — interactive only when usemap is present (image map)
if (tag === 'img') {
if (options.ignoreUsemap) {
return false;
}
return hasAttribute(node, 'usemap');
}

// audio / video — interactive only when controls is present
if (tag === 'audio' || tag === 'video') {
return hasAttribute(node, 'controls');
}

return false;
}

function hasAttribute(node, name) {
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
}

module.exports = { isHtmlInteractiveContent };
Loading
Loading