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 @@ -277,6 +277,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | 📋 | | |
| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | 📋 | | |
| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | 📋 | 🔧 | |
| [template-no-role-presentation-on-focusable](docs/rules/template-no-role-presentation-on-focusable.md) | disallow role="presentation" / role="none" on focusable elements | | | |
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | 📋 | 🔧 | |
| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | 📋 | | |
| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | 📋 | 🔧 | |
Expand Down
72 changes: 72 additions & 0 deletions docs/rules/template-no-role-presentation-on-focusable.md
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>
```
Comment thread
johanrd marked this conversation as resolved.

## 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)).
Comment thread
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)
185 changes: 185 additions & 0 deletions lib/rules/template-no-role-presentation-on-focusable.js
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;
}
Comment thread
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';
}
Comment thread
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',
]);
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) {
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;
}
Comment thread
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;
Comment thread
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
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.

isKeyboardFocusable currently treats details, option, and datalist as unconditionally focusable, and treats img[usemap] as focusable. These elements are not generally keyboard-focusable themselves (e.g., <option>/<datalist> are not focusable form controls on their own; image maps transfer focus/interaction to <area href>, not the <img>). This can produce false positives for the rule. Recommendation: remove details/option/datalist from UNCONDITIONAL_FOCUSABLE_TAGS, and drop (or rework) the img[usemap] focusability branch (leave focusability to tabindex/other explicit focus vectors; handle <area href> separately as you already do). Add targeted tests proving the corrected behavior for these tags.

Copilot uses AI. Check for mistakes.

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' });
}
},
};
},
};
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;
}
Comment on lines +52 to +61
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 new options.ignoreUsemap branch is user-visible behavior but isn’t covered by the new unit tests. Add a test asserting that isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue, { ignoreUsemap: true }) returns false (and ideally a companion test for the default behavior returning true).

Copilot uses AI. Check for mistakes.

// 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
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 new options.ignoreUsemap branch is user-visible behavior but isn’t covered by the new unit tests. Add a test asserting that isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue, { ignoreUsemap: true }) returns false (and ideally a companion test for the default behavior returning true).

Copilot uses AI. Check for mistakes.

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