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
97 changes: 68 additions & 29 deletions lib/rules/template-no-unsupported-role-attributes.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,78 @@
const { roles, elementRoles } = require('aria-query');

function createUnsupportedAttributeErrorMessage(attribute, role, element) {
if (element) {
return `The attribute ${attribute} is not supported by the element ${element} with the implicit role of ${role}`;
function getStaticAttrValue(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
if (!attr) {
return undefined;
}

return `The attribute ${attribute} is not supported by the role ${role}`;
if (!attr.value || attr.value.type !== 'GlimmerTextNode') {
// Presence with dynamic value — treat as "set" but unknown string.
return '';
}
return attr.value.chars.trim();
}

function getImplicitRole(tagName, typeAttribute) {
if (tagName === 'input') {
for (const key of elementRoles.keys()) {
if (key.name === tagName && key.attributes) {
for (const attribute of key.attributes) {
if (attribute.name === 'type' && attribute.value === typeAttribute) {
return elementRoles.get(key)[0];
}
}
}
}
function nodeSatisfiesAttributeConstraint(node, attrSpec) {
const value = getStaticAttrValue(node, attrSpec.name);
const isSet = value !== undefined;

if (attrSpec.constraints?.includes('set')) {
return isSet;
}
if (attrSpec.constraints?.includes('undefined')) {
return !isSet;
}
if (attrSpec.value !== undefined) {
// HTML enumerated attribute values are ASCII case-insensitive
// (HTML common-microsyntaxes §2.3.3). aria-query's attrSpec.value is
// already lowercase, so lowercase the node's value for comparison.
return isSet && value.toLowerCase() === attrSpec.value;
}
// No constraint listed — just require presence.
return isSet;
}

const key = [...elementRoles.keys()].find((entry) => entry.name === tagName);
const implicitRoles = key && elementRoles.get(key);
function keyMatchesNode(node, key) {
if (key.name !== node.tag) {
return false;
}
if (!key.attributes || key.attributes.length === 0) {
return true;
}
return key.attributes.every((attrSpec) => nodeSatisfiesAttributeConstraint(node, attrSpec));
}

return implicitRoles && implicitRoles[0];
function getImplicitRole(node) {
// Honor aria-query's attribute constraints when mapping element -> implicit role.
// Each elementRoles entry lists attributes that must match (with optional
// constraints "set" / "undefined"); pick the most specific entry whose
// attribute spec is fully satisfied by the node.
//
// Heuristic: "specificity = attribute-constraint count". aria-query exports
// elementRoles as an unordered Map and does not document how consumers
// should resolve multi-match cases; this count-based tiebreak is an
// inference from the data shape. It resolves the motivating bugs:
// - <input type="text"> without `list` → textbox, not combobox
// (the combobox entry requires `list=set`, a stricter 2-attr match;
// the textbox entry's 1-attr type=text wins when `list` is absent).
// - <input type="password"> → no role (no elementRoles entry matches).
// If aria-query ever publishes a resolution order, switch to that.
let bestKey;
let bestSpecificity = -1;
for (const key of elementRoles.keys()) {
if (!keyMatchesNode(node, key)) {
continue;
}
const specificity = key.attributes?.length ?? 0;
if (specificity > bestSpecificity) {
bestKey = key;
bestSpecificity = specificity;
}
}
if (!bestKey) {
return undefined;
}
return elementRoles.get(bestKey)[0];
}

function getExplicitRole(node) {
Expand All @@ -35,14 +83,6 @@ function getExplicitRole(node) {
return null;
}

function getTypeAttribute(node) {
const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
if (typeAttr && typeAttr.value?.type === 'GlimmerTextNode') {
return typeAttr.value.chars.trim();
}
return null;
}

function removeRangeWithAdjacentWhitespace(sourceText, range) {
let [start, end] = range;

Expand Down Expand Up @@ -111,8 +151,7 @@ module.exports = {

if (!role) {
element = node.tag;
const typeAttribute = getTypeAttribute(node);
role = getImplicitRole(element, typeAttribute);
role = getImplicitRole(node);
}

if (!role) {
Expand Down
213 changes: 213 additions & 0 deletions tests/audit/role-supports-aria-props/peer-parity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Audit fixture — translates peer-plugin test cases into assertions against
// our rule (`ember/template-no-unsupported-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-supports-aria-props.js
// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/role-supports-aria-props-test.js
// - eslint-plugin-lit-a11y/lib/rules/role-supports-aria-attr.js
// - eslint-plugin-lit-a11y/tests/lib/rules/role-supports-aria-attr.js

'use strict';

const rule = require('../../../lib/rules/template-no-unsupported-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-supports-aria-props (gts)', rule, {
valid: [
// === Upstream parity (valid in jsx-a11y + ours) ===
'<template><div /></template>',
'<template><div id="main" /></template>',

// Explicit role with supported attr.
'<template><div role="heading" aria-level="1" /></template>',
'<template><div role="button" aria-disabled="true" /></template>',
'<template><div role="textbox" aria-required="true" aria-errormessage="err" /></template>',
'<template><span role="checkbox" aria-checked={{this.checked}} /></template>',

// Implicit role tests that match between jsx-a11y and aria-query
// (we rely on aria-query's elementRoles).
// a with href — aria-query gives "generic" for first match; jsx-a11y
// gives "link". Both happen to support aria-describedby etc.
'<template><a href="#" aria-describedby="x"></a></template>',
// input[type=submit] — implicit "button".
'<template><input type="submit" aria-disabled="true" /></template>',
// select — implicit "combobox".
'<template><select aria-expanded="false" aria-controls="ctrlID" /></template>',
// menu[type=toolbar] — aria-query gives "list"; jsx-a11y gives "toolbar".
// aria-hidden is a global attr supported by both — valid in both.
'<template><menu type="toolbar" aria-hidden="true" /></template>',

// Components / unknown elements are skipped.
'<template><CustomComponent role="banner" /></template>',
'<template><some-custom-element /></template>',

// Dynamic role (mustache value) — we skip.
// jsx-a11y similarly skips non-literal role values.

// === DIVERGENCE — <a> without href ===
// jsx-a11y: `<a>` without href has NO implicit role → uses global aria
// set → `<a aria-checked />` is VALID.
// Our rule: aria-query's first entry for `a` has no attribute constraint
// and returns "generic". `generic` does not support aria-checked → we
// would FLAG. (Invalid section captures this.)
// Captured as the opposite: `<a aria-describedby="x">` passes in both
// because aria-describedby is global.
'<template><a aria-describedby="x"></a></template>',
],
invalid: [
// === Upstream parity (invalid in jsx-a11y + ours) ===
// Explicit role rejects unsupported attrs.
{
code: '<template><div role="link" href="#" aria-checked /></template>',
output: '<template><div role="link" href="#" /></template>',
errors: [{ messageId: 'unsupportedExplicit' }],
},
{
code: '<template><div role="option" aria-notreal="x" aria-selected="false" /></template>',
output: '<template><div role="option" aria-selected="false" /></template>',
errors: [{ messageId: 'unsupportedExplicit' }],
},
{
code: '<template><div role="combobox" aria-multiline="true" aria-expanded="false" aria-controls="x" /></template>',
output:
'<template><div role="combobox" aria-expanded="false" aria-controls="x" /></template>',
errors: [{ messageId: 'unsupportedExplicit' }],
},
{
code: '<template><a role="menuitem" aria-checked={{this.checked}} /></template>',
output: '<template><a role="menuitem" /></template>',
errors: [{ messageId: 'unsupportedExplicit' }],
},

// Implicit role rejects unsupported attrs (parity).
{
code: '<template><button type="submit" aria-valuetext="x"></button></template>',
output: '<template><button type="submit"></button></template>',
errors: [{ messageId: 'unsupportedImplicit' }],
},
{
code: '<template><input type="button" aria-invalid="grammar" /></template>',
output: '<template><input type="button" /></template>',
errors: [{ messageId: 'unsupportedImplicit' }],
},

// === DIVERGENCE — role-name in message differs for <menu type="toolbar"> ===
// jsx-a11y reports role "toolbar"; we report role "list".
// Both FLAG though, so the divergence is cosmetic (message text).
{
code: '<template><menu type="toolbar" aria-expanded="true" /></template>',
output: '<template><menu type="toolbar" /></template>',
errors: [
{
message:
'The attribute aria-expanded is not supported by the element menu with the implicit role of list',
},
],
},

// === DIVERGENCE — role-name differs for <body> ===
// jsx-a11y: <body> implicit role = "document".
// Our rule: aria-query first match gives role "generic".
// aria-expanded is unsupported by both, so both FLAG — diff is message.
{
code: '<template><body aria-expanded="true"></body></template>',
output: '<template><body></body></template>',
errors: [
{
message:
'The attribute aria-expanded is not supported by the element body with the implicit role of generic',
},
],
},

// === Parity — <input type="email"> without `list` → textbox ===
// jsx-a11y considers these to be "textbox" (since aria-query's first
// "email" entry has "list attribute not set" constraint → textbox).
// Our rule now honors aria-query attribute constraints: `type=email`
// without a `list` attribute maps to "textbox". With `list=...` it
// maps to "combobox" (sibling case below).
// aria-level is not supported by either role; still flagged.
{
code: '<template><input type="email" aria-level={{this.level}} /></template>',
output: '<template><input type="email" /></template>',
errors: [
{
message:
'The attribute aria-level is not supported by the element input with the implicit role of textbox',
},
],
},
// <input type="email" list="x"> → "combobox" (aria-level unsupported there too).
{
code: '<template><input type="email" list="x" aria-level={{this.level}} /></template>',
output: '<template><input type="email" list="x" /></template>',
errors: [
{
message:
'The attribute aria-level is not supported by the element input with the implicit role of combobox',
},
],
},

// === DIVERGENCE — <a> without href, with non-global aria attr ===
// jsx-a11y: VALID (no implicit role → global set).
// Our rule: role=generic, aria-checked not supported → FLAG. FALSE POSITIVE.
{
code: '<template><a aria-checked /></template>',
output: '<template><a /></template>',
errors: [{ messageId: 'unsupportedImplicit' }],
},
],
});

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

hbsRuleTester.run('audit:role-supports-aria-props (hbs)', rule, {
valid: [
'<div />',
'<div role="heading" aria-level="1" />',
'<div role="button" aria-disabled="true" />',
'<a href="#" aria-describedby=""></a>',
'<menu type="toolbar" aria-hidden="true" />',
'<input type="submit" aria-disabled="true" />',
],
invalid: [
{
code: '<div role="link" href="#" aria-checked />',
output: '<div role="link" href="#" />',
errors: [{ message: 'The attribute aria-checked is not supported by the role link' }],
},
{
code: '<menu type="toolbar" aria-expanded="true" />',
output: '<menu type="toolbar" />',
errors: [
{
message:
'The attribute aria-expanded is not supported by the element menu with the implicit role of list',
},
],
},
// DIVERGENCE: <a aria-checked /> — jsx-a11y says valid (no implicit role).
// We flag with implicit role "generic".
{
code: '<a aria-checked />',
output: '<a />',
errors: [{ messageId: 'unsupportedImplicit' }],
},
],
});
26 changes: 26 additions & 0 deletions tests/lib/rules/template-no-unsupported-role-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ const validHbs = [
'<ItemCheckbox @model={{@model}} @checkable={{@checkable}} />',
'<some-custom-element />',
'<input type="password">',

// <input type="password"> has no implicit role per aria-query (it's intentionally
// not mapped so that screen readers don't announce typed content). No role →
// no aria-supported-props check.
'<input type="password" aria-describedby="hint" />',
'<input type="password" aria-required="true" />',

// <input type="text"> without a list attribute is a textbox — aria-required,
// aria-readonly, aria-placeholder are all supported.
'<input type="text" aria-required="true" />',
'<input type="email" aria-readonly="true" />',
'<input type="tel" aria-required="true" />',
'<input type="url" aria-placeholder="https://…" />',
];

const invalidHbs = [
Expand Down Expand Up @@ -80,8 +93,21 @@ const invalidHbs = [
],
},
{
// <input type="email"> without a `list` attribute → implicit role "textbox"
// (per aria-query / HTML-AAM). With a `list` attribute it would be "combobox".
code: '<input type="email" aria-level={{this.level}} />',
output: '<input type="email" />',
errors: [
{
message:
'The attribute aria-level is not supported by the element input with the implicit role of textbox',
},
],
},
{
// With a `list` attribute, <input type="email"> becomes a combobox.
code: '<input type="email" list="x" aria-level={{this.level}} />',
output: '<input type="email" list="x" />',
errors: [
{
message:
Expand Down
Loading