From 68bf3e8143af1108b614a3c7903a1cc20173e957 Mon Sep 17 00:00:00 2001 From: Nguyen Tran <88808276+ngtr6788@users.noreply.github.com> Date: Fri, 14 Apr 2023 11:20:40 -0400 Subject: [PATCH] feat: add a11y `no-noninteractive-element-interactions` (#8391) #820 --- CHANGELOG.md | 3 + .../content/docs/06-accessibility-warnings.md | 14 +++++ src/compiler/compile/compiler_warnings.ts | 4 ++ src/compiler/compile/nodes/Element.ts | 41 +++++++++--- .../input.svelte | 3 + .../warnings.json | 16 ++--- .../input.svelte | 14 +++++ .../warnings.json | 62 +++++++++++++++++++ .../input.svelte | 1 + .../warnings.json | 8 +-- 10 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 test/validator/samples/a11y-no-noninteractive-element-interactions/input.svelte create mode 100644 test/validator/samples/a11y-no-noninteractive-element-interactions/warnings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e28643f1f02c..fb855b299638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ * **breaking** Minimum supported TypeScript version is now 5 (it will likely work with lower versions, but we make no guarantess about that) * **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224)) * **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224)) +* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391)) +* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251)) +* Bind `null` option and input values consistently ([#8312](https://github.com/sveltejs/svelte/issues/8312)) ## Unreleased (3.0) diff --git a/site/content/docs/06-accessibility-warnings.md b/site/content/docs/06-accessibility-warnings.md index 0d025d797def..934cf638e6b6 100644 --- a/site/content/docs/06-accessibility-warnings.md +++ b/site/content/docs/06-accessibility-warnings.md @@ -288,6 +288,20 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t --- +### `a11y-no-noninteractive-element-interactions` + +A non-interactive element does not support event handlers (mouse and key handlers). Non-interactive elements include `
`, ``, `

` (,`

`, etc), `

`, ``, `

  • `, `
      ` and `
        `. Non-interactive [WAI-ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) include `article`, `banner`, `complementary`, `img`, `listitem`, `main`, `region` and `tooltip`. + +```sv + +
      1. {}} /> + + +
        {}} /> +``` + +--- + ### `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`. diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index e9fc80cabef4..2138e81213ae 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -123,6 +123,10 @@ export default { code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` }), + a11y_no_noninteractive_element_interactions: (element: string) => ({ + code: 'a11y-no-noninteractive-element-interactions', + message: `A11y: Non-interactive element <${element}> should not be assigned mouse or keyboard event listeners.` + }), 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}'` diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 3e6d886a5d35..ee62d3e7e8be 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -11,7 +11,7 @@ import StyleDirective from './StyleDirective'; import Text from './Text'; import { namespaces } from '../../utils/namespaces'; import map_children from './shared/map_children'; -import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable'; +import { is_name_contenteditable, get_contenteditable_attr, has_contenteditable_attr } from '../utils/contenteditable'; import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns'; import fuzzymatch from '../../utils/fuzzymatch'; import list from '../../utils/list'; @@ -102,6 +102,15 @@ const a11y_interactive_handlers = new Set([ 'mouseup' ]); +const a11y_recommended_interactive_handlers = new Set([ + 'click', + 'mousedown', + 'mouseup', + 'keypress', + 'keydown', + 'keyup' +]); + const a11y_nested_implicit_semantics = new Map([ ['header', 'banner'], ['footer', 'contentinfo'] @@ -738,10 +747,12 @@ export default class Element extends Node { } } - const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey; + const role = attribute_map.get('role'); + const role_static_value = role?.get_static_value() as ARIARoleDefinitionKey; + const role_value = (role ? role_static_value : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; // no-noninteractive-tabindex - if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) { + if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role_static_value)) { 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); @@ -749,7 +760,6 @@ export default class Element extends Node { } // role-supports-aria-props - 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))); @@ -764,18 +774,33 @@ export default class Element extends Node { }); } + // no-noninteractive-element-interactions + if ( + !has_contenteditable_attr(this) && + !is_hidden_from_screen_reader(this.name, attribute_map) && + !is_presentation_role(role_static_value) && + ((!is_interactive_element(this.name, attribute_map) && + is_non_interactive_roles(role_static_value)) || + (is_non_interactive_element(this.name, attribute_map) && !role)) + ) { + const has_interactive_handlers = handlers.some((handler) => a11y_recommended_interactive_handlers.has(handler.name)); + if (has_interactive_handlers) { + component.warn(this, compiler_warnings.a11y_no_noninteractive_element_interactions(this.name)); + } + } + 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_presentation_role(role_static_value) && !is_interactive_element(this.name, attribute_map) && - !is_interactive_roles(role) && + !is_interactive_roles(role_static_value) && !is_non_interactive_element(this.name, attribute_map) && - !is_non_interactive_roles(role) && - !is_abstract_role(role) + !is_non_interactive_roles(role_static_value) && + !is_abstract_role(role_static_value) ) { const interactive_handlers = handlers .map((handler) => handler.name) 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 c6ac9ac86616..3fb1ded53d97 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 @@ -16,10 +16,13 @@
        +
        +
        +