Skip to content

Commit d587175

Browse files
timmcca-bedummdidumm
authored andcommittedApr 18, 2023
feat: add a11y-no-static-element-interactions compiler rule (#8251)
Ref: #820
1 parent c81522f commit d587175

File tree

14 files changed

+135
-31
lines changed

14 files changed

+135
-31
lines changed
 

‎src/compiler/compile/compiler_warnings.ts

+4
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export default {
115115
code: 'a11y-no-redundant-roles',
116116
message: `A11y: Redundant role '${role}'`
117117
}),
118+
a11y_no_static_element_interactions: (element: string, handlers: string[]) => ({
119+
code: 'a11y-no-static-element-interactions',
120+
message: `A11y: <${element}> with ${handlers.join(', ')} ${handlers.length === 1 ? 'handler' : 'handlers'} must have an ARIA role`
121+
}),
118122
a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
119123
code: 'a11y-no-interactive-element-to-noninteractive-role',
120124
message: `A11y: <${element}> cannot have role '${role}'`

‎src/compiler/compile/nodes/Element.ts

+28-3
Original file line numberDiff line numberDiff line change
@@ -738,17 +738,18 @@ export default class Element extends Node {
738738
}
739739
}
740740

741+
const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey;
742+
741743
// no-noninteractive-tabindex
742-
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) {
744+
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) {
743745
const tab_index = attribute_map.get('tabindex');
744746
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
745747
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
746748
}
747749
}
748750

749751
// role-supports-aria-props
750-
const role = attribute_map.get('role');
751-
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
752+
const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
752753
if (typeof role_value === 'string' && roles.has(role_value)) {
753754
const { props } = roles.get(role_value);
754755
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
@@ -762,6 +763,30 @@ export default class Element extends Node {
762763
}
763764
});
764765
}
766+
767+
const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static;
768+
769+
// no-static-element-interactions
770+
if (
771+
!has_dynamic_role &&
772+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
773+
!is_presentation_role(role) &&
774+
!is_interactive_element(this.name, attribute_map) &&
775+
!is_interactive_roles(role) &&
776+
!is_non_interactive_element(this.name, attribute_map) &&
777+
!is_non_interactive_roles(role) &&
778+
!is_abstract_role(role)
779+
) {
780+
const interactive_handlers = handlers
781+
.map((handler) => handler.name)
782+
.filter((handlerName) => a11y_interactive_handlers.has(handlerName));
783+
if (interactive_handlers.length > 0) {
784+
component.warn(
785+
this,
786+
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers)
787+
);
788+
}
789+
}
765790
}
766791

767792
validate_special_cases() {

‎src/compiler/compile/utils/a11y.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ const non_interactive_roles = new Set(
1919
// 'toolbar' does not descend from widget, but it does support
2020
// aria-activedescendant, thus in practice we treat it as a widget.
2121
// focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable.
22-
!['toolbar', 'tabpanel'].includes(name) &&
22+
// 'generic' is meant to have no semantic meaning.
23+
!['toolbar', 'tabpanel', 'generic'].includes(name) &&
2324
!role.superClass.some((classes) => classes.includes('widget'))
2425
);
2526
})
@@ -31,7 +32,11 @@ const non_interactive_roles = new Set(
3132
);
3233

3334
const interactive_roles = new Set(
34-
non_abstract_roles.filter((name) => !non_interactive_roles.has(name))
35+
non_abstract_roles.filter((name) =>
36+
!non_interactive_roles.has(name) &&
37+
// 'generic' is meant to have no semantic meaning.
38+
name !== 'generic'
39+
)
3540
);
3641

