Skip to content

Commit 82013aa

Browse files
mcmxcdevtanhauhaudsfx3d
authored
feat(a11y): add click-events-have-key-events rule (#5073)
* feat(a11y): add click-events-have-key-events rule Signed-off-by: mhatvan <[email protected]> * Fine-tune click-events-have-key-events rule Signed-off-by: mhatvan <[email protected]> * Implement PR feedback Signed-off-by: Markus Hatvan <[email protected]> * Implement PR feedback Signed-off-by: Markus Hatvan <[email protected]> * slight refactor to use existing utils * update docs * fix rebase conflicts Signed-off-by: mhatvan <[email protected]> Signed-off-by: Markus Hatvan <[email protected]> Co-authored-by: tanhauhau <[email protected]> Co-authored-by: dsfx3d <[email protected]>
1 parent 6469097 commit 82013aa

File tree

6 files changed

+222
-3
lines changed

6 files changed

+222
-3
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
4141

4242
---
4343

44+
### `a11y-click-events-have-key-events`
45+
46+
Enforce `on:click` is accompanied by at least one of the following: `onKeyUp`, `onKeyDown`, `onKeyPress`. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and screenreader users.
47+
48+
This does not apply for interactive or hidden elements.
49+
50+
```sv
51+
<!-- A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event. -->
52+
<div on:click={() => {}} />
53+
```
54+
55+
---
56+
4457
### `a11y-distracting-elements`
4558

4659
Enforces that no distracting elements are used. Elements that can be visually distracting can cause accessibility issues with visually impaired users. Such elements are most likely deprecated, and should be avoided.

src/compiler/compile/compiler_warnings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export default {
175175
code: 'a11y-mouse-events-have-key-events',
176176
message: `A11y: on:${event} must be accompanied by on:${accompanied_by}`
177177
}),
178+
a11y_click_events_have_key_events: () => ({
179+
code: 'a11y-click-events-have-key-events',
180+
message: 'A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.'
181+
}),
178182
a11y_missing_content: (name: string) => ({
179183
code: 'a11y-missing-content',
180184
message: `A11y: <${name}> element should have child content`

src/compiler/compile/nodes/Element.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { Literal } from 'estree';
2424
import compiler_warnings from '../compiler_warnings';
2525
import compiler_errors from '../compiler_errors';
2626
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
27-
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles } from '../utils/a11y';
27+
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader } from '../utils/a11y';
2828

2929
const aria_attributes = 'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' ');
3030
const aria_attribute_set = new Set(aria_attributes);
@@ -434,12 +434,17 @@ export default class Element extends Node {
434434
}
435435

436436
validate_attributes_a11y() {
437-
const { component, attributes } = this;
437+
const { component, attributes, handlers } = this;
438438

439439
const attribute_map = new Map<string, Attribute>();
440+
const handlers_map = new Map();
441+
440442
attributes.forEach(attribute => (
441443
attribute_map.set(attribute.name, attribute)
442444
));
445+
handlers.forEach(handler => (
446+
handlers_map.set(handler.name, handler)
447+
));
443448

444449
attributes.forEach(attribute => {
445450
if (attribute.is_spread) return;
@@ -484,7 +489,7 @@ export default class Element extends Node {
484489
}
485490

486491
const value = attribute.get_static_value() as ARIARoleDefintionKey;
487-
492+
488493
if (value && aria_role_abstract_set.has(value)) {
489494
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value));
490495
} else if (value && !aria_role_set.has(value)) {
@@ -550,6 +555,31 @@ export default class Element extends Node {
550555
}
551556
});
552557

558+
// click-events-have-key-events
559+
if (handlers_map.has('click')) {
560+
const role = attribute_map.get('role');
561+
const is_non_presentation_role = role?.is_static && !is_presentation_role(role.get_static_value() as ARIARoleDefintionKey);
562+
563+
if (
564+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
565+
(!role || is_non_presentation_role) &&
566+
!is_interactive_element(this.name, attribute_map) &&
567+
!this.attributes.find(attr => attr.is_spread)
568+
) {
569+
const has_key_event =
570+
handlers_map.has('keydown') ||
571+
handlers_map.has('keyup') ||
572+
handlers_map.has('keypress');
573+
574+
if (!has_key_event) {
575+
component.warn(
576+
this,
577+
compiler_warnings.a11y_click_events_have_key_events()
578+
);
579+
}
580+
}
581+
}
582+
553583
// no-noninteractive-tabindex
554584
if (!is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefintionKey)) {
555585
const tab_index = attribute_map.get('tabindex');

src/compiler/compile/utils/a11y.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ export function is_presentation_role(role: ARIARoleDefintionKey) {
6161
return presentation_roles.has(role);
6262
}
6363

64+
export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Map<string, Attribute>) {
65+
if (tag_name === 'input') {
66+
const type = attribute_map.get('type')?.get_static_value();
67+
68+
if (type && type === 'hidden') {
69+
return true;
70+
}
71+
}
72+
73+
const aria_hidden = attribute_map.get('aria-hidden');
74+
if (!aria_hidden) return false;
75+
if (!aria_hidden.is_static) return true;
76+
const aria_hidden_value = aria_hidden.get_static_value();
77+
return aria_hidden_value === true || aria_hidden_value === 'true';
78+
}
79+
6480
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
6581

