Skip to content
Draft
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
3 changes: 3 additions & 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 Expand Up @@ -584,6 +585,8 @@ If you have any suggestions, ideas, or problems, feel free to [create an issue](

### Creating a New Rule

If your rule inspects template attribute values (e.g. mustache forms like `attr={{X}}` or `attr="{{X}}"`), read [docs/glimmer-attribute-behavior.md](docs/glimmer-attribute-behavior.md) first — Glimmer's actual rendering behavior is non-obvious for several common forms, and the doc has the empirically-verified table.

- [Create an issue](https://github.com/ember-cli/eslint-plugin-ember/issues/new) with a description of the proposed rule
- Create files for the [new rule](https://eslint.org/docs/developer-guide/working-with-rules):
- `lib/rules/new-rule.js` (implementation, see [no-proxies](lib/rules/no-proxies.js) for an example)
Expand Down
330 changes: 330 additions & 0 deletions docs/glimmer-attribute-behavior.md

Large diffs are not rendered by default.

70 changes: 70 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,70 @@
# 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.

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

Custom elements (hyphenated tags like `<my-widget>`) are similarly skipped: we
can't know whether their shadow DOM defines a focusable region. If
`<my-widget aria-hidden="true">` renders a focusable element internally, the
trap still exists at runtime — this rule can't detect it.

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)
20 changes: 2 additions & 18 deletions lib/rules/template-block-indentation.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
'use strict';

const { htmlVoidElements } = require('html-void-elements');
const editorConfigUtil = require('../utils/editorconfig');

const VOID_TAGS = new Set([
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]);
const VOID_TAGS = new Set(htmlVoidElements);
const IGNORED_ELEMENTS = new Set(['pre', 'script', 'style', 'textarea']);

function isControlChar(char) {
Expand Down
206 changes: 206 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,206 @@
'use strict';

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

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);
}

// 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think I'm seeing a theme of related utility functions that we may want to extract some place where a11y rules can share them

const value = findAttr(node, 'aria-hidden')?.value;
if (!value) {
return false;
}
// Resolve through getStaticAttrValue so quoted-mustache concat forms
// (e.g. aria-hidden="{{true}}") and case variants normalize uniformly.
const resolved = getStaticAttrValue(value);
if (typeof resolved !== 'string') {
return false;
}
return resolved.trim().toLowerCase() === 'true';
}

// 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',
]);

// 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;
}
const attr = findAttr(node, 'disabled');
// Per docs/glimmer-attribute-behavior.md (rows d3, d6 plus cross-attribute
// observation on falsy-coercion), bare-mustache falsy literals on a boolean
// HTML attribute cause Glimmer to omit the attribute at runtime. We use
// classifyAttribute so the runtime-rendered presence drives the answer
// rather than AST-presence.
return classifyAttribute(attr).presence === 'present';
}

// 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. Use
// classifyAttribute so bare `{{false}}` / `{{null}}` / `{{undefined}}`
// (rows t6, t7) — which Glimmer omits at runtime — are NOT treated as
// having a tabindex.
if (classifyAttribute(findAttr(node, 'tabindex')).presence === 'present') {
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'));
}

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;
}
}
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',
},
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' });
}
},
};
},
};
25 changes: 6 additions & 19 deletions lib/rules/template-self-closing-void-elements.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
'use strict';

const { htmlVoidElements } = require('html-void-elements');

const VOID_ELEMENTS = new Set(htmlVoidElements);

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand Down Expand Up @@ -27,25 +33,6 @@ module.exports = {
},

create(context) {
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
]);

const sourceCode = context.sourceCode;
const config = context.options[0] ?? true;

Expand Down
Loading
Loading