-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add template-no-role-presentation-on-focusable #22
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,72 @@ | ||
| # ember/template-no-role-presentation-on-focusable | ||
|
|
||
| <!-- end auto-generated rule header --> | ||
|
|
||
| Disallow `role="presentation"` / `role="none"` on focusable elements. | ||
|
|
||
| `role="presentation"` and `role="none"` are intended to strip an element's semantics from the accessibility tree. However, when applied to a focusable element, user agents are **required to ignore** the presentation role — the element's native role and semantics are preserved by the browser ([WAI-ARIA 1.2 §4.6 Conflict Resolution](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none)). The author's intent (remove semantics) therefore conflicts with UA behavior (keep semantics), making the attribute misleading and the markup harder to reason about. | ||
|
|
||
| This rule flags the conflict so authors can either remove the `role` (if the native semantics are desired) or remove the focus vector (if the element genuinely should be presentational). | ||
|
|
||
| ## Examples | ||
|
|
||
| This rule **forbids** the following: | ||
|
|
||
| ```gjs | ||
| <template> | ||
| <button role="presentation">Click</button> | ||
| <a href="/x" role="none">Link</a> | ||
| <input type="text" role="presentation" /> | ||
| <div tabindex="0" role="presentation">Focusable</div> | ||
| {{! tabindex="-1" is also flagged — it makes the element programmatically focusable }} | ||
| <div tabindex="-1" role="none">Programmatically focusable</div> | ||
| {{! contenteditable (including valueless / mustache-boolean forms) is focusable }} | ||
| <div contenteditable role="presentation">Editable</div> | ||
| <div contenteditable={{true}} role="presentation">Editable</div> | ||
| </template> | ||
| ``` | ||
|
|
||
| This rule **allows** the following: | ||
|
|
||
| ```gjs | ||
| <template> | ||
| {{! Presentation on non-focusable elements }} | ||
| <div role="presentation"></div> | ||
| <span role="none" class="spacer"></span> | ||
|
|
||
| {{! Presentation + aria-hidden — fully removed from AT }} | ||
| <div role="presentation" aria-hidden="true"></div> | ||
|
|
||
| {{! input type="hidden" isn't focusable }} | ||
| <input type="hidden" role="presentation" /> | ||
| </template> | ||
| ``` | ||
|
|
||
| ## Focusability definition | ||
|
|
||
| Any element with a `tabindex` attribute — including `tabindex="-1"` — is considered focusable by this rule. `tabindex="-1"` removes an element from the sequential tab order but still makes it programmatically focusable (e.g. via `element.focus()`), which is exactly the kind of focus vector that creates the semantic conflict WAI-ARIA §4.6 describes. Elements with `contenteditable` (including the valueless form `<div contenteditable>` and mustache-boolean `contenteditable={{true}}`) are also considered focusable. | ||
|
|
||
| ## Scope / Rationale | ||
|
|
||
| This rule inspects **only the element that carries the `role="presentation"` / `role="none"`** — it does not recurse into descendants. Per [WAI-ARIA 1.2 §4.6 Conflict Resolution](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none) and [§5.3.3 Document Structure](https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles), `role="presentation"` / `role="none"` does **not** cascade to descendants — each descendant retains its own role and semantics. | ||
|
|
||
| As a result, wrapper patterns are **not flagged**: | ||
|
|
||
| ```gjs | ||
| <template> | ||
| {{! Not flagged: the div's role is a no-op (div had no meaningful role to | ||
| suppress), and the button keeps its role + keyboard behavior. }} | ||
| <div role="presentation"> | ||
| <button type="button">Click</button> | ||
| </div> | ||
| </template> | ||
| ``` | ||
|
|
||
| This is a deliberate divergence from [eslint-plugin-vuejs-accessibility's `no-role-presentation-on-focusable`](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-role-presentation-on-focusable.md), which recurses into descendants and flags the wrapper case above. Vue's recursion is uncommented in their source and appears to have been copy-pasted from their `aria-hidden` rule, where descendant recursion **is** spec-correct because `aria-hidden` **does** cascade (see [`template-no-aria-hidden-on-focusable`](./template-no-aria-hidden-on-focusable.md)). | ||
|
johanrd marked this conversation as resolved.
|
||
|
|
||
| ## References | ||
|
|
||
| - [WAI-ARIA 1.2 — presentation role](https://www.w3.org/TR/wai-aria-1.2/#presentation) | ||
| - [WAI-ARIA 1.2 §4.6 — Conflict Resolution for the `presentation` / `none` roles](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none) | ||
| - [WAI-ARIA 1.2 §5.3.3 — Document Structure Roles](https://www.w3.org/TR/wai-aria-1.2/#document_structure_roles) | ||
| - [`no-role-presentation-on-focusable` — eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/no-role-presentation-on-focusable.md) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| // Per WAI-ARIA 1.2 §4.6 Conflict Resolution, role="presentation" / role="none" | ||
| // does NOT cascade to descendants — each descendant retains its own role and | ||
| // semantics. So `<div role="presentation"><button>X</button></div>` is NOT a | ||
| // semantic problem: the div's role is a no-op (div had no meaningful role to | ||
| // suppress), and the button remains fully interactive with its role intact. | ||
| // | ||
| // Therefore, unlike the sibling rule `template-no-aria-hidden-on-focusable` | ||
| // (which recurses into descendants because aria-hidden DOES cascade and creates | ||
| // a keyboard trap landing on AT-hidden content), this rule only checks the | ||
| // element carrying the presentation role. | ||
| // | ||
| // Deliberately diverges from vue-a11y's no-role-presentation-on-focusable, which | ||
| // recurses into descendants. Vue's recursion is uncommented in their source and | ||
| // appears to be a copy-paste from their aria-hidden rule. | ||
|
|
||
| 'use strict'; | ||
|
|
||
| const { isNativeElement } = require('../utils/is-native-element'); | ||
|
|
||
| function findAttr(node, name) { | ||
| return node.attributes?.find((a) => a.name === name); | ||
| } | ||
|
|
||
| function getTextAttrValue(node, name) { | ||
| const attr = findAttr(node, name); | ||
| if (!attr) { | ||
| return undefined; | ||
| } | ||
| // Valueless attribute (e.g. `<div contenteditable>`): HTML treats this as | ||
| // the empty string, which is truthy for contenteditable per the HTML spec. | ||
| if (attr.value === null) { | ||
| return ''; | ||
| } | ||
| if (attr.value?.type === 'GlimmerTextNode') { | ||
| return attr.value.chars; | ||
| } | ||
| // Mustache literal: `contenteditable={{true}}` or `contenteditable={{"true"}}` | ||
| if (attr.value?.type === 'GlimmerMustacheStatement') { | ||
| const path = attr.value.path; | ||
| if (path?.type === 'GlimmerBooleanLiteral') { | ||
| return String(path.value); | ||
| } | ||
| if (path?.type === 'GlimmerStringLiteral') { | ||
| return path.value; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
johanrd marked this conversation as resolved.
|
||
|
|
||
| // Per WAI-ARIA "role" attribute semantics, when multiple whitespace-separated | ||
| // role tokens are supplied, user agents use the FIRST valid token. Subsequent | ||
| // tokens serve as author-provided fallbacks that only apply if the first is | ||
| // invalid/ignored. So `role="button presentation"` resolves to "button" — the | ||
| // element is NOT presentational. We only flag when the FIRST token is | ||
| // presentation/none. | ||
| function hasPresentationRole(node) { | ||
| const attr = findAttr(node, 'role'); | ||
| if (!attr || attr.value?.type !== 'GlimmerTextNode') { | ||
| return false; | ||
| } | ||
| const tokens = attr.value.chars.trim().toLowerCase().split(/\s+/u); | ||
| const first = tokens[0]; | ||
| return first === 'presentation' || first === 'none'; | ||
| } | ||
|
johanrd marked this conversation as resolved.
|
||
|
|
||
| // 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) { | ||
| 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 in scope for the semantic-conflict this rule targets. | ||
| if (findAttr(node, 'tabindex')) { | ||
| return true; | ||
| } | ||
|
johanrd marked this conversation as resolved.
|
||
|
|
||
| // contenteditable (truthy) makes the element focusable. | ||
| const contentEditable = getTextAttrValue(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; | ||
|
johanrd marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| if (UNCONDITIONAL_FOCUSABLE_TAGS.has(tag)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (tag === 'input') { | ||
| const type = getTextAttrValue(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')); | ||
| } | ||
|
Comment on lines
+140
to
+142
|
||
|
|
||
| if (tag === 'audio' || tag === 'video') { | ||
| return Boolean(findAttr(node, 'controls')); | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** @type {import('eslint').Rule.RuleModule} */ | ||
| module.exports = { | ||
| meta: { | ||
| type: 'problem', | ||
| docs: { | ||
| description: 'disallow role="presentation" / role="none" on focusable elements', | ||
| category: 'Accessibility', | ||
| url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-role-presentation-on-focusable.md', | ||
| templateMode: 'both', | ||
| }, | ||
| fixable: null, | ||
| schema: [], | ||
| messages: { | ||
| invalidPresentation: | ||
| 'role="presentation"/"none" must not be used on focusable elements: user agents are expected to ignore role="presentation"/"none" on focusable elements (WAI-ARIA Presentational Roles Conflict Resolution, §4.6), so the markup is misleading — remove the role or remove the focus vector.', | ||
| }, | ||
| }, | ||
|
|
||
| create(context) { | ||
| const sourceCode = context.sourceCode ?? context.getSourceCode(); | ||
| return { | ||
| GlimmerElementNode(node) { | ||
| if (!isNativeElement(node, sourceCode)) { | ||
| return; | ||
| } | ||
| if (!hasPresentationRole(node)) { | ||
| return; | ||
| } | ||
| if (isKeyboardFocusable(node)) { | ||
| context.report({ node, messageId: 'invalidPresentation' }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
| 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; | ||
| } | ||
|
Comment on lines
+52
to
+61
|
||
|
|
||
| // 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'); | ||
| } | ||
|
Comment on lines
+74
to
+80
|
||
|
|
||
| // 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.