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
6 changes: 6 additions & 0 deletions docs/rules/template-require-mandatory-role-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ This rule **allows** the following:
<div role="option" aria-selected="false" />
<CustomComponent role="checkbox" aria-required="true" aria-checked="false" />
{{some-component role="heading" aria-level="2"}}

{{! <input type="checkbox|radio"> supplies aria-checked natively for roles that require it. }}
<input type="checkbox" role="switch" />
<input type="checkbox" role="menuitemcheckbox" />
<input type="radio" role="menuitemradio" />
</template>
```

## References

- [WAI-ARIA Roles - Accessibility \_ MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
- [WAI-ARIA APG — Switch pattern](https://www.w3.org/WAI/ARIA/apg/patterns/switch/) (documents `<input type="checkbox" role="switch">` as an accessible switch)
54 changes: 49 additions & 5 deletions lib/rules/template-require-mandatory-role-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,57 @@ function getStaticRoleFromMustache(node) {
return undefined;
}

function getMissingRequiredAttributes(role, foundAriaAttributes) {
// `{input type}:{role}` pairings where the required `aria-checked` is supplied
// natively by the semantic <input>. Pairings match axobject-query's
// `elementAXObjects` for the input-role combinations and the WAI-ARIA APG
// Switch pattern (https://www.w3.org/WAI/ARIA/apg/patterns/switch/).
// Undocumented pairings (e.g. `input[type=checkbox] role=radio`) are NOT
// exempted.
const NATIVELY_CHECKED_INPUT_ROLE_PAIRS = new Set([
'checkbox:checkbox',
'checkbox:switch',
'checkbox:menuitemcheckbox',
'radio:radio',
'radio:menuitemradio',
]);

function getInputType(node) {
if (node?.tag !== 'input') {
return undefined;
}
const typeAttr = node.attributes?.find((a) => a.name === 'type');
if (typeAttr?.value?.type === 'GlimmerTextNode') {
// HTML input `type` keywords are ASCII case-insensitive.
return typeAttr.value.chars?.toLowerCase();
}
return undefined;
}

function isNativelyChecked(node, role) {
const type = getInputType(node);
if (!type) {
return false;
}
return NATIVELY_CHECKED_INPUT_ROLE_PAIRS.has(`${type}:${role}`);
}

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

if (!roleDefinition) {
return null;
}

const requiredAttributes = Object.keys(roleDefinition.requiredProps);
const missingRequiredAttributes = requiredAttributes.filter(
(requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute)
);
const missingRequiredAttributes = requiredAttributes.filter((requiredAttribute) => {
if (foundAriaAttributes.includes(requiredAttribute)) {
return false;
}
if (requiredAttribute === 'aria-checked' && isNativelyChecked(node, role)) {
return false;
}
return true;
});

return missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null;
}
Expand Down Expand Up @@ -93,7 +133,11 @@ module.exports = {
.filter((attribute) => attribute.name?.startsWith('aria-'))
.map((attribute) => attribute.name);

const missingRequiredAttributes = getMissingRequiredAttributes(role, foundAriaAttributes);
const missingRequiredAttributes = getMissingRequiredAttributes(
role,
foundAriaAttributes,
node
);

if (missingRequiredAttributes) {
reportMissingAttributes(node, role, missingRequiredAttributes);
Expand Down
202 changes: 202 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,202 @@
// 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>',

// === Parity — <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: VALID via the explicit `{input[type], role}` whitelist
// (checkbox+switch per WAI-ARIA APG Switch pattern; see PR adding the fix).
// Also covers checkbox+checkbox, checkbox+menuitemcheckbox, radio+radio,
// radio+menuitemradio.
'<template><input type="checkbox" role="switch" /></template>',
'<template><input type="checkbox" role="checkbox" /></template>',
'<template><input type="checkbox" role="menuitemcheckbox" /></template>',
'<template><input type="radio" role="radio" /></template>',
'<template><input type="radio" role="menuitemradio" /></template>',
// HTML type keyword values are ASCII case-insensitive, so the whitelist
// also matches uppercase `type` values.
'<template><input type="CHECKBOX" role="switch" /></template>',

// === DIVERGENCE — space-separated role tokens ===
// jsx-a11y + vue: split on whitespace, validate each token. If every token
// is a valid role, require attrs for each.
// Our rule: looks up the whole string in aria-query. `"combobox listbox"`
// is not a role → returns null → no missing attrs → NO FLAG.
// Net: jsx-a11y would flag `<div role="combobox listbox">` (missing attrs
// for both), we don't. Captured as valid below.
'<template><div role="combobox listbox" /></template>',

// === DIVERGENCE — case-insensitivity on role value ===
// jsx-a11y + vue + angular: lowercase the role value before lookup.
// `<div role="COMBOBOX" />` → INVALID (missing aria-expanded/controls).
// Our rule: passes the raw string; aria-query lookup misses → no flag.
'<template><div role="COMBOBOX" /></template>',
'<template><div role="SLIDER" /></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' }],
},

// === DIVERGENCE — 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' }],
},

// === DIVERGENCE — undocumented input+role pairings ===
// Pairings NOT on our whitelist remain flagged. jsx-a11y/angular defer to
// axobject-query's `elementAXObjects`, which covers a larger set; we only
// recognize the documented five pairings. Example mismatches:
// - input[type=checkbox] role=radio → we flag, jsx-a11y/angular don't
// - input[type=radio] role=switch → we flag, peer behavior varies
// These remain invalid (missing aria-checked) in our rule.
{
code: '<template><input type="checkbox" role="radio" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<template><input type="radio" role="switch" /></template>',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
// Bare `<input role="switch">` (no `type`) — not on our whitelist, stays
// flagged. The input's default `type=text` does not expose aria-checked.
{
code: '<template><input 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" />',
// Parity: input+role semantic whitelist.
'<input type="checkbox" role="switch" />',
'<input type="radio" role="menuitemradio" />',
// DIVERGENCES captured as valid-for-us:
// space-separated
'<div role="combobox listbox" />',
// case-insensitivity
'<div role="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' }],
},
// DIVERGENCE: pairings NOT in our input+role whitelist stay flagged.
// jsx-a11y/angular recognize more pairings via axobject-query.
{
code: '<input type="checkbox" role="radio" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
{
code: '<input role="switch" />',
output: null,
errors: [{ messageId: 'missingAttributes' }],
},
],
});
78 changes: 78 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,18 @@ 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>',

// Semantic inputs supply aria-checked natively; the role is satisfied
// without an explicit aria-checked attribute. Documented accessible
// patterns: https://www.w3.org/WAI/ARIA/apg/patterns/switch/#keyboardinteraction
'<template><input type="checkbox" role="switch" /></template>',
'<template><input type="checkbox" role="menuitemcheckbox" /></template>',
'<template><input type="radio" role="menuitemradio" /></template>',
'<template><input type="radio" role="radio" /></template>',
'<template><input type="checkbox" role="checkbox" /></template>',

// HTML `type` is ASCII case-insensitive; `Checkbox` must match `checkbox`.
'<template><input type="Checkbox" role="switch" /></template>',
],

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

// Undocumented {input type, role} pairings are NOT exempted.
{
code: '<template><input type="checkbox" role="radio" /></template>',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role radio' }],
},
{
code: '<template><input type="radio" role="switch" /></template>',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
{
code: '<template><input type="radio" role="checkbox" /></template>',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
},
{
code: '<template><input type="text" role="switch" /></template>',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
{
// No `type` attribute; defaults to text.
code: '<template><input role="switch" /></template>',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
],
});

Expand Down Expand Up @@ -105,6 +145,16 @@ hbsRuleTester.run('template-require-mandatory-role-attributes', rule, {
'{{foo-component role="button"}}',
'{{foo-component role="unknown"}}',
'{{foo-component role=role}}',

// Semantic inputs supply aria-checked natively.
'<input type="checkbox" role="switch" />',
'<input type="checkbox" role="menuitemcheckbox" />',
'<input type="radio" role="menuitemradio" />',
'<input type="radio" role="radio" />',
'<input type="checkbox" role="checkbox" />',

// HTML `type` is ASCII case-insensitive; `Checkbox` must match `checkbox`.
'<input type="Checkbox" role="switch" />',
],
invalid: [
{
Expand Down Expand Up @@ -146,5 +196,33 @@ hbsRuleTester.run('template-require-mandatory-role-attributes', rule, {
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
},

// Undocumented {input type, role} pairings are NOT exempted.
{
code: '<input type="checkbox" role="radio" />',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role radio' }],
},
{
code: '<input type="radio" role="switch" />',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
{
code: '<input type="radio" role="checkbox" />',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role checkbox' }],
},
{
code: '<input type="text" role="switch" />',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
{
// No `type` attribute; defaults to text.
code: '<input role="switch" />',
output: null,
errors: [{ message: 'The attribute aria-checked is required by the role switch' }],
},
],
});
Loading