feat: add a11y `role-supports-aria-props` (#8195)

#820

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/8335/head
Nguyen Tran 1 year ago committed by GitHub
parent 5f99ae76ce
commit b56dfe51a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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.

@ -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'

@ -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'],
@ -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'],
@ -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([
@ -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);
}
}
@ -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));
@ -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() {

@ -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" />

@ -0,0 +1,371 @@
<!-- INVALID -->
<a aria-setsize="0" href="/">Link</a>
<area aria-pressed alt="" />
<article aria-autocomplete="inline" />
<aside aria-modal />
<body aria-invalid />
<button aria-valuemax="0" />
<datalist aria-valuenow="0" />
<dd aria-rowindex="0" />
<dfn aria-colcount="0" />
<dialog aria-posinset="0" />
<details aria-orientation="undefined" />
<dt aria-valuemin="0" />
<fieldset aria-orientation="undefined" />
<form aria-disabled />
<h1 aria-selected>H1</h1>
<h2 aria-selected>H2</h2>
<h3 aria-expanded>H3</h3>
<h4 aria-valuemin="0">H4</h4>
<h5 aria-readonly>H5</h5>
<h6 aria-valuemin="0">H6</h6>
<hr aria-required />
<img aria-level="0" alt="invalid aria" />
<li aria-required />
<link aria-rowcount="0" />
<menu aria-valuemin="0" />
<meter aria-colspan="0" />
<nav aria-valuetext="" />
<ol aria-sort="none" />
<option aria-invalid />
<optgroup aria-sort="none" />
<output aria-multiline />
<progress aria-rowcount="0" />
<section aria-invalid />
<summary aria-rowcount="0" />
<tbody aria-colspan="0" />
<textarea aria-valuenow="0" />
<tfoot aria-required />
<thead aria-valuemin="0" />
<tr aria-pressed />
<ul aria-multiselectable />
<div role="alert" aria-colspan="0" />
<div role="alertdialog" aria-autocomplete="inline" />
<div role="application" aria-required />
<div role="article" aria-multiline />
<div role="banner" aria-autocomplete="inline" />
<div role="blockquote" aria-valuetext="" />
<div role="button" aria-colspan="0" />
<div role="caption" aria-setsize="0" />
<div role="cell" aria-multiline />
<div role="checkbox" aria-multiline aria-checked />
<div role="code" aria-invalid />
<div role="columnheader" aria-colcount="0" />
<div role="combobox" aria-multiselectable aria-controls={[]} aria-expanded />
<div role="complementary" aria-readonly />
<div role="contentinfo" aria-valuetext="" />
<div role="definition" aria-multiline />
<div role="deletion" aria-expanded />
<div role="dialog" aria-multiline />
<div role="directory" aria-rowcount="0" />
<div role="document" aria-valuemin="0" />
<div role="emphasis" aria-rowindex="0" />
<div role="feed" aria-colindex="0" />
<div role="figure" aria-valuemax="0" />
<div role="form" aria-readonly />
<div role="generic" aria-valuemax="0" />
<div role="grid" aria-checked />
<div role="gridcell" aria-level="0" />
<div role="group" aria-colspan="0" />
<div role="heading" aria-activedescendant="id" tabindex="-1" aria-level="0" />
<div role="insertion" aria-errormessage="error" />
<div role="link" aria-multiline />
<div role="list" aria-selected />
<div role="listbox" aria-haspopup />
<div role="listitem" aria-activedescendant="id" tabindex="-1" />
<div role="log" aria-required />
<div role="main" aria-sort="none" />
<div role="marquee" aria-autocomplete="inline" />
<div role="math" aria-multiline />
<div role="menu" aria-checked />
<div role="menubar" aria-errormessage="error" />
<div role="menuitem" aria-checked />
<div role="menuitemcheckbox" aria-pressed aria-checked="false" />
<div role="menuitemradio" aria-rowspan="0" aria-checked="false" />
<div role="meter" aria-valuenow="0" aria-haspopup />
<div role="navigation" aria-expanded />
<div role="none" aria-placeholder="" />
<div role="note" aria-modal />
<div role="option" aria-selected aria-valuemax="0" />
<div role="paragraph" aria-level="0" />
<div role="presentation" aria-disabled />
<div role="progressbar" aria-expanded />
<div role="radio" aria-checked aria-rowindex="0" />
<div role="radiogroup" aria-valuenow="0" />
<div role="region" aria-rowspan="0" />
<div role="row" aria-required />
<div role="rowgroup" aria-expanded />
<div role="rowheader" aria-activedescendant="id" tabindex="0" />
<div role="scrollbar" aria-controls={[]} aria-valuenow="0" aria-rowspan="0" />
<div role="search" aria-autocomplete="inline" />
<div role="searchbox" aria-colindex="0" />
<div role="separator" aria-sort="none" />
<div role="slider" aria-valuenow="0" aria-placeholder="" />
<div role="spinbutton" aria-posinset="0" />
<div role="status" aria-valuemin="0" />
<div role="strong" aria-valuemin="0" />
<div role="subscript" aria-colcount="0" />
<div role="superscript" aria-level="0" />
<div role="switch" aria-checked aria-valuenow="0" />
<div role="tab" aria-required />
<div role="table" aria-modal />
<div role="tablist" aria-setsize="0" />
<div role="tabpanel" aria-multiselectable />
<div role="term" aria-posinset="0" />
<div role="textbox" aria-colspan="0" />
<div role="time" aria-selected />
<div role="timer" aria-sort="none" />
<div role="toolbar" aria-valuetext="" />
<div role="tooltip" aria-multiline />
<div role="tree" aria-expanded />
<div role="treegrid" aria-level="0" />
<div role="treeitem" aria-selected aria-activedescendant="id" tabindex="-1" />
<div role="doc-abstract" aria-colindex="0" />
<div role="doc-acknowledgments" aria-setsize="0" />
<div role="doc-afterword" aria-modal />
<div role="doc-appendix" aria-activedescendant="id" tabindex="-1" />
<div role="doc-backlink" aria-colspan="0" />
<div role="doc-biblioentry" aria-valuemax="0" />
<div role="doc-bibliography" aria-level="0" />
<div role="doc-biblioref" aria-checked />
<div role="doc-chapter" aria-required />
<div role="doc-colophon" aria-setsize="0" />
<div role="doc-conclusion" aria-colindex="0" />
<div role="doc-cover" aria-modal />
<div role="doc-credit" aria-selected />
<div role="doc-credits" aria-orientation="undefined" />
<div role="doc-dedication" aria-level="0" />
<div role="doc-endnote" aria-checked />
<div role="doc-endnotes" aria-colcount="0" />
<div role="doc-epigraph" aria-multiline />
<div role="doc-epilogue" aria-colcount="0" />
<div role="doc-errata" aria-sort="none" />
<div role="doc-example" aria-multiselectable />
<div role="doc-footnote" aria-rowcount="0" />
<div role="doc-foreword" aria-valuenow="0" />
<div role="doc-glossary" aria-valuetext="" />
<div role="doc-glossref" aria-placeholder="" />
<div role="doc-index" aria-rowcount="0" />
<div role="doc-introduction" aria-pressed />
<div role="doc-noteref" aria-valuenow="0" />
<div role="doc-notice" aria-selected />
<div role="doc-pagebreak" aria-rowcount="0" />
<div role="doc-pagelist" aria-modal />
<div role="doc-part" aria-setsize="0" />
<div role="doc-preface" aria-orientation="undefined" />
<div role="doc-prologue" aria-required />
<div role="doc-pullquote" aria-rowcount="0" />
<div role="doc-qna" aria-setsize="0" />
<div role="doc-subtitle" aria-rowindex="0" />
<div role="doc-tip" aria-valuenow="0" />
<div role="doc-toc" aria-posinset="0" />
<!-- input and menuitem have different implicit roles based on different type attributes, and thus different valid and invalid props -->
<!-- INVALID -->
<input type="text" aria-rowspan="0" /> <!-- implicit role: textbox -->
<input type="tel" aria-pressed /> <!-- implicit role: textbox -->
<input type="url" aria-level="0" /> <!-- implicit role: textbox -->
<input type="email" aria-pressed /> <!-- implicit role: textbox -->
<input type="search" aria-valuetext="text" /> <!-- implicit role: searchbox -->
<input type="text" list={['id']} aria-valuemin="0" /> <!-- implicit role: combobox -->
<input type="tel" list={['id']} aria-colspan="0" /> <!-- implicit role: combobox -->
<input type="url" list={['id']} aria-posinset="0" /> <!-- implicit role: combobox -->
<input type="email" list={['id']} aria-modal /> <!-- implicit role: combobox -->
<input type="search" list={['id']} aria-rowindex="0" /> <!-- implicit role: combobox -->
<input type="image" alt="some text" aria-valuemax="0" /> <!-- implicit role: button -->
<input type="reset" aria-modal /> <!-- implicit role: button -->
<input type="submit" aria-placeholder="placeholder" /> <!-- implicit role: button -->
<input type="checkbox" aria-rowindex="0" /> <!-- implicit role: checkbox -->
<input type="radio" aria-valuetext="text" /> <!-- implicit role: radio -->
<input type="range" aria-checked /> <!-- implicit role: slider -->
<menuitem type="command" aria-colindex="0" /> <!-- implicit role: menuitem -->
<menuitem type="checkbox" aria-colcount="0" /> <!-- implicit role: menuitemcheckbox -->
<menuitem type="radio" aria-placeholder="placeholder" /> <!-- implicit role: menuitemradio -->
<!-- VALID -->
<a aria-keyshortcuts="" href="/">Link</a>
<area aria-expanded alt="" />
<article aria-dropeffect="none" />
<aside aria-keyshortcuts="" />
<body aria-labelledby="id" />
<button aria-hidden />
<datalist aria-activedescendant="id" tabindex="0" />
<dd aria-labelledby="id" />
<dfn aria-details="id" />
<dialog aria-keyshortcuts="" />
<details aria-keyshortcuts="" />
<dt aria-hidden />
<fieldset aria-owns="id" />
<form aria-keyshortcuts="" />
<h1 aria-keyshortcuts="">H1</h1>
<h2 aria-controls={[]}>H2</h2>
<h3 aria-controls={[]}>H3</h3>
<h4 aria-details="id">H4</h4>
<h5 aria-grabbed>H5</h5>
<h6 aria-grabbed>H6</h6>
<hr aria-relevant="all" />
<img aria-flowto="id" alt="Valid aria role" />
<li aria-label="" />
<link aria-hidden />
<menu aria-roledescription="" />
<meter aria-valuemin="0" />
<nav aria-labelledby="id" />
<ol aria-grabbed />
<option aria-selected />
<optgroup aria-hidden />
<output aria-dropeffect="none" />
<progress aria-hidden />
<section aria-details="id" />
<summary aria-controls={[]} />
<tbody aria-controls={[]} />
<textarea aria-busy />
<tfoot aria-labelledby="id" />
<thead aria-flowto="id" />
<tr aria-describedby="id" />
<ul aria-dropeffect="none" />
<div role="alert" aria-owns="id" />
<div role="alertdialog" aria-busy />
<div role="application" aria-invalid />
<div role="article" aria-atomic />
<div role="banner" aria-grabbed />
<div role="blockquote" aria-busy />
<div role="button" aria-busy />
<div role="caption" aria-grabbed />
<div role="cell" aria-rowindex="0" />
<div role="checkbox" aria-checked aria-details="id" />
<div role="code" aria-keyshortcuts="" />
<div role="columnheader" aria-rowspan="0" />
<div role="combobox" aria-invalid aria-controls={[]} aria-expanded />
<div role="complementary" aria-label="" />
<div role="contentinfo" aria-dropeffect="none" />
<div role="definition" aria-grabbed />
<div role="deletion" aria-busy />
<div role="dialog" aria-flowto="id" />
<div role="directory" aria-controls={[]} />
<div role="document" aria-grabbed />
<div role="emphasis" aria-atomic />
<div role="feed" aria-atomic />
<div role="figure" aria-busy />
<div role="form" aria-roledescription="" />
<div role="generic" aria-current />
<div role="grid" aria-busy />
<div role="gridcell" aria-relevant="all" />
<div role="group" aria-busy />
<div role="heading" aria-level="" aria-flowto="id" />
<div role="img" aria-grabbed />
<div role="insertion" aria-roledescription="" />
<div role="link" aria-owns="id" />
<div role="list" aria-labelledby="id" />
<div role="listbox" aria-current />
<div role="listitem" aria-controls={[]} />
<div role="log" aria-controls={[]} />
<div role="main" aria-keyshortcuts="" />
<div role="marquee" aria-labelledby="id" />
<div role="math" aria-labelledby="id" />
<div role="menu" aria-atomic />
<div role="menubar" aria-grabbed />
<div role="menuitem" aria-grabbed />
<div role="menuitemcheckbox" aria-checked aria-controls={[]} />
<div role="menuitemradio" aria-checked aria-grabbed />
<div role="meter" aria-valuenow="0" aria-valuetext="" />
<div role="navigation" aria-controls={[]} />
<div role="none" undefined />
<div role="note" aria-hidden />
<div role="option" aria-selected aria-describedby="id" />
<div role="paragraph" aria-grabbed />
<div role="presentation" aria-relevant="all" />
<div role="progressbar" aria-valuemin="0" />
<div role="radio" aria-checked aria-roledescription="" />
<div role="radiogroup" aria-required />
<div role="region" aria-roledescription="" />
<div role="row" aria-posinset="0" />
<div role="rowgroup" aria-busy />
<div role="rowheader" aria-label="" />
<div role="scrollbar" aria-controls={[]} aria-valuenow="0" aria-relevant="all" />
<div role="search" aria-grabbed />
<div role="searchbox" aria-dropeffect="none" />
<div role="separator" aria-roledescription="" />
<div role="slider" aria-valuenow="0" aria-relevant="all" />
<div role="spinbutton" aria-required />
<div role="status" aria-label="" />
<div role="strong" aria-keyshortcuts="" />
<div role="subscript" aria-keyshortcuts="" />
<div role="superscript" aria-live="off" />
<div role="switch" aria-checked aria-roledescription="" />
<div role="tab" aria-flowto="id" />
<div role="table" aria-rowcount="0" />
<div role="tablist" aria-atomic />
<div role="tabpanel" aria-labelledby="id" />
<div role="term" aria-details="id" />
<div role="textbox" aria-hidden />
<div role="time" aria-label="" />
<div role="timer" aria-hidden />
<div role="toolbar" aria-roledescription="" />
<div role="tooltip" aria-owns="id" />
<div role="tree" aria-errormessage="error" />
<div role="treegrid" aria-details="id" />
<div role="treeitem" aria-selected="true" aria-dropeffect="none" />
<div role="doc-abstract" aria-label="" />
<div role="doc-acknowledgments" aria-controls={[]} />
<div role="doc-afterword" aria-flowto="id" />
<div role="doc-appendix" aria-describedby="id" />
<div role="doc-backlink" aria-dropeffect="none" />
<div role="doc-biblioentry" aria-roledescription="" />
<div role="doc-bibliography" aria-labelledby="id" />
<div role="doc-biblioref" aria-haspopup />
<div role="doc-chapter" aria-controls={[]} />
<div role="doc-colophon" aria-expanded />
<div role="doc-conclusion" aria-dropeffect="none" />
<div role="doc-cover" aria-controls={[]} />
<div role="doc-credit" aria-haspopup />
<div role="doc-credits" aria-describedby="id" />
<div role="doc-dedication" aria-roledescription="" />
<div role="doc-endnote" aria-errormessage="error" />
<div role="doc-endnotes" aria-owns="id" />
<div role="doc-epigraph" aria-controls={[]} />
<div role="doc-epilogue" aria-relevant="all" />
<div role="doc-errata" aria-keyshortcuts="" />
<div role="doc-example" aria-invalid />
<div role="doc-footnote" aria-labelledby="id" />
<div role="doc-foreword" aria-expanded />
<div role="doc-glossary" aria-grabbed />
<div role="doc-glossref" aria-haspopup />
<div role="doc-index" aria-controls={[]} />
<div role="doc-introduction" aria-labelledby="id" />
<div role="doc-noteref" aria-details="id" />
<div role="doc-notice" aria-owns="id" />
<div role="doc-pagebreak" aria-owns="id" />
<div role="doc-pagelist" aria-disabled />
<div role="doc-part" aria-relevant="all" />
<div role="doc-preface" aria-label="" />
<div role="doc-prologue" aria-invalid />
<div role="doc-pullquote" undefined />
<div role="doc-qna" aria-errormessage="error" />
<div role="doc-subtitle" aria-errormessage="error" />
<div role="doc-tip" aria-owns="id" />
<div role="doc-toc" aria-expanded />
<!-- input and menuitem have different implicit roles based on different type attributes, and thus different valid and invalid props -->
<!-- VALID -->
<input type="text" aria-labelledby="id" /> <!-- implicit role: textbox -->
<input type="tel" aria-readonly /> <!-- implicit role: textbox -->
<input type="url" aria-errormessage="id" /> <!-- implicit role: textbox -->
<input type="email" aria-details="id" /> <!-- implicit role: textbox -->
<input type="searchbox" aria-owns="id" /> <!-- implicit role: searchbox -->
<input type="text" list={['id']} aria-keyshortcuts="key" /> <!-- implicit role: combobox -->
<input type="tel" list={['id']} aria-readonly /> <!-- implicit role: combobox -->
<input type="url" list={['id']} aria-label="label" /> <!-- implicit role: combobox -->
<input type="email" list={['id']} aria-activedescendant="id" /> <!-- implicit role: combobox -->
<input type="search" list={['id']} aria-dropeffect="none" /> <!-- implicit role: combobox -->
<input type="image" alt="some text" aria-pressed /> <!-- implicit role: button -->
<input type="reset" aria-expanded /> <!-- implicit role: button -->
<input type="submit" aria-disabled /> <!-- implicit role: button -->
<input type="checkbox" aria-controls="id" /> <!-- implicit role: checkbox -->
<input type="radio" aria-atomic /> <!-- implicit role: radio -->
<input type="range" aria-hidden /> <!-- implicit role: slider -->
<menuitem type="command" aria-live="off" /> <!-- implicit role: menuitem -->
<menuitem type="checkbox" aria-relevant="all" /> <!-- implicit role: menuitemcheckbox -->
<menuitem type="radio" aria-required /> <!-- implicit role: menuitemradio -->
Loading…
Cancel
Save