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
117 changes: 89 additions & 28 deletions lib/rules/template-no-unsupported-role-attributes.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,100 @@
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;
}
if (!attr.value || attr.value.type !== 'GlimmerTextNode') {
// Presence with dynamic value — treat as "set" but unknown string.
return '';
}
return attr.value.chars.trim();
}

function nodeSatisfiesAttributeConstraint(node, attrSpec) {
const value = getStaticAttrValue(node, attrSpec.name);
const isSet = value !== undefined;

return `The attribute ${attribute} is not supported by the role ${role}`;
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;
}

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 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));
}

const key = [...elementRoles.keys()].find((entry) => entry.name === tagName);
const implicitRoles = key && elementRoles.get(key);
// Pre-index elementRoles by tag name at module load. aria-query's Map is
// static data; bucketing by tag turns the per-call scan (~80 keys) into a
// 1–5 key lookup per tag. Benchmarked at ~2.6× speedup on realistic
// 200k-call workloads; parity verified across representative tag/attr
// combinations before landing.
const ELEMENT_ROLES_KEYS_BY_TAG = buildElementRolesIndex();

function buildElementRolesIndex() {
const index = new Map();
for (const key of elementRoles.keys()) {
if (!index.has(key.name)) {
index.set(key.name, []);
}
index.get(key.name).push(key);
}
return index;
}

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.
const keys = ELEMENT_ROLES_KEYS_BY_TAG.get(node.tag);
if (!keys) {
return undefined;
}
let bestKey;
let bestSpecificity = -1;
for (const key of keys) {
if (!keyMatchesNode(node, key)) {
continue;
}
const specificity = key.attributes?.length ?? 0;
if (specificity > bestSpecificity) {
bestKey = key;
bestSpecificity = specificity;
}
}
Comment thread
johanrd marked this conversation as resolved.
if (!bestKey) {
return undefined;
}
return elementRoles.get(bestKey)[0];
}

function getExplicitRole(node) {
Expand All @@ -35,14 +105,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 +173,7 @@ module.exports = {

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

if (!role) {
Expand Down
58 changes: 58 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,23 @@ 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. Pin this with attributes that would be REJECTED
// on a textbox (aria-checked, aria-selected): if the rule mis-classified password
// as a textbox fallback, these would flag.
'<input type="password" aria-describedby="hint" />',
'<input type="password" aria-required="true" />',
Comment thread
johanrd marked this conversation as resolved.
'<input type="password" aria-checked="false" />',
'<input type="password" aria-selected="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 +97,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 All @@ -94,6 +124,34 @@ const invalidHbs = [
output: '{{foo-component role="button"}}',
errors: [{ message: 'The attribute aria-valuetext is not supported by the role button' }],
},
// Documented divergence with jsx-a11y on implicit role for <body>.
// jsx-a11y resolves <body> to role "document"; aria-query (which our rule
// uses) resolves to "generic". aria-expanded is unsupported by either, so
// both plugins flag — only the role-name in the message differs.
{
code: '<body aria-expanded="true"></body>',
output: '<body></body>',
errors: [
{
message:
'The attribute aria-expanded is not supported by the element body with the implicit role of generic',
},
],
},
// <a> without href — implicit role is `generic` per HTML-AAM 1.2 §3.5.3
// (https://www.w3.org/TR/html-aam/#el-a-no-href). aria-checked is not
// supported on `generic`, so we flag. vue-a11y reaches the same conclusion
// (it walks aria-query the same way). jsx-a11y's `getImplicitRoleForAnchor`
// returns `''` for href-less <a>, which makes its role-supports-aria-props
// rule early-return and silently allow any aria-* attribute — its own
// source comments this as "This actually isn't true - should fix in future
// release." We're spec-current; jsx-a11y is spec-stale (legacy ARIA 1.1
// mental model).
{
code: '<a aria-checked />',
output: '<a />',
errors: [{ messageId: 'unsupportedImplicit' }],
},
];

function wrapTemplate(entry) {
Expand Down
Loading