diff --git a/site/content/docs/06-accessibility-warnings.md b/site/content/docs/06-accessibility-warnings.md index 4324ed7b75e..bc793d80e7f 100644 --- a/site/content/docs/06-accessibility-warnings.md +++ b/site/content/docs/06-accessibility-warnings.md @@ -277,6 +277,17 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t --- +### `a11y-no-noninteractive-element-to-interactive-role` + +[WAI-ARIA](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) roles should not be used to convert a non-interactive element to an interactive element. Interactive ARIA roles include `button`, `link`, `checkbox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `searchbox`, `switch` and `textbox`. + +```sv + +

Button

+``` + +--- + ### `a11y-no-noninteractive-tabindex` Tab key navigation should be limited to elements on the page that can be interacted with. diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index d6b2dbc5aa7..380ed5a6f09 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -119,6 +119,10 @@ export default { code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` }), + a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({ + code: 'a11y-no-noninteractive-element-to-interactive-role', + message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'` + }), a11y_role_has_required_aria_props: (role: string, props: string[]) => ({ code: 'a11y-role-has-required-aria-props', message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}` diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 0fdea77c166..a5dc74b256e 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -24,14 +24,13 @@ 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_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element } from '../utils/a11y'; +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 } 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); const aria_roles = roles.keys(); const aria_role_set = new Set(aria_roles); -const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract)); const a11y_required_attributes = { a: ['href'], @@ -567,7 +566,7 @@ export default class Element extends Node { if (typeof value === 'string') { value.split(regex_any_repeated_whitespaces).forEach((current_role: ARIARoleDefinitionKey) => { - if (current_role && aria_role_abstract_set.has(current_role)) { + if (current_role && is_abstract_role(current_role)) { component.warn(attribute, compiler_warnings.a11y_no_abstract_role(current_role)); } else if (current_role && !aria_role_set.has(current_role)) { const match = fuzzymatch(current_role, aria_roles); @@ -607,8 +606,12 @@ export default class Element extends Node { if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) { component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name)); } - }); + // no-noninteractive-element-to-interactive-role + if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role)) { + component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name)); + } + }); } } diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 60cb31c6638..d0564b419e0 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -7,7 +7,9 @@ import { import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query'; import Attribute from '../nodes/Attribute'; -const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract); +const aria_roles = roles_map.keys(); +const abstract_roles = new Set(aria_roles.filter(role => roles_map.get(role).abstract)); +const non_abstract_roles = aria_roles.filter((name) => !abstract_roles.has(name)); const non_interactive_roles = new Set( non_abstract_roles @@ -40,6 +42,10 @@ export function is_interactive_roles(role: ARIARoleDefinitionKey) { return interactive_roles.has(role); } +export function is_abstract_role(role: ARIARoleDefinitionKey) { + return abstract_roles.has(role); +} + const presentation_roles = new Set(['presentation', 'none']); export function is_presentation_role(role: ARIARoleDefinitionKey) { @@ -65,7 +71,7 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = []; elementRoles.entries().forEach(([schema, roles]) => { - if ([...roles].every((role) => non_interactive_roles.has(role))) { + if ([...roles].every((role) => role !== 'generic' && non_interactive_roles.has(role))) { non_interactive_element_role_schemas.push(schema); } }); @@ -82,6 +88,10 @@ const interactive_ax_objects = new Set( [...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget') ); +const non_interactive_ax_objects = new Set( + [...AXObjects.keys()].filter((name) => ['windows', 'structure'].includes(AXObjects.get(name).type)) +); + const interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = []; elementAXObjects.entries().forEach(([schema, ax_object]) => { @@ -90,6 +100,14 @@ elementAXObjects.entries().forEach(([schema, ax_object]) => { } }); +const non_interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = []; + +elementAXObjects.entries().forEach(([schema, ax_object]) => { + if ([...ax_object].every((role) => non_interactive_ax_objects.has(role))) { + non_interactive_element_ax_object_schemas.push(schema); + } +}); + function match_schema( schema: ARIARoleRelationConcept, tag_name: string, @@ -110,24 +128,31 @@ function match_schema( }); } -export function is_interactive_element( +export enum ElementInteractivity { + Interactive = 'interactive', + NonInteractive = 'non-interactive', + Static = 'static', +} + +export function element_interactivity( tag_name: string, attribute_map: Map -): boolean { +): ElementInteractivity { if ( interactive_element_role_schemas.some((schema) => match_schema(schema, tag_name, attribute_map) ) ) { - return true; + return ElementInteractivity.Interactive; } if ( + tag_name !== 'header' && non_interactive_element_role_schemas.some((schema) => match_schema(schema, tag_name, attribute_map) ) ) { - return false; + return ElementInteractivity.NonInteractive; } if ( @@ -135,10 +160,30 @@ export function is_interactive_element( match_schema(schema, tag_name, attribute_map) ) ) { - return true; + return ElementInteractivity.Interactive; } - return false; + if ( + non_interactive_element_ax_object_schemas.some((schema) => + match_schema(schema, tag_name, attribute_map) + ) + ) { + return ElementInteractivity.NonInteractive; + } + + return ElementInteractivity.Static; +} + +export function is_interactive_element(tag_name: string, attribute_map: Map): boolean { + return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive; +} + +export function is_non_interactive_element(tag_name: string, attribute_map: Map): boolean { + return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive; +} + +export function is_static_element(tag_name: string, attribute_map: Map): boolean { + return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static; } export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name: string, attribute_map: Map) { diff --git a/test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/input.svelte b/test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/input.svelte index 80b4fd94102..5c9bfd86ed9 100644 --- a/test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/input.svelte +++ b/test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/input.svelte @@ -71,36 +71,34 @@
- -
-x -
-
-
- -
-
-
-
- -

title

-

title

-

title

-

title

-
title
-
title
-
-x -
  • -
  • -