3742
export function is_non_interactive_roles(role: ARIARoleDefinitionKey) {

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

+17
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99
</script>
1010

1111
<!-- should warn -->
12+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1213
<div on:click={noop} />
14+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1315
<div on:click={noop} aria-hidden="false" />
1416

17+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1518
<section on:click={noop} />
1619
<main on:click={noop} />
1720
<article on:click={noop} />
21+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1822
<header on:click={noop} />
1923
<footer on:click={noop} />
2024

@@ -28,24 +32,37 @@
2832
<input type="button" on:click={noop} />
2933
<input type={dynamicTypeValue} on:click={noop} />
3034

35+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3136
<div on:click={noop} {...props} />
37+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3238
<div on:click={noop} on:keydown={noop} />
39+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3340
<div on:click={noop} on:keyup={noop} />
41+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3442
<div on:click={noop} on:keypress={noop} />
43+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3544
<div on:click={noop} on:keydown={noop} on:keyup={noop} />
45+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3646
<div on:click={noop} on:keyup={noop} on:keypress={noop} />
47+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3748
<div on:click={noop} on:keypress={noop} on:keydown={noop} />
49+
<!-- svelte-ignore a11y-no-static-element-interactions -->
3850
<div on:click={noop} on:keydown={noop} on:keyup={noop} on:keypress={noop} />
3951

4052
<input on:click={noop} type="hidden" />
4153

54+
<!-- svelte-ignore a11y-no-static-element-interactions -->
4255
<div on:click={noop} aria-hidden />
56+
<!-- svelte-ignore a11y-no-static-element-interactions -->
4357
<div on:click={noop} aria-hidden="true" />
58+
<!-- svelte-ignore a11y-no-static-element-interactions -->
4459
<div on:click={noop} aria-hidden="false" on:keydown={noop} />
60+
<!-- svelte-ignore a11y-no-static-element-interactions -->
4561
<div on:click={noop} aria-hidden={dynamicAriaHiddenValue} />
4662

4763
<div on:click={noop} role="presentation" />
4864
<div on:click={noop} role="none" />
4965
<div on:click={noop} role={dynamicRole} />
5066

67+
<!-- svelte-ignore a11y-no-static-element-interactions -->
5168
<svelte:element this={Math.random() ? 'button' : 'div'} on:click={noop} />

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

+14-14
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,83 @@
33
"code": "a11y-click-events-have-key-events",
44
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
55
"start": {
6-
"line": 12,
6+
"line": 13,
77
"column": 0
88
},
99
"end": {
10-
"line": 12,
10+
"line": 13,
1111
"column": 23
1212
}
1313
},
1414
{
1515
"code": "a11y-click-events-have-key-events",
1616
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
1717
"start": {
18-
"line": 13,
18+
"line": 15,
1919
"column": 0
2020
},
2121
"end": {
22-
"line": 13,
22+
"line": 15,
2323
"column": 43
2424
}
2525
},
2626
{
2727
"code": "a11y-click-events-have-key-events",
2828
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
2929
"start": {
30-
"line": 15,
30+
"line": 18,
3131
"column": 0
3232
},
3333
"end": {
34-
"line": 15,
34+
"line": 18,
3535
"column": 27
3636
}
3737
},
3838
{
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": 16,
42+
"line": 19,
4343
"column": 0
4444
},
4545
"end": {
46-
"line": 16,
46+
"line": 19,
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": 17,
54+
"line": 20,
5555
"column": 0
5656
},
5757
"end": {
58-
"line": 17,
58+
"line": 20,
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": 18,
66+
"line": 22,
6767
"column": 0
6868
},
6969
"end": {
70-
"line": 18,
70+
"line": 22,
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": 19,
78+
"line": 23,
7979
"column": 0
8080
},
8181
"end": {
82-
"line": 19,
82+
"line": 23,
8383
"column": 26
8484
}
8585
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
77
};
88
</script>
99

10+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1011
<div on:mouseover={() => void 0} />
12+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1113
<div on:mouseover={() => void 0} on:focus={() => void 0} />
14+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1215
<div on:mouseover={() => void 0} {...otherProps} />
16+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1317
<div on:mouseout={() => void 0} />
18+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1419
<div on:mouseout={() => void 0} on:blur={() => void 0} />
20+
<!-- svelte-ignore a11y-no-static-element-interactions -->
1521
<div on:mouseout={() => void 0} {...otherProps} />

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

