Skip to content

Commit fed93ab

Browse files
authored
feat: add a11y interactive-supports-focus (#8392)
#820
1 parent 7e9e78b commit fed93ab

File tree

6 files changed

+386
-1
lines changed

6 files changed

+386
-1
lines changed

site/content/docs/06-accessibility-warnings.md

+11
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ Enforce that attributes important for accessibility have a valid value. For exam
137137

138138
---
139139

140+
### `a11y-interactive-supports-focus`
141+
142+
Enforce that elements with an interactive role and interactive handlers (mouse or key press) must be focusable or tabbable.
143+
144+
```sv
145+
<!-- A11y: Elements with the 'button' interactive role must have a tabindex value. -->
146+
<div role="button" on:keypress={() => {}} />
147+
```
148+
149+
---
150+
140151
### `a11y-label-has-associated-control`
141152

142153
Enforce that a label tag has a text label and an associated control.

src/compiler/compile/compiler_warnings.ts

+4
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export default {
166166
code: 'a11y-img-redundant-alt',
167167
message: 'A11y: Screenreaders already announce <img> elements as an image.'
168168
},
169+
a11y_interactive_supports_focus: (role: string) => ({
170+
code: 'a11y-interactive-supports-focus',
171+
message: `A11y: Elements with the '${role}' interactive role must have a tabindex value.`
172+
}),
169173
a11y_label_has_associated_control: {
170174
code: 'a11y-label-has-associated-control',
171175
message: 'A11y: A form label must be associated with a control.'

src/compiler/compile/nodes/Element.ts

+43-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { Literal } from 'estree';
2525
import compiler_warnings from '../compiler_warnings';
2626
import compiler_errors from '../compiler_errors';
2727
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
28-
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';
28+
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';
2929

3030
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(' ');
3131
const aria_attribute_set = new Set(aria_attributes);
@@ -75,6 +75,33 @@ const a11y_labelable = new Set([
7575
'textarea'
7676
]);
7777

78+
const a11y_interactive_handlers = new Set([
79+
// Keyboard events
80+
'keypress',
81+
'keydown',
82+
'keyup',
83+
84+
// Click events
85+
'click',
86+
'contextmenu',
87+
'dblclick',
88+
'drag',
89+
'dragend',
90+
'dragenter',
91+
'dragexit',
92+
'dragleave',
93+
'dragover',
94+
'dragstart',
95+
'drop',
96+
'mousedown',
97+
'mouseenter',
98+
'mouseleave',
99+
'mousemove',
100+
'mouseout',
101+
'mouseover',
102+
'mouseup'
103+
]);
104+
78105
const a11y_nested_implicit_semantics = new Map([
79106
['header', 'banner'],
80107
['footer', 'contentinfo']
@@ -603,6 +630,21 @@ export default class Element extends Node {
603630
}
604631
}
605632

633+
// interactive-supports-focus
634+
if (
635+
!has_disabled_attribute(attribute_map) &&
636+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
637+
!is_presentation_role(current_role) &&
638+
is_interactive_roles(current_role) &&
639+
is_static_element(this.name, attribute_map) &&
640+
!attribute_map.get('tabindex')
641+
) {
642+
const has_interactive_handlers = handlers.some((handler) => a11y_interactive_handlers.has(handler.name));
643+
if (has_interactive_handlers) {
644+
component.warn(this, compiler_warnings.a11y_interactive_supports_focus(current_role));
645+
}
646+
}
647+
606648
// no-interactive-element-to-noninteractive-role
607649
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) {
608650
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name));

src/compiler/compile/utils/a11y.ts

+18
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma
6868
return aria_hidden_value === true || aria_hidden_value === 'true';
6969
}
7070

71+
export function has_disabled_attribute(attribute_map: Map<string, Attribute>) {
72+
const disabled_attr = attribute_map.get('disabled');
73+
const disabled_attr_value = disabled_attr && disabled_attr.get_static_value();
74+
if (disabled_attr_value) {
75+
return true;
76+
}
77+
78+
const aria_disabled_attr = attribute_map.get('aria-disabled');
79+
if (aria_disabled_attr) {
80+
const aria_disabled_attr_value = aria_disabled_attr.get_static_value();
81+
if (aria_disabled_attr_value === true) {
82+
return true;
83+
}
84+
}
85+
86+
return false;
87+
}
88+
7189
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
7290

7391
elementRoles.entries().forEach(([schema, roles]) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!-- VALID -->
2+
<div aria-hidden role="button" on:keypress={() => {}} />
3+
<div aria-disabled role="button" on:keypress={() => {}} />
4+
<div disabled role="button" on:keypress={() => {}} />
5+
<div role="presentation" on:keypress={() => {}} />
6+
<button on:click={() => {}} />
7+
<div role="menuitem" tabindex="0" on:click={() => {}} on:keypress={() => {}} />
8+
<div role="button" tabindex="-1" on:click={() => {}} on:keypress={() => {}} />
9+
10+
<!-- INVALID -->
11+
<div role="button" on:keypress={() => {}} />
12+
<span role="menuitem" on:keydown={() => {}} />
13+
<div role="button" on:keyup={() => {}} />
14+
<span role="menuitem" on:click={() => {}} on:keypress={() => {}} />
15+
<div role="button" on:contextmenu={() => {}} />
16+
<span role="menuitem" on:dblclick={() => {}} />
17+
<div role="button" on:drag={() => {}} />
18+
<span role="menuitem" on:dragend={() => {}} />
19+
<div role="button" on:dragenter={() => {}} />
20+
<span role="menuitem" on:dragexit={() => {}} />
21+
<div role="button" on:dragleave={() => {}} />
22+
<span role="menuitem" on:dragover={() => {}} />
23+
<div role="button" on:dragstart={() => {}} />
24+
<span role="menuitem" on:drop={() => {}} />
25+
<div role="button" on:mousedown={() => {}} />
26+
<span role="menuitem" on:mouseenter={() => {}} />
27+
<div role="button" on:mouseleave={() => {}} />
28+
<span role="menuitem" on:mousemove={() => {}} />
29+
<div role="button" on:mouseout={() => {}} />
30+
<span role="menuitem" on:mouseover={() => {}} />
31+
<div role="button" on:mouseup={() => {}} />
32+

0 commit comments

Comments
 (0)