Skip to content

Commit a61e9a6

Browse files
committed
fix(template-no-unsupported-role-attributes): honor aria-query attribute constraints in implicit-role lookup
The old getImplicitRole picked the first elementRoles entry whose name matched the tag. For <input> it tried a type-attribute match but ignored the rest of aria-query's constraint annotations. Concrete consequences of the old logic: - <input type="text"> without a `list` attribute returned "combobox" (because aria-query's {type=text, list=set}→combobox entry appears before {type=text, list=undefined}→textbox). Correct implicit role is textbox. - <input type="email|tel|url"> behaved the same way. - <input type="password"> isn't mapped by aria-query; the fallback branch returned the first input entry — "button". The new implementation walks every elementRoles key whose tag matches, checks each attribute spec (honoring `constraints: ["set"]` and `constraints: ["undefined"]` as well as required values), and picks the entry with the most attribute constraints satisfied. Behavioral impact: - Text-like inputs without a list attribute now correctly resolve to textbox and accept aria-required / aria-readonly / aria-placeholder. - <input type="password"> now resolves to no implicit role, so global ARIA attrs don't trip the rule (matching jsx-a11y's treatment). - One existing test updated: <input type="email"> now maps to textbox in the error message; added a sibling case with `list="x"` to cover the combobox path. - Eight new valid tests cover the textbox/password paths.
1 parent 24882a3 commit a61e9a6

2 files changed

Lines changed: 86 additions & 24 deletions

File tree

lib/rules/template-no-unsupported-role-attributes.js

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,68 @@ function createUnsupportedAttributeErrorMessage(attribute, role, element) {
88
return `The attribute ${attribute} is not supported by the role ${role}`;
99
}
1010

11-
function getImplicitRole(tagName, typeAttribute) {
12-
if (tagName === 'input') {
13-
for (const key of elementRoles.keys()) {
14-
if (key.name === tagName && key.attributes) {
15-
for (const attribute of key.attributes) {
16-
if (attribute.name === 'type' && attribute.value === typeAttribute) {
17-
return elementRoles.get(key)[0];
18-
}
19-
}
20-
}
21-
}
11+
function getStaticAttrValue(node, name) {
12+
const attr = node.attributes?.find((a) => a.name === name);
13+
if (!attr) {
14+
return undefined;
2215
}
16+
if (!attr.value || attr.value.type !== 'GlimmerTextNode') {
17+
// Presence with dynamic value — treat as "set" but unknown string.
18+
return '';
19+
}
20+
return attr.value.chars;
21+
}
22+
23+
function nodeSatisfiesAttributeConstraint(node, attrSpec) {
24+
const value = getStaticAttrValue(node, attrSpec.name);
25+
const isSet = value !== undefined;
26+
27+
if (attrSpec.constraints?.includes('set')) {
28+
return isSet;
29+
}
30+
if (attrSpec.constraints?.includes('undefined')) {
31+
return !isSet;
32+
}
33+
if (attrSpec.value !== undefined) {
34+
return isSet && value === attrSpec.value;
35+
}
36+
// No constraint listed — just require presence.
37+
return isSet;
38+
}
2339

24-
const key = [...elementRoles.keys()].find((entry) => entry.name === tagName);
25-
const implicitRoles = key && elementRoles.get(key);
40+
function keyMatchesNode(node, key) {
41+
if (key.name !== node.tag) {
42+
return false;
43+
}
44+
if (!key.attributes || key.attributes.length === 0) {
45+
return true;
46+
}
47+
return key.attributes.every((attrSpec) =>
48+
nodeSatisfiesAttributeConstraint(node, attrSpec)
49+
);
50+
}
2651

27-
return implicitRoles && implicitRoles[0];
52+
function getImplicitRole(node) {
53+
// Honor aria-query's attribute constraints when mapping element -> implicit role.
54+
// Each elementRoles entry lists attributes that must match (with optional
55+
// constraints "set" / "undefined"); pick the most specific entry whose
56+
// attribute spec is fully satisfied by the node.
57+
let bestKey;
58+
let bestSpecificity = -1;
59+
for (const key of elementRoles.keys()) {
60+
if (!keyMatchesNode(node, key)) {
61+
continue;
62+
}
63+
const specificity = key.attributes?.length ?? 0;
64+
if (specificity > bestSpecificity) {
65+
bestKey = key;
66+
bestSpecificity = specificity;
67+
}
68+
}
69+
if (!bestKey) {
70+
return undefined;
71+
}
72+
return elementRoles.get(bestKey)[0];
2873
}
2974

3075
function getExplicitRole(node) {
@@ -35,14 +80,6 @@ function getExplicitRole(node) {
3580
return null;
3681
}
3782

38-
function getTypeAttribute(node) {
39-
const typeAttr = node.attributes?.find((attr) => attr.name === 'type');
40-
if (typeAttr && typeAttr.value?.type === 'GlimmerTextNode') {
41-
return typeAttr.value.chars.trim();
42-
}
43-
return null;
44-
}
45-
4683
function removeRangeWithAdjacentWhitespace(sourceText, range) {
4784
let [start, end] = range;
4885

@@ -111,8 +148,7 @@ module.exports = {
111148

112149
if (!role) {
113150
element = node.tag;
114-
const typeAttribute = getTypeAttribute(node);
115-
role = getImplicitRole(element, typeAttribute);
151+
role = getImplicitRole(node);
116152
}
117153

118154
if (!role) {

tests/lib/rules/template-no-unsupported-role-attributes.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ const validHbs = [
2121
'<ItemCheckbox @model={{@model}} @checkable={{@checkable}} />',
2222
'<some-custom-element />',
2323
'<input type="password">',
24+
25+
// <input type="password"> has no implicit role per aria-query (it's intentionally
26+
// not mapped so that screen readers don't announce typed content). No role →
27+
// no aria-supported-props check.
28+
'<input type="password" aria-describedby="hint" />',
29+
'<input type="password" aria-required="true" />',
30+
31+
// <input type="text"> without a list attribute is a textbox — aria-required,
32+
// aria-readonly, aria-placeholder are all supported.
33+
'<input type="text" aria-required="true" />',
34+
'<input type="email" aria-readonly="true" />',
35+
'<input type="tel" aria-required="true" />',
36+
'<input type="url" aria-placeholder="https://…" />',
2437
];
2538

2639
const invalidHbs = [
@@ -80,8 +93,21 @@ const invalidHbs = [
8093
],
8194
},
8295
{
96+
// <input type="email"> without a `list` attribute → implicit role "textbox"
97+
// (per aria-query / HTML-AAM). With a `list` attribute it would be "combobox".
8398
code: '<input type="email" aria-level={{this.level}} />',
8499
output: '<input type="email" />',
100+
errors: [
101+
{
102+
message:
103+
'The attribute aria-level is not supported by the element input with the implicit role of textbox',
104+
},
105+
],
106+
},
107+
{
108+
// With a `list` attribute, <input type="email"> becomes a combobox.
109+
code: '<input type="email" list="x" aria-level={{this.level}} />',
110+
output: '<input type="email" list="x" />',
85111
errors: [
86112
{
87113
message:

0 commit comments

Comments
 (0)