Skip to content

Commit

Permalink
feat: add a11y no-noninteractive-element-interactions (#8391)
Browse files Browse the repository at this point in the history
  • Loading branch information
ngtr6788 authored and dummdidumm committed Apr 18, 2023
1 parent 1728a89 commit 68bf3e8
Show file tree
Hide file tree
Showing 10 changed files with 146 additions and 20 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions site/content/docs/06-accessibility-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<main>`, `<area>`, `<h1>` (,`<h2>`, etc), `<p>`, `<img>`, `<li>`, `<ul>` and `<ol>`. 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
<!-- `A11y: Non-interactive element <li> should not be assigned mouse or keyboard event listeners.` -->
<li on:click={() => {}} />
<!-- `A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.` -->
<div role="listitem" on:click={() => {}} />
```

---

### `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`.
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/compile/compiler_warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`
Expand Down
41 changes: 33 additions & 8 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -738,18 +747,19 @@ 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);
}
}

// 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)));
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

<!-- svelte-ignore a11y-no-static-element-interactions -->
<section on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<main on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<article on:click={noop} />
<!-- svelte-ignore a11y-no-static-element-interactions -->
<header on:click={noop} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:click={noop} />

<!-- should not warn -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,47 +39,47 @@
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 19,
"line": 20,
"column": 0
},
"end": {
"line": 19,
"line": 20,
"column": 24
}
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 20,
"line": 22,
"column": 0
},
"end": {
"line": 20,
"line": 22,
"column": 27
}
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 22,
"line": 24,
"column": 0
},
"end": {
"line": 22,
"line": 24,
"column": 26
}
},
{
"code": "a11y-click-events-have-key-events",
"message": "A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.",
"start": {
"line": 23,
"line": 26,
"column": 0
},
"end": {
"line": 23,
"line": 26,
"column": 26
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- VALID -->
<div role="presentation" on:mouseup={() => {}} />
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}} />
<div role="listitem" aria-hidden on:click={() => {}} on:keypress={() => {}} />
<button on:click={() => {}} />
<h1 contenteditable="true" on:keydown={() => {}}>Heading</h1>
<h1>Heading</h1>

<!-- INVALID -->
<div role="listitem" on:mousedown={() => {}} />
<h1 on:click={() => {}} on:keydown={() => {}}>Heading</h1>
<h1 role="banner" on:keyup={() => {}}>Heading</h1>
<p on:keypress={() => {}} />
<div role="paragraph" on:mouseup={() => {}} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
[
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 47,
"line": 10
},
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 10
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 58,
"line": 11
},
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 11
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 50,
"line": 12
},
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 12
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 28,
"line": 13
},
"message": "A11y: Non-interactive element <p> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 13
}
},
{
"code": "a11y-no-noninteractive-element-interactions",
"end": {
"column": 46,
"line": 14
},
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": {
"column": 0,
"line": 14
}
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<div on:copy={() => {}} />
<a href="/foo" on:click={() => {}}>link</a>
<div role={dynamicRole} on:click={() => {}} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:keydown={() => {}} />

<!-- invalid -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@
"code": "a11y-no-static-element-interactions",
"end": {
"column": 29,
"line": 16
"line": 17
},
"message": "A11y: <div> with keydown handler must have an ARIA role",
"start": {
"column": 0,
"line": 16
"line": 17
}
},
{
"code": "a11y-no-static-element-interactions",
"end": {
"column": 76,
"line": 18
"line": 19
},
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
"start": {
"column": 0,
"line": 18
"line": 19
}
}
]

0 comments on commit 68bf3e8

Please sign in to comment.