feat: add a11y `no-noninteractive-element-interactions` (#8391)

#820
pull/8515/head
Nguyen Tran 2 years ago committed by Simon Holthausen
parent 1728a8940e
commit 68bf3e8143

@ -6,6 +6,9 @@
* **breaking** Minimum supported TypeScript version is now 5 (it will likely work with lower versions, but we make no guarantess about that)
* **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
* **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391))
* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251))
* Bind `null` option and input values consistently ([#8312](https://github.com/sveltejs/svelte/issues/8312))
## Unreleased (3.0)

@ -288,6 +288,20 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t
---
### `a11y-no-noninteractive-element-interactions`
A non-interactive element does not support event handlers (mouse and key handlers). Non-interactive elements include `<main>`, `<area>`, `<h1>` (,`<h2>`, etc), `<p>`, `<img>`, `<li>`, `<ul>` and `<ol>`. Non-interactive [WAI-ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) include `article`, `banner`, `complementary`, `img`, `listitem`, `main`, `region` and `tooltip`.
```sv
<!-- `A11y: Non-interactive element <li> should not be assigned mouse or keyboard event listeners.` -->
<li on:click={() => {}} />
<!-- `A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.` -->
<div role="listitem" on:click={() => {}} />
```
---
### `a11y-no-noninteractive-element-to-interactive-role`
[WAI-ARIA](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) roles should not be used to convert a non-interactive element to an interactive element. Interactive ARIA roles include `button`, `link`, `checkbox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `searchbox`, `switch` and `textbox`.

@ -123,6 +123,10 @@ export default {
code: 'a11y-no-interactive-element-to-noninteractive-role',
message: `A11y: <${element}> cannot have role '${role}'`
}),
a11y_no_noninteractive_element_interactions: (element: string) => ({
code: 'a11y-no-noninteractive-element-interactions',
message: `A11y: Non-interactive element <${element}> should not be assigned mouse or keyboard event listeners.`
}),
a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({
code: 'a11y-no-noninteractive-element-to-interactive-role',
message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'`

@ -11,7 +11,7 @@ import StyleDirective from './StyleDirective';
import Text from './Text';
import { namespaces } from '../../utils/namespaces';
import map_children from './shared/map_children';
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
import { is_name_contenteditable, get_contenteditable_attr, has_contenteditable_attr } from '../utils/contenteditable';
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns';
import fuzzymatch from '../../utils/fuzzymatch';
import list from '../../utils/list';
@ -102,6 +102,15 @@ const a11y_interactive_handlers = new Set([
'mouseup'
]);
const a11y_recommended_interactive_handlers = new Set([
'click',
'mousedown',
'mouseup',
'keypress',
'keydown',
'keyup'
]);
const a11y_nested_implicit_semantics = new Map([
['header', 'banner'],
['footer', 'contentinfo']
@ -738,10 +747,12 @@ export default class Element extends Node {
}
}
const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey;
const role = attribute_map.get('role');
const role_static_value = role?.get_static_value() as ARIARoleDefinitionKey;
const role_value = (role ? role_static_value : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
// no-noninteractive-tabindex
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) {
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role_static_value)) {
const tab_index = attribute_map.get('tabindex');
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
@ -749,7 +760,6 @@ export default class Element extends Node {
}
// role-supports-aria-props
const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
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)));
@ -764,18 +774,33 @@ export default class Element extends Node {
});
}
// no-noninteractive-element-interactions
if (
!has_contenteditable_attr(this) &&
!is_hidden_from_screen_reader(this.name, attribute_map) &&
!is_presentation_role(role_static_value) &&
((!is_interactive_element(this.name, attribute_map) &&
is_non_interactive_roles(role_static_value)) ||
(is_non_interactive_element(this.name, attribute_map) && !role))
) {
const has_interactive_handlers = handlers.some((handler) => a11y_recommended_interactive_handlers.has(handler.name));
if (has_interactive_handlers) {
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_interactions(this.name));
}
}
const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static;
// no-static-element-interactions
if (
!has_dynamic_role &&
!is_hidden_from_screen_reader(this.name, attribute_map) &&
!is_presentation_role(role) &&
!is_presentation_role(role_static_value) &&
!is_interactive_element(this.name, attribute_map) &&
!is_interactive_roles(role) &&
!is_interactive_roles(role_static_value) &&
!is_non_interactive_element(this.name, attribute_map) &&
!is_non_interactive_roles(role) &&
!is_abstract_role(role)
!is_non_interactive_roles(role_static_value) &&
!is_abstract_role(role_static_value)
) {
const interactive_handlers = handlers
.map((handler) => handler.name)

@ -16,10 +16,13 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<section on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<main on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<article on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<header on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:click={noop} />
<!-- should not warn -->

@ -39,11 +39,11 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 19,
"line": 20,
"column": 0
},
"end": {
"line": 19,
"line": 20,
"column": 24
}
},
@ -51,11 +51,11 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 20,
"line": 22,
"column": 0
},
"end": {
"line": 20,
"line": 22,
"column": 27
}
},
@ -63,11 +63,11 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 22,
"line": 24,
"column": 0
},
"end": {
"line": 22,
"line": 24,
"column": 26
}
},
@ -75,11 +75,11 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 23,
"line": 26,
"column": 0
},
"end": {
"line": 23,
"line": 26,
"column": 26
}
}

@ -0,0 +1,14 @@
<!-- VALID -->
<div role="presentation" on:mouseup={() => {}} />
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}} />
<div role="listitem" aria-hidden on:click={() => {}} on:keypress={() => {}} />
<button on:click={() => {}} />
<h1 contenteditable="true" on:keydown={() => {}}>Heading</h1>
<h1>Heading</h1>
<!-- INVALID -->
<div role="listitem" on:mousedown={() => {}} />
<h1 on:click={() => {}} on:keydown={() => {}}>Heading</h1>
<h1 role="banner" on:keyup={() => {}}>Heading</h1>
<p on:keypress={() => {}} />
<div role="paragraph" on:mouseup={() => {}} />

@ -0,0 +1,62 @@
[
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 47,
"line": 10
},
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 10
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 58,
"line": 11
},
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 11
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 50,
"line": 12
},
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 12
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 28,
"line": 13
},
"message": "A11y: Non-interactive element <p> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 13
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 46,
"line": 14
},
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 14
}
}
]

@ -10,6 +10,7 @@
<div on:copy={() => {}} />
<a href="/foo" on:click={() => {}}>link</a>
<div role={dynamicRole} on:click={() => {}} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:keydown={() => {}} />
<!-- invalid -->

@ -3,24 +3,24 @@
"code": "a11y-no-static-element-interactions",
"end": {
"column": 29,
"line": 16
"line": 17
},
"message": "A11y: <div> with keydown handler must have an ARIA role",
"start": {
"column": 0,
"line": 16
"line": 17
}
},
{
"code": "a11y-no-static-element-interactions",
"end": {
"column": 76,
"line": 18
"line": 19
},
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
"start": {
"column": 0,
"line": 18
"line": 19
}
}
]

Loading…
Cancel
Save