Skip to content

feat: add a11y role-supports-aria-props #8195

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 27, 2023
Merged
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
14 changes: 14 additions & 0 deletions site/content/docs/06-accessibility-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,20 @@ Elements with ARIA roles must have all required attributes for that role.

---

### `a11y-role-supports-aria-props`

Elements with explicit or implicit roles defined contain only `aria-*` properties supported by that role.

```sv
<!-- A11y: The attribute 'aria-multiline' is not supported by the role 'link'. -->
<div role="link" aria-multiline />

<!-- A11y: The attribute 'aria-required' is not supported by the role 'listitem'. This role is implicit on the element <li>. -->
<li aria-required />
```

---

### `a11y-structure`

Enforce that certain DOM elements have the correct structure.
Expand Down
11 changes: 11 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ export default {
code: 'a11y-role-has-required-aria-props',
message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}`
}),
a11y_role_supports_aria_props: (attribute: string, role: string, is_implicit: boolean, name: string) => {
let message = `The attribute '${attribute}' is not supported by the role '${role}'.`;
if (is_implicit) {
message += ` This role is implicit on the element <${name}>.`;
}

return {
code: 'a11y-role-supports-aria-props',
message: `A11y: ${message}`
};
},
a11y_accesskey: {
code: 'a11y-accesskey',
message: 'A11y: Avoid using accesskey'
Expand Down
84 changes: 82 additions & 2 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,15 @@ const a11y_nested_implicit_semantics = new Map([

const a11y_implicit_semantics = new Map([
['a', 'link'],
['area', 'link'],
['article', 'article'],
['aside', 'complementary'],
['body', 'document'],
['button', 'button'],
['datalist', 'listbox'],
['dd', 'definition'],
['dfn', 'term'],
['dialog', 'dialog'],
['details', 'group'],
['dt', 'term'],
['fieldset', 'group'],
Expand All @@ -98,10 +102,14 @@ const a11y_implicit_semantics = new Map([
['h5', 'heading'],
['h6', 'heading'],
['hr', 'separator'],
['img', 'img'],
['li', 'listitem'],
['link', 'link'],
['menu', 'list'],
['meter', 'progressbar'],
['nav', 'navigation'],
['ol', 'list'],
['option', 'option'],
['optgroup', 'group'],
['output', 'status'],
['progress', 'progressbar'],
Expand All @@ -115,6 +123,61 @@ const a11y_implicit_semantics = new Map([
['ul', 'list']
]);

const menuitem_type_to_implicit_role = new Map([
['command', 'menuitem'],
['checkbox', 'menuitemcheckbox'],
['radio', 'menuitemradio']
]);

const input_type_to_implicit_role = new Map([
['button', 'button'],
['image', 'button'],
['reset', 'button'],
['submit', 'button'],
['checkbox', 'checkbox'],
['radio', 'radio'],
['range', 'slider'],
['number', 'spinbutton'],
['email', 'textbox'],
['search', 'searchbox'],
['tel', 'textbox'],
['text', 'textbox'],
['url', 'textbox']
]);

const combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']);

function input_implicit_role(attribute_map: Map<string, Attribute>) {
const type_attribute = attribute_map.get('type');
if (!type_attribute || !type_attribute.is_static) return;
const type = type_attribute.get_static_value() as string;

const list_attribute_exists = attribute_map.has('list');

if (list_attribute_exists && combobox_if_list.has(type)) {
return 'combobox';
}

return input_type_to_implicit_role.get(type);
}

function menuitem_implicit_role(attribute_map: Map<string, Attribute>) {
const type_attribute = attribute_map.get('type');
if (!type_attribute || !type_attribute.is_static) return;
const type = type_attribute.get_static_value() as string;
return menuitem_type_to_implicit_role.get(type);
}

function get_implicit_role(name: string, attribute_map: Map<string, Attribute>) : (string | undefined) {
if (name === 'menuitem') {
return menuitem_implicit_role(attribute_map);
} else if (name === 'input') {
return input_implicit_role(attribute_map);
} else {
return a11y_implicit_semantics.get(name);
}
}

const invisible_elements = new Set(['meta', 'html', 'script', 'style']);

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

// aria-activedescendant-has-tabindex
if (name === 'aria-activedescendant' && !is_interactive_element(this.name, attribute_map) && !attribute_map.has('tabindex')) {
component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex);
component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex);
}
}

Expand All @@ -511,7 +574,7 @@ export default class Element extends Node {
}

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

if (this.name === current_role || has_redundant_role) {
component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role));
Expand Down Expand Up @@ -605,6 +668,23 @@ export default class Element extends Node {
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
}
}

// role-supports-aria-props
const role = attribute_map.get('role');
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefintionKey;
if (typeof role_value === 'string' && roles.has(role_value)) {
const { props } = roles.get(role_value);
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
const is_implicit = role_value && role === undefined;

attributes
.filter(prop => prop.type !== 'Spread')
.forEach(prop => {
if (invalid_aria_props.has(prop.name as ARIAProperty)) {
component.warn(prop, compiler_warnings.a11y_role_supports_aria_props(prop.name, role_value, is_implicit, this.name));
}
});
}
}

validate_special_cases() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,3 @@
<div role="meter" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>
<div role="scrollbar" aria-controls="panel" aria-valuenow="50"></div>
<input role="switch" type="checkbox" />
<input role="radio" type="radio" />
Loading