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
135 changes: 35 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,39 @@ 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) {
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)
if (
(roleLower === 'presentation' || roleLower === 'none') &&
SEMANTIC_ELEMENTS.has(node.tag)
) {
const offendingToken = tokens.find((t) => t === 'presentation' || t === 'none');
if (offendingToken && SEMANTIC_ELEMENTS.has(node.tag)) {
context.report({
node: roleAttr,
messageId: 'presentationOnSemantic',
data: { role, tag: node.tag },
// Report the specific offending token, not the whole raw role
// string — e.g. for role="presentation foo" we point at
// 'presentation' rather than the full attribute value.
data: { role: offendingToken, 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`).
// 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
// 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.
// 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