From 94606169905b9764789f127917aa8fc23cf61a96 Mon Sep 17 00:00:00 2001 From: Tim McCabe Date: Fri, 14 Apr 2023 08:51:04 -0400 Subject: [PATCH] feat: add `a11y-no-static-element-interactions` compiler rule (#8251) Ref: #820 --- src/compiler/compile/compiler_warnings.ts | 4 +++ src/compiler/compile/nodes/Element.ts | 31 +++++++++++++++++-- src/compiler/compile/utils/a11y.ts | 9 ++++-- .../input.svelte | 17 ++++++++++ .../warnings.json | 28 ++++++++--------- .../input.svelte | 6 ++++ .../warnings.json | 16 +++++----- .../input.svelte | 18 +++++++++++ .../warnings.json | 26 ++++++++++++++++ .../samples/slot-warning-ignore/input.svelte | 1 + .../samples/slot-warning/input.svelte | 1 + .../samples/slot-warning/warnings.json | 4 +-- .../samples/slot-warning2/input.svelte | 1 + .../samples/slot-warning2/warnings.json | 4 +-- 14 files changed, 135 insertions(+), 31 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..3e6d886a5d35 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -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,30 @@ export default class Element extends Node { } }); } + + const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static; + + // no-static-element-interactions + if ( + !has_dynamic_role && + !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((handlerName) => a11y_interactive_handlers.has(handlerName)); + 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..bc23f5c81866 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) { 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..c6ac9ac86616 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,12 +9,16 @@ +
+
+
+