6682
elementRoles.entries().forEach(([schema, roles]) => {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script>
2+
function noop() {}
3+
4+
let props = {};
5+
6+
const dynamicTypeValue = "checkbox";
7+
const dynamicAriaHiddenValue = "false";
8+
const dynamicRole = "button";
9+
</script>
10+
11+
<!-- should warn -->
12+
<div on:click={noop} />
13+
<div on:click={noop} aria-hidden="false" />
14+
15+
<section on:click={noop} />
16+
<main on:click={noop} />
17+
<article on:click={noop} />
18+
<header on:click={noop} />
19+
<footer on:click={noop} />
20+
21+
<!-- should not warn -->
22+
<div class="foo" />
23+
24+
<a href="http://x.y.z" on:click={noop}>foo</a>
25+
<button on:click={noop} />
26+
<select on:click={noop} />
27+
28+
<input type="button" on:click={noop} />
29+
<input type={dynamicTypeValue} on:click={noop} />
30+
31+
<div on:click={noop} {...props} />
32+
<div on:click={noop} on:keydown={noop} />
33+
<div on:click={noop} on:keyup={noop} />
34+
<div on:click={noop} on:keypress={noop} />
35+
<div on:click={noop} on:keydown={noop} on:keyup={noop} />
36+
<div on:click={noop} on:keyup={noop} on:keypress={noop} />
37+
<div on:click={noop} on:keypress={noop} on:keydown={noop} />
38+
<div on:click={noop} on:keydown={noop} on:keyup={noop} on:keypress={noop} />
39+
40+
<input on:click={noop} type="hidden" />
41+
42+
<div on:click={noop} aria-hidden />
43+
<div on:click={noop} aria-hidden="true" />
44+
<div on:click={noop} aria-hidden="false" on:keydown={noop} />
45+
<div on:click={noop} aria-hidden={dynamicAriaHiddenValue} />
46+
47+
<div on:click={noop} role="presentation" />
48+
<div on:click={noop} role="none" />
49+
<div on:click={noop} role={dynamicRole} />
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
[
2+
{
3+
"code": "a11y-click-events-have-key-events",
4+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
5+
"start": {
6+
"line": 12,
7+
"column": 0,
8+
"character": 190
9+
},
10+
"end": {
11+
"line": 12,
12+
"column": 23,
13+
"character": 213
14+
},
15+
"pos": 190
16+
},
17+
{
18+
"code": "a11y-click-events-have-key-events",
19+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
20+
"start": {
21+
"line": 13,
22+
"column": 0,
23+
"character": 214
24+
},
25+
"end": {
26+
"line": 13,
27+
"column": 43,
28+
"character": 257
29+
},
30+
"pos": 214
31+
},
32+
{
33+
"code": "a11y-click-events-have-key-events",
34+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
35+
"start": {
36+
"line": 15,
37+
"column": 0,
38+
"character": 259
39+
},
40+
"end": {
41+
"line": 15,
42+
"column": 27,
43+
"character": 286
44+
},
45+
"pos": 259
46+
},
47+
{
48+
"code": "a11y-click-events-have-key-events",
49+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
50+
"start": {
51+
"line": 16,
52+
"column": 0,
53+
"character": 287
54+
},
55+
"end": {
56+
"line": 16,
57+
"column": 24,
58+
"character": 311
59+
},
60+
"pos": 287
61+
},
62+
{
63+
"code": "a11y-click-events-have-key-events",
64+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
65+
"start": {
66+
"line": 17,
67+
"column": 0,
68+
"character": 312
69+
},
70+
"end": {
71+
"line": 17,
72+
"column": 27,
73+
"character": 339
74+
},
75+
"pos": 312
76+
},
77+
{
78+
"code": "a11y-click-events-have-key-events",
79+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
80+
"start": {
81+
"line": 18,
82+
"column": 0,
83+
"character": 340
84+
},
85+
"end": {
86+
"line": 18,
87+
"column": 26,
88+
"character": 366
89+
},
90+
"pos": 340
91+
},
92+
{
93+
"code": "a11y-click-events-have-key-events",
94+
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
95+
"start": {
96+
"line": 19,
97+
"column": 0,
98+
"character": 367
99+
},
100+
"end": {
101+
"line": 19,
102+
"column": 26,
103+
"character": 393
104+
},
105+
"pos": 367
106+
}
107+
]

0 commit comments

Comments
 (0)