diff --git a/site/content/docs/05-accessibility-warnings.md b/site/content/docs/05-accessibility-warnings.md index a7c1b8ded85..c82ef21fcb5 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 2834847b8b1..f1940908703 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 48ea0ec3e1a..20381ee4f6e 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 5b300eac139..3f35327e7c2 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 00000000000..a63ea92fbcb --- /dev/null +++ b/test/validator/samples/a11y-click-events-have-key-events/input.svelte @@ -0,0 +1,49 @@ + + + +
+
+ +
+
+
+
+