-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add template-no-aria-hidden-on-focusable #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| ## 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
|
||||||||||||||||||||||||
| 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. |
| 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); | ||
| } | ||
|
|
||
|
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', | ||
| ]); | ||
|
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')); | ||
| } | ||
|
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; | ||
| } | ||
|
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', | ||
| }, | ||
|
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' }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
| 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 }; |
Uh oh!
There was an error while loading. Please reload this page.