From 82013aa1610e9d8c73064e449d3b2c1304987896 Mon Sep 17 00:00:00 2001 From: MCMXC <16797721+mcmxcdev@users.noreply.github.com> Date: Tue, 13 Sep 2022 15:22:10 +0200 Subject: [PATCH] feat(a11y): add click-events-have-key-events rule (#5073) * feat(a11y): add click-events-have-key-events rule Signed-off-by: mhatvan * Fine-tune click-events-have-key-events rule Signed-off-by: mhatvan * Implement PR feedback Signed-off-by: Markus Hatvan * Implement PR feedback Signed-off-by: Markus Hatvan * slight refactor to use existing utils * update docs * fix rebase conflicts Signed-off-by: mhatvan Signed-off-by: Markus Hatvan Co-authored-by: tanhauhau Co-authored-by: dsfx3d --- .../content/docs/05-accessibility-warnings.md | 13 +++ src/compiler/compile/compiler_warnings.ts | 4 + src/compiler/compile/nodes/Element.ts | 36 +++++- src/compiler/compile/utils/a11y.ts | 16 +++ .../input.svelte | 49 ++++++++ .../warnings.json | 107 ++++++++++++++++++ 6 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 test/validator/samples/a11y-click-events-have-key-events/input.svelte create mode 100644 test/validator/samples/a11y-click-events-have-key-events/warnings.json diff --git a/site/content/docs/05-accessibility-warnings.md b/site/content/docs/05-accessibility-warnings.md index a7c1b8ded8..c82ef21fcb 100644 --- a/site/content/docs/05-accessibility-warnings.md +++ b/site/content/docs/05-accessibility-warnings.md @@ -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-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. diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index 2834847b8b..f194090870 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -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` diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 48ea0ec3e1..20381ee4f6 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -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(); + 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'); diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 5b300eac13..3f35327e7c 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -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) { + 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]) => { diff --git a/test/validator/samples/a11y-click-events-have-key-events/input.svelte b/test/validator/samples/a11y-click-events-have-key-events/input.svelte new file mode 100644 index 0000000000..a63ea92fbc --- /dev/null +++ b/test/validator/samples/a11y-click-events-have-key-events/input.svelte @@ -0,0 +1,49 @@ + + + +
+
+ +
+
+
+
+