Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from Sep 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}
]