Skip to content

Commit b56dfe5

Browse files
ngtr6788dummdidumm
andauthored
feat: add a11y role-supports-aria-props (#8195)
#820 --------- Co-authored-by: Simon Holthausen <[email protected]>
1 parent 5f99ae7 commit b56dfe5

File tree

6 files changed

+2628
-3
lines changed

6 files changed

+2628
-3
lines changed

site/content/docs/06-accessibility-warnings.md

+14
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,20 @@ Elements with ARIA roles must have all required attributes for that role.
308308

309309
---
310310

311+
### `a11y-role-supports-aria-props`
312+
313+
Elements with explicit or implicit roles defined contain only `aria-*` properties supported by that role.
314+
315+
```sv
316+
<!-- A11y: The attribute 'aria-multiline' is not supported by the role 'link'. -->
317+
<div role="link" aria-multiline />
318+
319+
<!-- A11y: The attribute 'aria-required' is not supported by the role 'listitem'. This role is implicit on the element <li>. -->
320+
<li aria-required />
321+
```
322+
323+
---
324+
311325
### `a11y-structure`
312326

313327
Enforce that certain DOM elements have the correct structure.

src/compiler/compile/compiler_warnings.ts

+11
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,17 @@ export default {
123123
code: 'a11y-role-has-required-aria-props',
124124
message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}`
125125
}),
126+
a11y_role_supports_aria_props: (attribute: string, role: string, is_implicit: boolean, name: string) => {
127+
let message = `The attribute '${attribute}' is not supported by the role '${role}'.`;
128+
if (is_implicit) {
129+
message += ` This role is implicit on the element <${name}>.`;
130+
}
131+
132+
return {
133+
code: 'a11y-role-supports-aria-props',
134+
message: `A11y: ${message}`
135+
};
136+
},
126137
a11y_accesskey: {
127138
code: 'a11y-accesskey',
128139
message: 'A11y: Avoid using accesskey'

src/compiler/compile/nodes/Element.ts

+82-2
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,15 @@ const a11y_nested_implicit_semantics = new Map([
8282

8383
const a11y_implicit_semantics = new Map([
8484
['a', 'link'],
85+
['area', 'link'],
86+
['article', 'article'],
8587
['aside', 'complementary'],
8688
['body', 'document'],
89+
['button', 'button'],
8790
['datalist', 'listbox'],
8891
['dd', 'definition'],
8992
['dfn', 'term'],
93+
['dialog', 'dialog'],
9094
['details', 'group'],
9195
['dt', 'term'],
9296
['fieldset', 'group'],
@@ -98,10 +102,14 @@ const a11y_implicit_semantics = new Map([
98102
['h5', 'heading'],
99103
['h6', 'heading'],
100104
['hr', 'separator'],
105+
['img', 'img'],
101106
['li', 'listitem'],
107+
['link', 'link'],
102108
['menu', 'list'],
109+
['meter', 'progressbar'],
103110
['nav', 'navigation'],
104111
['ol', 'list'],
112+
['option', 'option'],
105113
['optgroup', 'group'],
106114
['output', 'status'],
107115
['progress', 'progressbar'],
@@ -115,6 +123,61 @@ const a11y_implicit_semantics = new Map([
115123
['ul', 'list']
116124
]);
117125

126+
const menuitem_type_to_implicit_role = new Map([
127+
['command', 'menuitem'],
128+
['checkbox', 'menuitemcheckbox'],
129+
['radio', 'menuitemradio']
130+
]);
131+
132+
const input_type_to_implicit_role = new Map([
133+
['button', 'button'],
134+
['image', 'button'],
135+
['reset', 'button'],
136+
['submit', 'button'],
137+
['checkbox', 'checkbox'],
138+
['radio', 'radio'],
139+
['range', 'slider'],
140+
['number', 'spinbutton'],
141+
['email', 'textbox'],
142+
['search', 'searchbox'],
143+
['tel', 'textbox'],
144+
['text', 'textbox'],
145+
['url', 'textbox']
146+
]);
147+
148+
const combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']);
149+
150+
function input_implicit_role(attribute_map: Map<string, Attribute>) {
151+
const type_attribute = attribute_map.get('type');
152+
if (!type_attribute || !type_attribute.is_static) return;
153+
const type = type_attribute.get_static_value() as string;
154+
155+
const list_attribute_exists = attribute_map.has('list');
156+
157+
if (list_attribute_exists && combobox_if_list.has(type)) {
158+
return 'combobox';
159+
}
160+
161+
return input_type_to_implicit_role.get(type);
162+
}
163+
164+
function menuitem_implicit_role(attribute_map: Map<string, Attribute>) {
165+
const type_attribute = attribute_map.get('type');
166+
if (!type_attribute || !type_attribute.is_static) return;
167+
const type = type_attribute.get_static_value() as string;
168+
return menuitem_type_to_implicit_role.get(type);
169+
}
170+
171+
function get_implicit_role(name: string, attribute_map: Map<string, Attribute>) : (string | undefined) {
172+
if (name === 'menuitem') {
173+
return menuitem_implicit_role(attribute_map);
174+
} else if (name === 'input') {
175+
return input_implicit_role(attribute_map);
176+
} else {
177+
return a11y_implicit_semantics.get(name);
178+
}
179+
}
180+
118181
const invisible_elements = new Set(['meta', 'html', 'script', 'style']);
119182

120183
const valid_modifiers = new Set([
@@ -488,7 +551,7 @@ export default class Element extends Node {
488551

489552
// aria-activedescendant-has-tabindex
490553
if (name === 'aria-activedescendant' && !is_interactive_element(this.name, attribute_map) && !attribute_map.has('tabindex')) {
491-
component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex);
554+
component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex);
492555
}
493556
}
494557

@@ -511,7 +574,7 @@ export default class Element extends Node {
511574
}
512575

513576
// no-redundant-roles
514-
const has_redundant_role = current_role === a11y_implicit_semantics.get(this.name);
577+
const has_redundant_role = current_role === get_implicit_role(this.name, attribute_map);
515578

516579
if (this.name === current_role || has_redundant_role) {
517580
component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role));
@@ -605,6 +668,23 @@ export default class Element extends Node {
605668
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
606669
}
607670
}
671+
672+
// role-supports-aria-props
673+
const role = attribute_map.get('role');
674+
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefintionKey;
675+
if (typeof role_value === 'string' && roles.has(role_value)) {
676+
const { props } = roles.get(role_value);
677+
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
678+
const is_implicit = role_value && role === undefined;
679+
680+
attributes
681+
.filter(prop => prop.type !== 'Spread')
682+
.forEach(prop => {
683+
if (invalid_aria_props.has(prop.name as ARIAProperty)) {
684+
component.warn(prop, compiler_warnings.a11y_role_supports_aria_props(prop.name, role_value, is_implicit, this.name));
685+
}
686+
});
687+
}
608688
}
609689

610690
validate_special_cases() {

test/validator/samples/a11y-role-has-required-aria-props/input.svelte

-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,3 @@
88
<div role="meter" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>
99
<div role="scrollbar" aria-controls="panel" aria-valuenow="50"></div>
1010
<input role="switch" type="checkbox" />
11-
<input role="radio" type="radio" />

0 commit comments

Comments
 (0)