Skip to content

Commit

Permalink
feat(a11y): add click-events-have-key-events rule (#5073)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed Sep 13, 2022
1 parent 6469097 commit 82013aa
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 3 deletions.
13 changes: 13 additions & 0 deletions site/content/docs/05-accessibility-warnings.md
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Expand Up @@ -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`
Expand Down
36 changes: 33 additions & 3 deletions src/compiler/compile/nodes/Element.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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');
Expand Down
16 changes: 16 additions & 0 deletions src/compiler/compile/utils/a11y.ts
Expand Up @@ -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]) => {
Expand Down
@@ -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} />
107 changes: 107 additions & 0 deletions test/validator/samples/a11y-click-events-have-key-events/warnings.json
@@ -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
}
]

0 comments on commit 82013aa

Please sign in to comment.