feat(a11y): add click-events-have-key-events rule (#5073)

* feat(a11y): add click-events-have-key-events rule

Signed-off-by: mhatvan <markus_hatvan@aon.at>

* Fine-tune click-events-have-key-events rule

Signed-off-by: mhatvan <markus_hatvan@aon.at>

* Implement PR feedback

Signed-off-by: Markus Hatvan <markus_hatvan@aon.at>

* Implement PR feedback

Signed-off-by: Markus Hatvan <markus_hatvan@aon.at>

* slight refactor to use existing utils

* update docs

* fix rebase conflicts

Signed-off-by: mhatvan <markus_hatvan@aon.at>
Signed-off-by: Markus Hatvan <markus_hatvan@aon.at>
Co-authored-by: tanhauhau <lhtan93@gmail.com>
Co-authored-by: dsfx3d <dsfx3d@gmail.com>
pull/7838/head
MCMXC 2 years ago committed by GitHub
parent 64690974dd
commit 82013aa161
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -41,6 +41,19 @@ Enforce that `autofocus` is not used on elements. Autofocusing elements can caus
---
### `a11y-click-events-have-key-events`
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.
This does not apply for interactive or hidden elements.
```sv
<!-- A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event. -->
<div on:click={() => {}} />
```
---
### `a11y-distracting-elements`
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.

@ -175,6 +175,10 @@ export default {
code: 'a11y-mouse-events-have-key-events',
message: `A11y: on:${event} must be accompanied by on:${accompanied_by}`
}),
a11y_click_events_have_key_events: () => ({
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.'
}),
a11y_missing_content: (name: string) => ({
code: 'a11y-missing-content',
message: `A11y: <${name}> element should have child content`

@ -24,7 +24,7 @@ import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles } from '../utils/a11y';
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader } from '../utils/a11y';
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(' ');
const aria_attribute_set = new Set(aria_attributes);
@ -434,12 +434,17 @@ export default class Element extends Node {
}
validate_attributes_a11y() {
const { component, attributes } = this;
const { component, attributes, handlers } = this;
const attribute_map = new Map<string, Attribute>();
const handlers_map = new Map();
attributes.forEach(attribute => (
attribute_map.set(attribute.name, attribute)
));
handlers.forEach(handler => (
handlers_map.set(handler.name, handler)
));
attributes.forEach(attribute => {
if (attribute.is_spread) return;
@ -484,7 +489,7 @@ export default class Element extends Node {
}
const value = attribute.get_static_value() as ARIARoleDefintionKey;
if (value && aria_role_abstract_set.has(value)) {
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value));
} else if (value && !aria_role_set.has(value)) {
@ -550,6 +555,31 @@ export default class Element extends Node {
}
});
// click-events-have-key-events
if (handlers_map.has('click')) {
const role = attribute_map.get('role');
const is_non_presentation_role = role?.is_static && !is_presentation_role(role.get_static_value() as ARIARoleDefintionKey);
if (
!is_hidden_from_screen_reader(this.name, attribute_map) &&
(!role || is_non_presentation_role) &&
!is_interactive_element(this.name, attribute_map) &&
!this.attributes.find(attr => attr.is_spread)
) {
const has_key_event =
handlers_map.has('keydown') ||
handlers_map.has('keyup') ||
handlers_map.has('keypress');
if (!has_key_event) {
component.warn(
this,
compiler_warnings.a11y_click_events_have_key_events()
);
}
}
}
// no-noninteractive-tabindex
if (!is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefintionKey)) {
const tab_index = attribute_map.get('tabindex');

@ -61,6 +61,22 @@ export function is_presentation_role(role: ARIARoleDefintionKey) {
return presentation_roles.has(role);
}
export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Map<string, Attribute>) {
if (tag_name === 'input') {
const type = attribute_map.get('type')?.get_static_value();
if (type && type === 'hidden') {
return true;
}
}
const aria_hidden = attribute_map.get('aria-hidden');
if (!aria_hidden) return false;
if (!aria_hidden.is_static) return true;
const aria_hidden_value = aria_hidden.get_static_value();
return aria_hidden_value === true || aria_hidden_value === 'true';
}
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
elementRoles.entries().forEach(([schema, roles]) => {

@ -0,0 +1,49 @@
<script>
function noop() {}
let props = {};
const dynamicTypeValue = "checkbox";
const dynamicAriaHiddenValue = "false";
const dynamicRole = "button";
</script>
<!-- should warn -->
<div on:click={noop} />
<div on:click={noop} aria-hidden="false" />
<section on:click={noop} />
<main on:click={noop} />
<article on:click={noop} />
<header on:click={noop} />
<footer on:click={noop} />
<!-- should not warn -->
<div class="foo" />
<a href="http://x.y.z" on:click={noop}>foo</a>
<button on:click={noop} />
<select on:click={noop} />
<input type="button" on:click={noop} />
<input type={dynamicTypeValue} on:click={noop} />
<div on:click={noop} {...props} />
<div on:click={noop} on:keydown={noop} />
<div on:click={noop} on:keyup={noop} />
<div on:click={noop} on:keypress={noop} />
<div on:click={noop} on:keydown={noop} on:keyup={noop} />
<div on:click={noop} on:keyup={noop} on:keypress={noop} />
<div on:click={noop} on:keypress={noop} on:keydown={noop} />
<div on:click={noop} on:keydown={noop} on:keyup={noop} on:keypress={noop} />
<input on:click={noop} type="hidden" />
<div on:click={noop} aria-hidden />
<div on:click={noop} aria-hidden="true" />
<div on:click={noop} aria-hidden="false" on:keydown={noop} />
<div on:click={noop} aria-hidden={dynamicAriaHiddenValue} />
<div on:click={noop} role="presentation" />
<div on:click={noop} role="none" />
<div on:click={noop} role={dynamicRole} />

@ -0,0 +1,107 @@
[
{
"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": 12,
"column": 0,
"character": 190
},
"end": {
"line": 12,
"column": 23,
"character": 213
},
"pos": 190
},
{
"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": 13,
"column": 0,
"character": 214
},
"end": {
"line": 13,
"column": 43,
"character": 257
},
"pos": 214
},
{
"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": 15,
"column": 0,
"character": 259
},
"end": {
"line": 15,
"column": 27,
"character": 286
},
"pos": 259
},
{
"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": 16,
"column": 0,
"character": 287
},
"end": {
"line": 16,
"column": 24,
"character": 311
},
"pos": 287
},
{
"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": 17,
"column": 0,
"character": 312
},
"end": {
"line": 17,
"column": 27,
"character": 339
},
"pos": 312
},
{
"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": 18,
"column": 0,
"character": 340
},
"end": {
"line": 18,
"column": 26,
"character": 366
},
"pos": 340
},
{
"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,
"column": 0,
"character": 367
},
"end": {
"line": 19,
"column": 26,
"character": 393
},
"pos": 367
}
]
Loading…
Cancel
Save