From 38305ac7ca0bdf23b52d0cbe2996c692b9f299e5 Mon Sep 17 00:00:00 2001 From: Tim McCabe Date: Fri, 3 Feb 2023 23:38:51 -0500 Subject: [PATCH] add no-static-element-interactions compiler rule --- src/compiler/compile/compiler_warnings.ts | 4 + src/compiler/compile/nodes/Element.ts | 30 +++++- src/compiler/compile/utils/a11y.ts | 70 ++++++++++++-- .../input.svelte | 18 ++++ .../warnings.json | 65 +++++++++++++ .../input.svelte | 6 ++ .../warnings.json | 96 +++++++++---------- .../input.svelte | 10 ++ .../warnings.json | 32 +++++++ .../samples/slot-warning-ignore/input.svelte | 1 + .../samples/slot-warning/input.svelte | 1 + .../samples/slot-warning/warnings.json | 2 +- .../samples/slot-warning2/input.svelte | 1 + .../samples/slot-warning2/warnings.json | 2 +- 14 files changed, 277 insertions(+), 61 deletions(-) create mode 100644 test/validator/samples/a11y-no-static-element-interactions/input.svelte create mode 100644 test/validator/samples/a11y-no-static-element-interactions/warnings.json diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a851bc24c2c8..e9fc80cabef4 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -115,6 +115,10 @@ export default { code: 'a11y-no-redundant-roles', message: `A11y: Redundant role '${role}'` }), + a11y_no_static_element_interactions: (element: string, handlers: string[]) => ({ + code: 'a11y-no-static-element-interactions', + message: `A11y: <${element}> with ${handlers.join(', ')} ${handlers.length === 1 ? 'handler' : 'handlers'} must have an ARIA role` + }), a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({ code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 2410904d6301..3ab6a2251518 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -25,7 +25,7 @@ import { Literal } from 'estree'; import compiler_warnings from '../compiler_warnings'; import compiler_errors from '../compiler_errors'; import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; -import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute } from '../utils/a11y'; +import { is_non_interactive_element, is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute, is_interactive_handler } 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); @@ -738,8 +738,10 @@ export default class Element extends Node { } } + const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey; + // no-noninteractive-tabindex - if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) { + if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) { const tab_index = attribute_map.get('tabindex'); if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); @@ -747,8 +749,7 @@ export default class Element extends Node { } // role-supports-aria-props - const role = attribute_map.get('role'); - const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; + const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; if (typeof role_value === 'string' && roles.has(role_value)) { const { props } = roles.get(role_value); const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props))); @@ -762,6 +763,27 @@ export default class Element extends Node { } }); } + + // no-static-element-interactions + // TODO: investigate footer + // TODO: investigate dynamic roles + if ( + !is_hidden_from_screen_reader(this.name, attribute_map) && + !is_presentation_role(role) && + !is_interactive_element(this.name, attribute_map) && + !is_interactive_roles(role) && + !is_non_interactive_element(this.name, attribute_map) && + !is_non_interactive_roles(role) && + !is_abstract_role(role) + ) { + const interactive_handlers = handlers.map((handler) => handler.name).filter(is_interactive_handler); + if (interactive_handlers.length > 0) { + component.warn( + this, + compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers) + ); + } + } } validate_special_cases() { diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 4409f802623d..fbc4327468a9 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -19,7 +19,8 @@ const non_interactive_roles = new Set( // 'toolbar' does not descend from widget, but it does support // aria-activedescendant, thus in practice we treat it as a widget. // focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable. - !['toolbar', 'tabpanel'].includes(name) && + // 'generic' is meant to have no semantic meaning. + !['toolbar', 'tabpanel', 'generic'].includes(name) && !role.superClass.some((classes) => classes.includes('widget')) ); }) @@ -31,7 +32,11 @@ const non_interactive_roles = new Set( ); const interactive_roles = new Set( - non_abstract_roles.filter((name) => !non_interactive_roles.has(name)) + non_abstract_roles.filter((name) => + !non_interactive_roles.has(name) && + // 'generic' is meant to have no semantic meaning. + name !== 'generic' + ) ); export function is_non_interactive_roles(role: ARIARoleDefinitionKey) { @@ -52,7 +57,10 @@ export function is_presentation_role(role: ARIARoleDefinitionKey) { return presentation_roles.has(role); } -export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Map) { +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(); @@ -204,11 +212,21 @@ export function is_static_element(tag_name: string, attribute_map: Map) { +export function is_semantic_role_element( + role: ARIARoleDefinitionKey, + tag_name: string, + attribute_map: Map +) { for (const [schema, ax_object] of elementAXObjects.entries()) { - if (schema.name === tag_name && (!schema.attributes || schema.attributes.every( - (attr) => attribute_map.has(attr.name) && attribute_map.get(attr.name).get_static_value() === attr.value - ))) { + if ( + schema.name === tag_name && + (!schema.attributes || + schema.attributes.every( + (attr) => + attribute_map.has(attr.name) && + attribute_map.get(attr.name).get_static_value() === attr.value + )) + ) { for (const name of ax_object) { const roles = AXObjectRoles.get(name); if (roles) { @@ -223,3 +241,41 @@ export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name: } return false; } + +const interactive_handlers = new Set([ + // Focus + 'focus', + 'focusin', + 'focusout', + 'blur', + + // Keyboard + 'keydown', + 'keypress', + 'keyup', + + // Mouse + 'auxclick', + 'click', + 'contextmenu', + 'dblclick', + 'drag', + 'dragend', + 'dragenter', + 'dragexit', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup' +]); + +export function is_interactive_handler(handler: string) { + return interactive_handlers.has(handler); +} 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 index 8737f04ec586..f518b9c298af 100644 --- a/test/validator/samples/a11y-click-events-have-key-events/input.svelte +++ b/test/validator/samples/a11y-click-events-have-key-events/input.svelte @@ -9,16 +9,21 @@ +
+
+
+