diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a851bc24c2..e9fc80cabe 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 2410904d63..3ab6a22515 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 4409f80262..fbc4327468 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 8737f04ec5..f518b9c298 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 @@ +
+
+
+