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 @@ +
+
+
+