Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0c1d701
fix(template-no-invalid-role): source valid roles from aria-query; su…
johanrd Apr 21, 2026
8ec2054
fix: template-no-autofocus-attribute — value-aware + <dialog> exception
johanrd Apr 21, 2026
bcabf34
chore: drop temporal 'previously flagged' comment
johanrd Apr 21, 2026
6588862
docs: document <dialog> and falsy-value exceptions
johanrd Apr 21, 2026
b99708a
test: add Phase 3 audit fixture translating aria-role peer cases
johanrd Apr 21, 2026
823631f
fix(template-no-invalid-role): drop unsupported ARIA 1.3 allowlist to…
johanrd Apr 21, 2026
1670293
fix(template-no-autofocus-attribute): align value-aware check with HT…
johanrd Apr 21, 2026
6d4fcda
docs: cite Glimmer VM source verifying autofocus={{false}} omits attr…
johanrd Apr 22, 2026
d0844e1
fix: apply <dialog> exemption to mustache form + update comment (Copi…
johanrd Apr 22, 2026
39d2d27
fix: add 3 missing ARIA 1.3 roles + report specific offending token (…
johanrd Apr 22, 2026
7e45da3
docs: correct audit-fixture CI-run claim (Copilot review)
johanrd Apr 22, 2026
c2a4128
docs: address Copilot review wording (PR #32)
johanrd Apr 23, 2026
f4b5b49
fix(template-no-invalid-role): flag presentation/none only when it's …
johanrd Apr 24, 2026
0a3d1da
lint: reorder test-case properties to [code, output, options, errors]
johanrd Apr 24, 2026
c97a131
fix(#32): address round-2 Copilot review (document hash-pair opt-out;…
johanrd Apr 24, 2026
a3a3884
fix(#55): address round-2 Copilot review (correct audit-fixture heade…
johanrd Apr 24, 2026
57f55fc
fix(template-no-autofocus-attribute): narrow mustache check to native…
johanrd Apr 24, 2026
7ca1edc
lint: normalize string quotes to single per quotes rule
johanrd Apr 24, 2026
909e56d
fix(template-no-invalid-role): preserve author casing in invalid-role…
johanrd Apr 24, 2026
5919767
test(template-no-invalid-role): absorb audit-fixture cases, drop audi…
johanrd Apr 25, 2026
41087e0
fix(template-no-invalid-role): flag empty / whitespace-only role attr…
johanrd Apr 25, 2026
30ce5df
Merge pull request #2743 from johanrd/fix/autofocus-value-aware-and-d…
NullVoxPopuli Apr 25, 2026
f25c34e
chore(deps): update node.js to v24.15.0 (#2740)
renovate[bot] Apr 25, 2026
e34533b
Merge branch 'master' into fix/invalid-role-aria-query
johanrd Apr 25, 2026
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
141 changes: 41 additions & 100 deletions lib/rules/template-no-invalid-role.js
Original file line number Diff line number Diff line change
@@ -1,92 +1,22 @@
const VALID_ROLES = new Set([
'alert',
'alertdialog',
'application',
'article',
const { roles } = require('aria-query');

// Valid ARIA roles = concrete (non-abstract) entries from aria-query, plus the
// WAI-ARIA 1.3 draft roles that aria-query 5.3.2 doesn't yet ship. The
// ARIA 1.2 base roles, DPUB-ARIA (doc-*), and Graphics-ARIA (graphics-*) all
// come from aria-query. `associationlist*`, `comment`, and `suggestion` are in
// the current ARIA 1.3 editor's draft (https://w3c.github.io/aria/) but not
// yet in aria-query, so they're listed here until the next aria-query release
// adds them.
const ARIA_13_DRAFT_ROLES = [
'associationlist',
'associationlistitemkey',
'associationlistitemvalue',
'banner',
'blockquote',
'button',
'caption',
'cell',
'checkbox',
'code',
'columnheader',
'combobox',
'comment',
'complementary',
'contentinfo',
'definition',
'deletion',
'dialog',
'directory',
'document',
'emphasis',
'feed',
'figure',
'form',
'generic',
'grid',
'gridcell',
'group',
'heading',
'img',
'insertion',
'link',
'list',
'listbox',
'listitem',
'log',
'main',
'mark',
'marquee',
'math',
'menu',
'menubar',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'meter',
'navigation',
'none',
'note',
'option',
'paragraph',
'presentation',
'progressbar',
'radio',
'radiogroup',
'region',
'row',
'rowgroup',
'rowheader',
'scrollbar',
'search',
'searchbox',
'separator',
'slider',
'spinbutton',
'status',
'strong',
'subscript',
'suggestion',
'superscript',
'switch',
'tab',
'table',
'tablist',
'tabpanel',
'term',
'textbox',
'time',
'timer',
'toolbar',
'tooltip',
'tree',
'treegrid',
'treeitem',
];
const VALID_ROLES = new Set([
...[...roles.keys()].filter((role) => !roles.get(role).abstract),
...ARIA_13_DRAFT_ROLES,
]);

// Elements with semantic meaning that should not be given role="presentation" or role="none"
Expand Down Expand Up @@ -225,34 +155,45 @@ module.exports = {
return;
}

const role = roleAttr.value.chars.trim();
if (!role) {
const raw = roleAttr.value.chars.trim();
if (!raw) {
return;
}

const roleLower = role.toLowerCase();
// ARIA role attribute is a whitespace-separated list of tokens
// (role-fallback pattern per ARIA 1.2 §5.4). Validate each token.
const tokens = raw.split(/\s+/u).map((t) => t.toLowerCase());

// Check for nonexistent roles
if (catchNonexistentRoles && !VALID_ROLES.has(roleLower)) {
context.report({
node: roleAttr,
messageId: 'invalid',
data: { role },
});
return;
if (catchNonexistentRoles) {
const invalidToken = tokens.find((token) => !VALID_ROLES.has(token));
if (invalidToken) {
Comment thread
johanrd marked this conversation as resolved.
Outdated
context.report({
node: roleAttr,
messageId: 'invalid',
data: { role: invalidToken },
});
return;
}
}

// Check for presentation/none role on semantic elements (case-insensitive per WAI-ARIA 1.2:
// "Case-sensitivity of the comparison inherits from the case-sensitivity of the host language"
// and HTML is case-insensitive — https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles)
// Flag presentation/none only when it's the FIRST recognised role per
// WAI-ARIA §4.1 fallback semantics — UAs walk the token list and use
// the first role they recognise; subsequent tokens are author-provided
// fallbacks that never take effect if the first is recognised. So
// `role="button presentation"` resolves to `button` at runtime and
// should NOT flag. `role="xxyxyz presentation"` resolves to
// `presentation` (unknown tokens are skipped) and SHOULD flag on a
// semantic element. Case-insensitivity inherits from HTML per §4.1:
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
const firstRecognisedRole = tokens.find((t) => VALID_ROLES.has(t));
if (
(roleLower === 'presentation' || roleLower === 'none') &&
(firstRecognisedRole === 'presentation' || firstRecognisedRole === 'none') &&
SEMANTIC_ELEMENTS.has(node.tag)
) {
context.report({
node: roleAttr,
messageId: 'presentationOnSemantic',
data: { role, tag: node.tag },
data: { role: firstRecognisedRole, tag: node.tag },
});
}
},
Expand Down
159 changes: 159 additions & 0 deletions tests/audit/aria-role/peer-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Audit fixture — translates peer-plugin test cases into assertions against
// our rule (`ember/template-no-invalid-role` + `ember/template-no-abstract-roles`).
Comment thread
johanrd marked this conversation as resolved.
Outdated
// Runs as part of the default Vitest suite (via the `tests/**/*.js` include
// glob) and serves double-duty: (1) auditable record of peer-parity
// divergences, (2) regression coverage pinning CURRENT behavior. Each case
Comment thread
johanrd marked this conversation as resolved.
Outdated
// encodes what OUR rule does today; divergences from upstream plugins are
// annotated as `DIVERGENCE —`. Peer-only constructs that can't be translated
// to Ember templates (JSX spread props, Vue v-bind, Angular `$event`,
// undefined-handler expression analysis) are marked `AUDIT-SKIP`.
//
// Peers covered: jsx-a11y/aria-role, vuejs-accessibility/aria-role,
// lit-a11y/aria-role.
//
// Source files (context/ checkouts):
// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/aria-role-test.js
// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/aria-role.test.ts
// - eslint-plugin-lit-a11y/tests/lib/rules/aria-role.js

'use strict';

const rule = require('../../../lib/rules/template-no-invalid-role');
const RuleTester = require('eslint').RuleTester;

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('audit:aria-role (gts)', rule, {
valid: [
// === Upstream parity (valid in both jsx-a11y and us) ===
// jsx-a11y: valid (base case, no role)
'<template><div /></template>',
'<template><div></div></template>',

// jsx-a11y / vue-a11y / lit-a11y: valid (concrete, non-abstract, single role)
'<template><div role="button"></div></template>',
'<template><div role="progressbar"></div></template>',
'<template><div role="navigation"></div></template>',
'<template><div role="alert"></div></template>',
'<template><div role="switch"></div></template>',

// Dynamic role — both plugins and we skip
'<template><div role={{this.role}}></div></template>',
'<template><div role="{{if @open "dialog" "contentinfo"}}"></div></template>',

// === DIVERGENCE — case-insensitivity ===
// jsx-a11y: INVALID (`<div role="Button" />` is rejected, case-sensitive).
// Our rule lowercases the role before lookup; we allow this. Intentional:
// HTML attribute values are case-insensitive in many contexts, and the
// existing test suite encodes this as an explicit design choice.
'<template><div role="Button">Click</div></template>',
'<template><div role="NAVIGATION">Nav</div></template>',

// === Parity — space-separated multiple roles ===
// jsx-a11y / vuejs-accessibility: VALID — splits on whitespace, each
// token must be a valid role. Our rule now does the same.
'<template><div role="tabpanel row"></div></template>',
'<template><section role="doc-appendix doc-bibliography"></section></template>',

// === Parity — DPUB-ARIA (doc-*) roles ===
// jsx-a11y / vuejs-accessibility: VALID via aria-query. Our rule now
// derives VALID_ROLES from aria-query's concrete role keys, covering
// all 40+ doc-* roles.
'<template><div role="doc-abstract"></div></template>',
'<template><section role="doc-appendix"></section></template>',

// === Parity — Graphics-ARIA (graphics-*) roles on <svg> ===
// jsx-a11y: VALID. Our rule: VALID via aria-query.
'<template><svg role="graphics-document"></svg></template>',
'<template><svg role="graphics-document document"></svg></template>',
],

invalid: [
// === Upstream parity (invalid in both jsx-a11y and us) ===
{
code: '<template><div role="foobar"></div></template>',
output: null,
errors: [{ messageId: 'invalid' }],
},
{
code: '<template><div role="datepicker"></div></template>',
output: null,
errors: [{ messageId: 'invalid' }],
},
// jsx-a11y: invalid (`range` is an abstract role).
// Ours: `range` is not in VALID_ROLES so we flag it as "not a valid ARIA role".
// Upstream says "abstract role"; we conflate. Message wording differs.
{
code: '<template><div role="range"></div></template>',
output: null,
errors: [{ messageId: 'invalid' }],
},

// === DIVERGENCE — empty role string ===
// jsx-a11y: INVALID — `<div role="" />` flagged.
// vue-a11y: INVALID — same.
// Our rule: early-return on empty/whitespace role (line 229 of rule). NO FLAG.
Comment thread
johanrd marked this conversation as resolved.
Outdated
// So this case reflects OUR (non-flagging) behavior with an explicit note.
// (No invalid assertion possible here — we'd need to move this to valid,
// or fix the rule to flag.)

// === Parity — space-separated with at least one invalid token ===
// jsx-a11y: INVALID — splits and flags the token `foobar`.
// Our rule: splits on whitespace and now names the offending token
// specifically (`'foobar'`) rather than the whole compound string.
{
code: '<template><div role="tabpanel row foobar"></div></template>',
output: null,
errors: [{ messageId: 'invalid' }],
},
],
});

// === DIVERGENCE — empty role string (captured as valid because we don't flag) ===
// Intentionally isolated so the intent is clear.
ruleTester.run('audit:aria-role empty string (gts)', rule, {
valid: [
// jsx-a11y + vue-a11y both flag this. We don't. This captures OUR behavior.
'<template><div role=""></div></template>',
],
invalid: [],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('audit:aria-role (hbs)', rule, {
valid: [
'<div></div>',
'<div role="button"></div>',
'<div role="navigation"></div>',
// DIVERGENCE case-insensitivity (see gts section).
'<div role="Button"></div>',
// DIVERGENCE empty string (we don't flag).
'<div role=""></div>',
// Parity — space-separated all-valid tokens.
'<div role="tabpanel row"></div>',
// Parity — DPUB-ARIA.
'<div role="doc-abstract"></div>',
// Parity — Graphics-ARIA on <svg>.
'<svg role="graphics-document"></svg>',
],
invalid: [
{
code: '<div role="foobar"></div>',
output: null,
errors: [{ messageId: 'invalid' }],
},
// Parity — compound with at least one invalid token.
{
code: '<div role="tabpanel row foobar"></div>',
output: null,
errors: [{ messageId: 'invalid' }],
},
],
});
Loading
Loading