Skip to content

Commit

Permalink
feat: add a11y role-supports-aria-props (#8195)
Browse files Browse the repository at this point in the history
#820

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
  • Loading branch information
ngtr6788 and dummdidumm authored Feb 27, 2023
1 parent 5f99ae7 commit b56dfe5
Show file tree
Hide file tree
Showing 6 changed files with 2,628 additions and 3 deletions.
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

0 comments on commit b56dfe5

Please sign in to comment.