+8-8
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,48 @@
33
"code": "a11y-mouse-events-have-key-events",
44
"end": {
55
"column": 35,
6-
"line": 10
6+
"line": 11
77
},
88
"message": "A11y: on:mouseover must be accompanied by on:focus",
99
"start": {
1010
"column": 0,
11-
"line": 10
11+
"line": 11
1212
}
1313
},
1414
{
1515
"code": "a11y-mouse-events-have-key-events",
1616
"end": {
1717
"column": 51,
18-
"line": 12
18+
"line": 15
1919
},
2020
"message": "A11y: on:mouseover must be accompanied by on:focus",
2121
"start": {
2222
"column": 0,
23-
"line": 12
23+
"line": 15
2424
}
2525
},
2626
{
2727
"code": "a11y-mouse-events-have-key-events",
2828
"end": {
2929
"column": 34,
30-
"line": 13
30+
"line": 17
3131
},
3232
"message": "A11y: on:mouseout must be accompanied by on:blur",
3333
"start": {
3434
"column": 0,
35-
"line": 13
35+
"line": 17
3636
}
3737
},
3838
{
3939
"code": "a11y-mouse-events-have-key-events",
4040
"end": {
4141
"column": 50,
42-
"line": 15
42+
"line": 21
4343
},
4444
"message": "A11y: on:mouseout must be accompanied by on:blur",
4545
"start": {
4646
"column": 0,
47-
"line": 15
47+
"line": 21
4848
}
4949
}
5050
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
const dynamicRole = "button";
3+
</script>
4+
5+
<!-- valid -->
6+
<button on:click={() => {}} />
7+
<!-- svelte-ignore a11y-interactive-supports-focus -->
8+
<div on:keydown={() => {}} role="button" />
9+
<input type="text" on:click={() => {}} />
10+
<div on:copy={() => {}} />
11+
<a href="/foo" on:click={() => {}}>link</a>
12+
<div role={dynamicRole} on:click={() => {}} />
13+
<footer on:keydown={() => {}} />
14+
15+
<!-- invalid -->
16+
<div on:keydown={() => {}} />
17+
<!-- svelte-ignore a11y-missing-attribute -->
18+
<a on:mousedown={() => {}} on:mouseup={() => {}} on:copy={() => {}}>link</a>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"code": "a11y-no-static-element-interactions",
4+
"end": {
5+
"column": 29,
6+
"line": 16
7+
},
8+
"message": "A11y: <div> with keydown handler must have an ARIA role",
9+
"start": {
10+
"column": 0,
11+
"line": 16
12+
}
13+
},
14+
{
15+
"code": "a11y-no-static-element-interactions",
16+
"end": {
17+
"column": 76,
18+
"line": 18
19+
},
20+
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
21+
"start": {
22+
"column": 0,
23+
"line": 18
24+
}
25+
}
26+
]

‎test/validator/samples/slot-warning-ignore/input.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44

55
<Component>
66
<!-- svelte-ignore a11y-click-events-have-key-events -->
7+
<!-- svelte-ignore a11y-no-static-element-interactions -->
78
<div slot='foo' on:click>hi!</div>
89
</Component>

‎test/validator/samples/slot-warning/input.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
</script>
44

55
<Component>
6+
<!-- svelte-ignore a11y-no-static-element-interactions -->
67
<div slot='foo' on:click>hi!</div>
78
</Component>

‎test/validator/samples/slot-warning/warnings.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
55
"start": {
66
"column": 1,
7-
"line": 6
7+
"line": 7
88
},
99
"end": {
1010
"column": 35,
11-
"line": 6
11+
"line": 7
1212
}
1313
}
1414
]

‎test/validator/samples/slot-warning2/input.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44

55
<Component>
66
<!-- svelte-ignore unrelated-warning -->
7+
<!-- svelte-ignore a11y-no-static-element-interactions -->
78
<div slot='foo' on:click>hi!</div>
89
</Component>

‎test/validator/samples/slot-warning2/warnings.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
55
"start": {
66
"column": 1,
7-
"line": 7
7+
"line": 8
88
},
99
"end": {
1010
"column": 35,
11-
"line": 7
11+
"line": 8
1212
}
1313
}
1414
]

0 commit comments

Comments
 (0)
Please sign in to comment.