Skip to content

Commit 26c38e7

Browse files
authored
feat: add a11y no-noninteractive-element-to-interactive-role (#8167)
Part of #820
1 parent 127b61a commit 26c38e7

File tree

8 files changed

+1043
-55
lines changed

8 files changed

+1043
-55
lines changed

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

+11
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,17 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t
277277

278278
---
279279

280+
### `a11y-no-noninteractive-element-to-interactive-role`
281+
282+
[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`.
283+
284+
```sv
285+
<!-- A11y: Non-interactive element <h3> cannot have interactive role 'searchbox' -->
286+
<h3 role="searchbox">Button</h3>
287+
```
288+
289+
---
290+
280291
### `a11y-no-noninteractive-tabindex`
281292

282293
Tab key navigation should be limited to elements on the page that can be interacted with.

src/compiler/compile/compiler_warnings.ts

+4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ export default {
119119
code: 'a11y-no-interactive-element-to-noninteractive-role',
120120
message: `A11y: <${element}> cannot have role '${role}'`
121121
}),
122+
a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({
123+
code: 'a11y-no-noninteractive-element-to-interactive-role',
124+
message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'`
125+
}),
122126
a11y_role_has_required_aria_props: (role: string, props: string[]) => ({
123127
code: 'a11y-role-has-required-aria-props',
124128
message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}`

src/compiler/compile/nodes/Element.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,13 @@ import { Literal } from 'estree';
2424
import compiler_warnings from '../compiler_warnings';
2525
import compiler_errors from '../compiler_errors';
2626
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
27-
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';
27+
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';
2828

2929
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(' ');
3030
const aria_attribute_set = new Set(aria_attributes);
3131

3232
const aria_roles = roles.keys();
3333
const aria_role_set = new Set(aria_roles);
34-
const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract));
3534

3635
const a11y_required_attributes = {
3736
a: ['href'],
@@ -567,7 +566,7 @@ export default class Element extends Node {
567566

568567
if (typeof value === 'string') {
569568
value.split(regex_any_repeated_whitespaces).forEach((current_role: ARIARoleDefinitionKey) => {
570-
if (current_role && aria_role_abstract_set.has(current_role)) {
569+
if (current_role && is_abstract_role(current_role)) {
571570
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(current_role));
572571
} else if (current_role && !aria_role_set.has(current_role)) {
573572
const match = fuzzymatch(current_role, aria_roles);
@@ -607,8 +606,12 @@ export default class Element extends Node {
607606
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) {
608607
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name));
609608
}
610-
});
611609

610+
// no-noninteractive-element-to-interactive-role
611+
if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role)) {
612+
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name));
613+
}
614+
});
612615
}
613616
}
614617

src/compiler/compile/utils/a11y.ts

+53-8
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
88
import Attribute from '../nodes/Attribute';
99

10-
const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract);
10+
const aria_roles = roles_map.keys();
11+
const abstract_roles = new Set(aria_roles.filter(role => roles_map.get(role).abstract));
12+
const non_abstract_roles = aria_roles.filter((name) => !abstract_roles.has(name));
1113

1214
const non_interactive_roles = new Set(
1315
non_abstract_roles
@@ -40,6 +42,10 @@ export function is_interactive_roles(role: ARIARoleDefinitionKey) {
4042
return interactive_roles.has(role);
4143
}
4244

45+
export function is_abstract_role(role: ARIARoleDefinitionKey) {
46+
return abstract_roles.has(role);
47+
}
48+
4349
const presentation_roles = new Set(['presentation', 'none']);
4450

4551
export function is_presentation_role(role: ARIARoleDefinitionKey) {
@@ -65,7 +71,7 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma
6571
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
6672

6773
elementRoles.entries().forEach(([schema, roles]) => {
68-
if ([...roles].every((role) => non_interactive_roles.has(role))) {
74+
if ([...roles].every((role) => role !== 'generic' && non_interactive_roles.has(role))) {
6975
non_interactive_element_role_schemas.push(schema);
7076
}
7177
});
@@ -82,6 +88,10 @@ const interactive_ax_objects = new Set(
8288
[...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget')
8389
);
8490

91+
const non_interactive_ax_objects = new Set(
92+
[...AXObjects.keys()].filter((name) => ['windows', 'structure'].includes(AXObjects.get(name).type))
93+
);
94+
8595
const interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = [];
8696

8797
elementAXObjects.entries().forEach(([schema, ax_object]) => {
@@ -90,6 +100,14 @@ elementAXObjects.entries().forEach(([schema, ax_object]) => {
90100
}
91101
});
92102

103+
const non_interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = [];
104+
105+
elementAXObjects.entries().forEach(([schema, ax_object]) => {
106+
if ([...ax_object].every((role) => non_interactive_ax_objects.has(role))) {
107+
non_interactive_element_ax_object_schemas.push(schema);
108+
}
109+
});
110+
93111
function match_schema(
94112
schema: ARIARoleRelationConcept,
95113
tag_name: string,
@@ -110,35 +128,62 @@ function match_schema(
110128
});
111129
}
112130

113-
export function is_interactive_element(
131+
export enum ElementInteractivity {
132+
Interactive = 'interactive',
133+
NonInteractive = 'non-interactive',
134+
Static = 'static',
135+
}
136+
137+
export function element_interactivity(
114138
tag_name: string,
115139
attribute_map: Map<string, Attribute>
116-
): boolean {
140+
): ElementInteractivity {
117141
if (
118142
interactive_element_role_schemas.some((schema) =>
119143
match_schema(schema, tag_name, attribute_map)
120144
)
121145
) {
122-
return true;
146+
return ElementInteractivity.Interactive;
123147
}
124148

125149
if (
150+
tag_name !== 'header' &&
126151
non_interactive_element_role_schemas.some((schema) =>
127152
match_schema(schema, tag_name, attribute_map)
128153
)
129154
) {
130-
return false;
155+
return ElementInteractivity.NonInteractive;
131156
}
132157

133158
if (
134159
interactive_element_ax_object_schemas.some((schema) =>
135160
match_schema(schema, tag_name, attribute_map)
136161
)
137162
) {
138-
return true;
163+
return ElementInteractivity.Interactive;
139164
}
140165

141-
return false;
166+
if (
167+
non_interactive_element_ax_object_schemas.some((schema) =>
168+
match_schema(schema, tag_name, attribute_map)
169+
)
170+
) {
171+
return ElementInteractivity.NonInteractive;
172+
}
173+
174+
return ElementInteractivity.Static;
175+
}
176+
177+
export function is_interactive_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
178+
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Interactive;
179+
}
180+
181+
export function is_non_interactive_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
182+
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.NonInteractive;
183+
}
184+
185+
export function is_static_element(tag_name: string, attribute_map: Map<string, Attribute>): boolean {
186+
return element_interactivity(tag_name, attribute_map) === ElementInteractivity.Static;
142187
}
143188

144189
export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name: string, attribute_map: Map<string, Attribute>) {

test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/input.svelte

+28-30
Original file line numberDiff line numberDiff line change
@@ -71,36 +71,34 @@
7171
<div role="widget" />
7272
<div role="window" />
7373

74-
<!-- HTML elements with an inherent, non-interactive role, assigned an interactive role. -->
75-
<main role="button" />
76-
<area role="button" alt="x" />
77-
<article role="button" />
78-
<article role="button" />
79-
<dd role="button" />
80-
<dfn role="button" />
81-
<dt role="button" />
82-
<fieldset role="button" />
83-
<figure role="button" />
84-
<form role="button" />
85-
<frame role="button" />
86-
<h1 role="button">title</h1>
87-
<h2 role="button">title</h2>
88-
<h3 role="button">title</h3>
89-
<h4 role="button">title</h4>
90-
<h5 role="button">title</h5>
91-
<h6 role="button">title</h6>
92-
<hr role="button" />
93-
<img role="button" alt="x" />
94-
<li role="button" />
95-
<li role="presentation" />
96-
<nav role="button" />
97-
<ol role="button" />
98-
<table role="button" />
99-
<tbody role="button" />
100-
<td role="button" />
101-
<tfoot role="button" />
102-
<thead role="button" />
103-
<ul role="button" />
74+
<!-- VALID: div elements assigned an interactive role. -->
75+
<div role="button" />
76+
<div role="checkbox" aria-checked={true} />
77+
<div role="columnheader" />
78+
<div role="combobox" aria-controls={[]} aria-expanded={true} />
79+
<div role="grid" />
80+
<div role="gridcell" />
81+
<div role="link" />
82+
<div role="listbox" />
83+
<div role="menu" />
84+
<div role="menubar" />
85+
<div role="menuitem" />
86+
<div role="menuitemcheckbox" aria-checked />
87+
<div role="menuitemradio" aria-checked />
88+
<div role="option" aria-selected />
89+
<div role="progressbar" />
90+
<div role="radio" aria-checked />
91+
<div role="radiogroup" />
92+
<div role="row" />
93+
<div role="rowheader" />
94+
<div role="scrollbar" aria-controls={[]} aria-valuenow={0} />
95+
<div role="searchbox" />
96+
<div role="slider" aria-valuenow={0} />
97+
<div role="spinbutton" />
98+
<div role="switch" aria-checked />
99+
<div role="tab" />
100+
<div role="textbox" />
101+
<div role="treeitem" aria-selected={true} />
104102

105103
<!-- HTML elements attributed with a non-interactive role -->
106104
<div role="alert" />

test/validator/samples/a11y-no-interactive-element-to-noninteractive-role/warnings.json

+13-13
Original file line numberDiff line numberDiff line change
@@ -635,76 +635,76 @@
635635
"line": 72
636636
}
637637
},
638-
{
638+
{
639639
"code": "a11y-no-interactive-element-to-noninteractive-role",
640640
"end": {
641641
"column": 28,
642-
"line": 145
642+
"line": 143
643643
},
644644
"message": "A11y: <menuitem> cannot have role 'listitem'",
645645
"start": {
646646
"column": 0,
647-
"line": 145
647+
"line": 143
648648
}
649649
},
650650
{
651651
"code": "a11y-no-interactive-element-to-noninteractive-role",
652652
"end": {
653653
"column": 38,
654-
"line": 146
654+
"line": 144
655655
},
656656
"message": "A11y: <option> cannot have role 'listitem'",
657657
"start": {
658658
"column": 0,
659-
"line": 146
659+
"line": 144
660660
}
661661
},
662662
{
663663
"code": "a11y-no-interactive-element-to-noninteractive-role",
664664
"end": {
665665
"column": 38,
666-
"line": 147
666+
"line": 145
667667
},
668668
"message": "A11y: <select> cannot have role 'listitem'",
669669
"start": {
670670
"column": 0,
671-
"line": 147
671+
"line": 145
672672
}
673673
},
674674
{
675675
"code": "a11y-no-interactive-element-to-noninteractive-role",
676676
"end": {
677677
"column": 27,
678-
"line": 148
678+
"line": 146
679679
},
680680
"message": "A11y: <summary> cannot have role 'listitem'",
681681
"start": {
682682
"column": 0,
683-
"line": 148
683+
"line": 146
684684
}
685685
},
686686
{
687687
"code": "a11y-no-interactive-element-to-noninteractive-role",
688688
"end": {
689689
"column": 40,
690-
"line": 149
690+
"line": 147
691691
},
692692
"message": "A11y: <textarea> cannot have role 'listitem'",
693693
"start": {
694694
"column": 0,
695-
"line": 149
695+
"line": 147
696696
}
697697
},
698698
{
699699
"code": "a11y-no-interactive-element-to-noninteractive-role",
700700
"end": {
701701
"column": 22,
702-
"line": 150
702+
"line": 148
703703
},
704704
"message": "A11y: <tr> cannot have role 'listitem'",
705705
"start": {
706706
"column": 0,
707-
"line": 150
707+
"line": 148
708708
}
709709
}
710710
]

0 commit comments

Comments
 (0)