Skip to content

Commit d1a9722

Browse files
authored
feat: add a11y no-noninteractive-element-interactions (#8391)
#820
1 parent 88728e3 commit d1a9722

File tree

10 files changed

+146
-20
lines changed

10 files changed

+146
-20
lines changed

CHANGELOG.md

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

1013
## Unreleased (3.0)
1114

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

+14
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,20 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t
288288

289289
---
290290

291+
### `a11y-no-noninteractive-element-interactions`
292+
293+
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`.
294+
295+
```sv
296+
<!-- `A11y: Non-interactive element <li> should not be assigned mouse or keyboard event listeners.` -->
297+
<li on:click={() => {}} />
298+
299+
<!-- `A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.` -->
300+
<div role="listitem" on:click={() => {}} />
301+
```
302+
303+
---
304+
291305
### `a11y-no-noninteractive-element-to-interactive-role`
292306

293307
[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`.

src/compiler/compile/compiler_warnings.ts

+4
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ export default {
123123
code: 'a11y-no-interactive-element-to-noninteractive-role',
124124
message: `A11y: <${element}> cannot have role '${role}'`
125125
}),
126+
a11y_no_noninteractive_element_interactions: (element: string) => ({
127+
code: 'a11y-no-noninteractive-element-interactions',
128+
message: `A11y: Non-interactive element <${element}> should not be assigned mouse or keyboard event listeners.`
129+
}),
126130
a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({
127131
code: 'a11y-no-noninteractive-element-to-interactive-role',
128132
message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'`

src/compiler/compile/nodes/Element.ts

+33-8
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import StyleDirective from './StyleDirective';
1111
import Text from './Text';
1212
import { namespaces } from '../../utils/namespaces';
1313
import map_children from './shared/map_children';
14-
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
14+
import { is_name_contenteditable, get_contenteditable_attr, has_contenteditable_attr } from '../utils/contenteditable';
1515
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns';
1616
import fuzzymatch from '../../utils/fuzzymatch';
1717
import list from '../../utils/list';
@@ -102,6 +102,15 @@ const a11y_interactive_handlers = new Set([
102102
'mouseup'
103103
]);
104104

105+
const a11y_recommended_interactive_handlers = new Set([
106+
'click',
107+
'mousedown',
108+
'mouseup',
109+
'keypress',
110+
'keydown',
111+
'keyup'
112+
]);
113+
105114
const a11y_nested_implicit_semantics = new Map([
106115
['header', 'banner'],
107116
['footer', 'contentinfo']
@@ -738,18 +747,19 @@ export default class Element extends Node {
738747
}
739748
}
740749

741-
const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey;
750+
const role = attribute_map.get('role');
751+
const role_static_value = role?.get_static_value() as ARIARoleDefinitionKey;
752+
const role_value = (role ? role_static_value : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
742753

743754
// no-noninteractive-tabindex
744-
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) {
755+
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role_static_value)) {
745756
const tab_index = attribute_map.get('tabindex');
746757
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
747758
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
748759
}
749760
}
750761

751762
// role-supports-aria-props
752-
const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
753763
if (typeof role_value === 'string' && roles.has(role_value)) {
754764
const { props } = roles.get(role_value);
755765
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
@@ -764,18 +774,33 @@ export default class Element extends Node {
764774
});
765775
}
766776

777+
// no-noninteractive-element-interactions
778+
if (
779+
!has_contenteditable_attr(this) &&
780+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
781+
!is_presentation_role(role_static_value) &&
782+
((!is_interactive_element(this.name, attribute_map) &&
783+
is_non_interactive_roles(role_static_value)) ||
784+
(is_non_interactive_element(this.name, attribute_map) && !role))
785+
) {
786+
const has_interactive_handlers = handlers.some((handler) => a11y_recommended_interactive_handlers.has(handler.name));
787+
if (has_interactive_handlers) {
788+
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_interactions(this.name));
789+
}
790+
}
791+
767792
const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static;
768793

