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
68 changes: 43 additions & 25 deletions lib/rules/template-require-mandatory-role-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,57 @@ function createRequiredAttributeErrorMessage(attrs, 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)
);
return {
role,
missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null,
};
}
return null;
}

/** @type {import('eslint').Rule.RuleModule} */
Expand Down Expand Up @@ -83,38 +101,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
204 changes: 204 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,204 @@
// Audit fixture — peer-plugin parity for
// `ember/template-require-mandatory-role-attributes`.
//
// 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
//
// These tests are NOT part of the main suite and do not run in CI. They encode
// the CURRENT behavior of our rule. Each divergence from an upstream plugin is
// annotated as "DIVERGENCE —".

'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' }],
},
],
});
26 changes: 26 additions & 0 deletions tests/lib/rules/template-require-mandatory-role-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
'<template>{{foo-component role="button"}}</template>',
'<template>{{foo-component role="unknown"}}</template>',
'<template>{{foo-component role=role}}</template>',

// Case-insensitive role matching — ARIA role tokens compare as ASCII-case-insensitive.
'<template><div role="COMBOBOX" aria-expanded="false" aria-controls="ctrl" /></template>',
// Role fallback list — primary role's required attributes are satisfied.
'<template><div role="combobox listbox" aria-expanded="false" aria-controls="ctrl" /></template>',
],

invalid: [
Expand Down Expand Up @@ -75,6 +80,27 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
},

// Case-insensitive role matching — uppercase role missing required props is flagged.
{
code: '<template><div role="COMBOBOX"></div></template>',
output: null,
errors: [
{
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
},
],
},
// Role-fallback list: when the primary role is missing required props, flag it.
{
code: '<template><div role="combobox listbox"></div></template>',
output: null,
errors: [
{
message: 'The attributes aria-controls, aria-expanded are required by the role combobox',
},
],
},
],
});

Expand Down
Loading