Skip to content
78 changes: 45 additions & 33 deletions lib/rules/template-require-mandatory-role-attributes.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,58 @@
const { roles } = require('aria-query');

function createRequiredAttributeErrorMessage(attrs, role) {
if (attrs.length < 2) {
return `The attribute ${attrs[0]} is required by the role ${role}`;
}

return `The attributes ${attrs.join(', ')} are required by the role ${role}`;
}

function getStaticRoleFromElement(node) {
// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
// Returns the list of normalised tokens, or undefined when the attribute is
// missing or dynamic.
function getStaticRolesFromElement(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');

if (roleAttr?.value?.type === 'GlimmerTextNode') {
return roleAttr.value.chars || undefined;
return splitRoleTokens(roleAttr.value.chars);
}

return undefined;
}

function getStaticRoleFromMustache(node) {
function getStaticRolesFromMustache(node) {
const rolePair = node.hash?.pairs?.find((pair) => pair.key === 'role');

if (rolePair?.value?.type === 'GlimmerStringLiteral') {
return rolePair.value.value;
return splitRoleTokens(rolePair.value.value);
}

return undefined;
}

function getMissingRequiredAttributes(role, foundAriaAttributes) {
const roleDefinition = roles.get(role);

if (!roleDefinition) {
return null;
function splitRoleTokens(value) {
if (!value) {
return undefined;
}
const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean);
return tokens.length > 0 ? tokens : undefined;
}

const requiredAttributes = Object.keys(roleDefinition.requiredProps);
const missingRequiredAttributes = requiredAttributes.filter(
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
);

return missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null;
// For an ARIA role-fallback list like "combobox listbox", check required
// attributes against the FIRST recognised role (the primary) per ARIA 1.2
// role-fallback semantics — a user agent picks the first role it recognises.
// Diverges from jsx-a11y, which validates every recognised token.
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes) {
for (const role of roleTokens) {
const roleDefinition = roles.get(role);
if (!roleDefinition) {
continue;
}
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
const missingRequiredAttributes = requiredAttributes
.filter((requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute))
// Sort for deterministic report order (aria-query's requiredProps
// iteration order is not guaranteed stable across versions).
.sort();
return {
role,
missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null,
};
}
return null;
}

/** @type {import('eslint').Rule.RuleModule} */
Expand Down Expand Up @@ -83,38 +95,38 @@ module.exports = {

return {
GlimmerElementNode(node) {
const role = getStaticRoleFromElement(node);
const roleTokens = getStaticRolesFromElement(node);

if (!role) {
if (!roleTokens) {
return;
}

const foundAriaAttributes = (node.attributes ?? [])
.filter((attribute) => attribute.name?.startsWith('aria-'))
.map((attribute) => attribute.name);

const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes);

if (missingRequiredAttributes) {
reportMissingAttributes(node, role, missingRequiredAttributes);
if (result?.missing) {
reportMissingAttributes(node, result.role, result.missing);
}
},

GlimmerMustacheStatement(node) {
const role = getStaticRoleFromMustache(node);
const roleTokens = getStaticRolesFromMustache(node);

if (!role) {
if (!roleTokens) {
return;
}

const foundAriaAttributes = (node.hash?.pairs ?? [])
.filter((pair) => pair.key.startsWith('aria-'))
.map((pair) => pair.key);

const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes);

if (missingRequiredAttributes) {
reportMissingAttributes(node, role, missingRequiredAttributes);
if (result?.missing) {
reportMissingAttributes(node, result.role, result.missing);
}
},
};
Expand Down
207 changes: 207 additions & 0 deletions tests/audit/role-has-required-aria/peer-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Audit fixture — translates peer-plugin test cases into assertions against
// our rule (`ember/template-require-mandatory-role-attributes`). 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`.
//
// Source files (context/ checkouts):
// - eslint-plugin-jsx-a11y-main/src/rules/role-has-required-aria-props.js
// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/role-has-required-aria-props-test.js
// - eslint-plugin-vuejs-accessibility-main/src/rules/role-has-required-aria-props.ts
// - angular-eslint-main/packages/eslint-plugin-template/src/rules/role-has-required-aria.ts
// - angular-eslint-main/packages/eslint-plugin-template/tests/rules/role-has-required-aria/cases.ts
// - eslint-plugin-lit-a11y/lib/rules/role-has-required-aria-attrs.js

'use strict';

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

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

ruleTester.run('audit:role-has-required-aria (gts)', rule, {
valid: [
// === Upstream parity (valid everywhere) ===
'<template><div /></template>',
'<template><div role="button" /></template>', // no required props
'<template><div role="heading" aria-level="2" /></template>',
'<template><span role="button">X</span></template>',
// checkbox with aria-checked — valid in all plugins.
'<template><div role="checkbox" aria-checked="false" /></template>',
// combobox with BOTH required props (jsx-a11y, vue, ours).
'<template><div role="combobox" aria-expanded="false" aria-controls="id" /></template>',
// scrollbar requires aria-valuenow, aria-valuemin, aria-valuemax, aria-controls, aria-orientation.
// slider similarly — we leave the all-present case off for brevity.

// Dynamic role — skipped by all.
'<template><div role={{this.role}} /></template>',

// Unknown role — jsx-a11y filters out unknown, we return null. Both allow.
'<template><div role="foobar" /></template>',

// === DIVERGENCE — <input type="checkbox" role="switch"> ===
// jsx-a11y: VALID via `isSemanticRoleElement` (semantic input[type=checkbox]
// counts as already-declaring aria-checked via its `checked` state).
// vue-a11y: VALID via explicit carve-out in filterRequiredPropsExceptions.
// angular: VALID via isSemanticRoleElement.
// Our rule: INVALID — we treat every element generically and `switch` has
// `aria-checked` as a required prop. Captured in invalid section below.

// === Partial parity — space-separated role tokens ===
// jsx-a11y + vue: split on whitespace, validate EACH recognised token.
// Our rule: splits on whitespace, validates only the FIRST recognised
// token (ARIA 1.2 §4.1 role-fallback semantics — UA picks the first
// recognised role). So `<div role="button combobox">` — which has
// "button" as the first recognised token (no required attrs) —
// remains valid for us but jsx-a11y would flag it for missing
// combobox attrs.
'<template><div role="button combobox" /></template>',
// Both-token case where the first token HAS no required attrs: valid
// for us, invalid for jsx-a11y.
'<template><div role="heading button" aria-level="2" /></template>',
],

invalid: [
// === Upstream parity (invalid everywhere) ===
{
code: '<template><div role="slider" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="checkbox" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="combobox" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="scrollbar" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="heading" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="option" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},

// === Partial parity — partial attrs present, still missing one ===
// jsx-a11y flags `<div role="scrollbar" aria-valuemax aria-valuemin />`
// (missing aria-controls/aria-orientation/aria-valuenow).
// Our rule: also flags — missing-attrs list non-empty. Parity.
{
code: '<template><div role="combobox" aria-controls="x" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},

// === Parity — case-insensitive role comparison ===
// jsx-a11y + vue + angular lowercase the role value before lookup.
// Our rule now does the same, so `<div role="COMBOBOX" />` → INVALID
// (missing aria-expanded / aria-controls).
{
code: '<template><div role="COMBOBOX" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><div role="SLIDER" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},

// === Parity — whitespace-separated roles, first recognised validated ===
// `<div role="combobox listbox">` — both tokens are recognised roles
// with required attrs. Per ARIA role-fallback semantics we validate
// the first recognised token (combobox). jsx-a11y validates every
// token; both plugins end up flagging this same code (though our
// error names `combobox`, jsx-a11y may cite all missing attrs).
{
code: '<template><div role="combobox listbox" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},

// === DIVERGENCE — input[type=checkbox] role="switch" ===
// jsx-a11y / vue / angular: VALID (semantic exception).
// Our rule: INVALID (missing aria-checked). FALSE POSITIVE.
// (This PR does not fix the semantic-input exception; separate
// fix lives on fix/role-required-aria-checkbox-switch.)
{
code: '<template><input type="checkbox" role="switch" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
],
});

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

hbsRuleTester.run('audit:role-has-required-aria (hbs)', rule, {
valid: [
'<div />',
'<div role="button" />',
'<div role="heading" aria-level="2" />',
'<div role="combobox" aria-expanded="false" aria-controls="id" />',
// Partial-parity: role-fallback validates only the first recognised
// token. `<div role="button combobox">` is valid for us (first
// recognised token "button" needs no attrs) but flagged by jsx-a11y.
'<div role="button combobox" />',
// unknown role
'<div role="foobar" />',
],
invalid: [
{
code: '<div role="slider" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<div role="checkbox" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<div role="heading" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
// Parity — case-insensitive comparison.
{
code: '<div role="COMBOBOX" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
// Parity — whitespace-separated roles, first recognised validated.
{
code: '<div role="combobox listbox" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
// DIVERGENCE: semantic input exception — jsx-a11y/vue/angular say VALID.
{
code: '<input type="checkbox" role="switch" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
],
});
Loading
Loading