769794
// no-static-element-interactions
770795
if (
771796
!has_dynamic_role &&
772797
!is_hidden_from_screen_reader(this.name, attribute_map) &&
773-
!is_presentation_role(role) &&
798+
!is_presentation_role(role_static_value) &&
774799
!is_interactive_element(this.name, attribute_map) &&
775-
!is_interactive_roles(role) &&
800+
!is_interactive_roles(role_static_value) &&
776801
!is_non_interactive_element(this.name, attribute_map) &&
777-
!is_non_interactive_roles(role) &&
778-
!is_abstract_role(role)
802+
!is_non_interactive_roles(role_static_value) &&
803+
!is_abstract_role(role_static_value)
779804
) {
780805
const interactive_handlers = handlers
781806
.map((handler) => handler.name)

test/validator/samples/a11y-click-events-have-key-events/input.svelte

+3
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616

1717
<!-- svelte-ignore a11y-no-static-element-interactions -->
1818
<section on:click={noop} />
19+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
1920
<main on:click={noop} />
21+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
2022
<article on:click={noop} />
2123
<!-- svelte-ignore a11y-no-static-element-interactions -->
2224
<header on:click={noop} />
25+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
2326
<footer on:click={noop} />
2427

2528
<!-- should not warn -->

test/validator/samples/a11y-click-events-have-key-events/warnings.json

+8-8
Original file line numberDiff line numberDiff line change
@@ -39,47 +39,47 @@
3939
"code": "a11y-click-events-have-key-events",
4040
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
4141
"start": {
42-
"line": 19,
42+
"line": 20,
4343
"column": 0
4444
},
4545
"end": {
46-
"line": 19,
46+
"line": 20,
4747
"column": 24
4848
}
4949
},
5050
{
5151
"code": "a11y-click-events-have-key-events",
5252
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
5353
"start": {
54-
"line": 20,
54+
"line": 22,
5555
"column": 0
5656
},
5757
"end": {
58-
"line": 20,
58+
"line": 22,
5959
"column": 27
6060
}
6161
},
6262
{
6363
"code": "a11y-click-events-have-key-events",
6464
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
6565
"start": {
66-
"line": 22,
66+
"line": 24,
6767
"column": 0
6868
},
6969
"end": {
70-
"line": 22,
70+
"line": 24,
7171
"column": 26
7272
}
7373
},
7474
{
7575
"code": "a11y-click-events-have-key-events",
7676
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
7777
"start": {
78-
"line": 23,
78+
"line": 26,
7979
"column": 0
8080
},
8181
"end": {
82-
"line": 23,
82+
"line": 26,
8383
"column": 26
8484
}
8585
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!-- VALID -->
2+
<div role="presentation" on:mouseup={() => {}} />
3+
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}} />
4+
<div role="listitem" aria-hidden on:click={() => {}} on:keypress={() => {}} />
5+
<button on:click={() => {}} />
6+
<h1 contenteditable="true" on:keydown={() => {}}>Heading</h1>
7+
<h1>Heading</h1>
8+
9+
<!-- INVALID -->
10+
<div role="listitem" on:mousedown={() => {}} />
11+
<h1 on:click={() => {}} on:keydown={() => {}}>Heading</h1>
12+
<h1 role="banner" on:keyup={() => {}}>Heading</h1>
13+
<p on:keypress={() => {}} />
14+
<div role="paragraph" on:mouseup={() => {}} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
[
2+
{
3+
"code": "a11y-no-noninteractive-element-interactions",
4+
"end": {
5+
"column": 47,
6+
"line": 10
7+
},
8+
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
9+
"start": {
10+
"column": 0,
11+
"line": 10
12+
}
13+
},
14+
{
15+
"code": "a11y-no-noninteractive-element-interactions",
16+
"end": {
17+
"column": 58,
18+
"line": 11
19+
},
20+
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
21+
"start": {
22+
"column": 0,
23+
"line": 11
24+
}
25+
},
26+
{
27+
"code": "a11y-no-noninteractive-element-interactions",
28+
"end": {
29+
"column": 50,
30+
"line": 12
31+
},
32+
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
33+
"start": {
34+
"column": 0,
35+
"line": 12
36+
}
37+
},
38+
{
39+
"code": "a11y-no-noninteractive-element-interactions",
40+
"end": {
41+
"column": 28,
42+
"line": 13
43+
},
44+
"message": "A11y: Non-interactive element <p> should not be assigned mouse or keyboard event listeners.",
45+
"start": {
46+
"column": 0,
47+
"line": 13
48+
}
49+
},
50+
{
51+
"code": "a11y-no-noninteractive-element-interactions",
52+
"end": {
53+
"column": 46,
54+
"line": 14
55+
},
56+
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
57+
"start": {
58+
"column": 0,
59+
"line": 14
60+
}
61+
}
62+
]

test/validator/samples/a11y-no-static-element-interactions/input.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<div on:copy={() => {}} />
1111
<a href="/foo" on:click={() => {}}>link</a>
1212
<div role={dynamicRole} on:click={() => {}} />
13+
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
1314
<footer on:keydown={() => {}} />
1415

1516
<!-- invalid -->

test/validator/samples/a11y-no-static-element-interactions/warnings.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,24 @@
33
"code": "a11y-no-static-element-interactions",
44
"end": {
55
"column": 29,
6-
"line": 16
6+
"line": 17
77
},
88
"message": "A11y: <div> with keydown handler must have an ARIA role",
99
"start": {
1010
"column": 0,
11-
"line": 16
11+
"line": 17
1212
}
1313
},
1414
{
1515
"code": "a11y-no-static-element-interactions",
1616
"end": {
1717
"column": 76,
18-
"line": 18
18+
"line": 19
1919
},
2020
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
2121
"start": {
2222
"column": 0,
23-
"line": 18
23+
"line": 19
2424
}
2525
}
2626
]

0 commit comments

Comments
 (0)