Skip to content

Commit b517df1

Browse files
[feat] check noninteractive roles on interactive elements (#5955)
* check noninteractive roles on interactive elements * refactor to match the eslint-plugin-jsx-a11y implementation * update test case Co-authored-by: tanhauhau <[email protected]>
1 parent 9f3625a commit b517df1

File tree

8 files changed

+1222
-6
lines changed

8 files changed

+1222
-6
lines changed

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"acorn": "^8.4.1",
133133
"agadoo": "^1.1.0",
134134
"aria-query": "^5.0.0",
135+
"axobject-query": "^3.0.1",
135136
"code-red": "^0.2.5",
136137
"css-tree": "^1.1.2",
137138
"eslint": "^8.0.0",

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,17 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t
250250

251251
---
252252

253+
### `a11y-no-interactive-element-to-noninteractive-role`
254+
255+
[WAI-ARIA](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) roles should not be used to convert an interactive element to a non-interactive element. Non-interactive ARIA roles include `article`, `banner`, `complementary`, `img`, `listitem`, `main`, `region` and `tooltip`.
256+
257+
```sv
258+
<!-- A11y: <textarea> cannot have role 'listitem' -->
259+
<textarea role="listitem" />
260+
```
261+
262+
---
263+
253264
### `a11y-positive-tabindex`
254265

255266
Avoid positive `tabindex` property values. This will move elements out of the expected tab order, creating a confusing experience for keyboard users.

src/compiler/compile/compiler_warnings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export default {
115115
code: 'a11y-no-redundant-roles',
116116
message: `A11y: Redundant role '${role}'`
117117
}),
118+
a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
119+
code: 'a11y-no-interactive-element-to-noninteractive-role',
120+
message: `A11y: <${element}> cannot have role '${role}'`
121+
}),
118122
a11y_role_has_required_aria_props: (role: string, props: string[]) => ({
119123
code: 'a11y-role-has-required-aria-props',
120124
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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ import { Literal } from 'estree';
2424
import compiler_warnings from '../compiler_warnings';
2525
import compiler_errors from '../compiler_errors';
2626
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
27+
import { is_interactive_element, is_non_interactive_roles, is_presentation_role } from '../utils/a11y';
2728

2829
const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|svg|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/;
2930

3031
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(' ');
3132
const aria_attribute_set = new Set(aria_attributes);
3233

33-
const aria_roles = 'alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic graphics-document graphics-object graphics-symbol grid gridcell group heading img link list listbox listitem log main marquee math meter menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem'.split(' ');
34+
const aria_roles = roles.keys();
3435
const aria_role_set = new Set(aria_roles);
3536
const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract));
3637

@@ -437,6 +438,11 @@ export default class Element extends Node {
437438
validate_attributes_a11y() {
438439
const { component, attributes } = this;
439440

441+
const attribute_map = new Map<string, Attribute>();
442+
attributes.forEach(attribute => (
443+
attribute_map.set(attribute.name, attribute)
444+
));
445+
440446
attributes.forEach(attribute => {
441447
if (attribute.is_spread) return;
442448

@@ -479,12 +485,11 @@ export default class Element extends Node {
479485
component.warn(attribute, compiler_warnings.a11y_misplaced_role(this.name));
480486
}
481487

482-
const value = attribute.get_static_value();
488+
const value = attribute.get_static_value() as ARIARoleDefintionKey;
483489

484-
if (value && aria_role_abstract_set.has(value as ARIARoleDefintionKey)) {
490+
if (value && aria_role_abstract_set.has(value)) {
485491
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value));
486-
} else if (value && !aria_role_set.has(value as string)) {
487-
// @ts-ignore
492+
} else if (value && !aria_role_set.has(value)) {
488493
const match = fuzzymatch(value, aria_roles);
489494
component.warn(attribute, compiler_warnings.a11y_unknown_role(value, match));
490495
}
@@ -506,7 +511,7 @@ export default class Element extends Node {
506511
}
507512

508513
// role-has-required-aria-props
509-
const role = roles.get(value as ARIARoleDefintionKey);
514+
const role = roles.get(value);
510515
if (role) {
511516
const required_role_props = Object.keys(role.requiredProps);
512517
const has_missing_props = required_role_props.some(prop => !attributes.find(a => a.name === prop));
@@ -515,6 +520,11 @@ export default class Element extends Node {
515520
component.warn(attribute, compiler_warnings.a11y_role_has_required_aria_props(value as string, required_role_props));
516521
}
517522
}
523+
524+
// no-interactive-element-to-noninteractive-role
525+
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(value) || is_presentation_role(value))) {
526+
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(value, this.name));
527+
}
518528
}
519529

520530
// no-access-key

src/compiler/compile/utils/a11y.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
ARIARoleDefintionKey,
3+
roles as roles_map,
4+
elementRoles,
5+
ARIARoleRelationConcept
6+
} from 'aria-query';
7+
import { AXObjects, elementAXObjects } from 'axobject-query';
8+
import Attribute from '../nodes/Attribute';
9+
10+
const roles = [...roles_map.keys()];
11+
12+
const non_interactive_roles = new Set(
13+
roles
14+
.filter((name) => {
15+
const role = roles_map.get(name);
16+
return (
17+
!roles_map.get(name).abstract &&
18+
// 'toolbar' does not descend from widget, but it does support
19+
// aria-activedescendant, thus in practice we treat it as a widget.
20+
name !== 'toolbar' &&
21+
!role.superClass.some((classes) => classes.includes('widget'))
22+
);
23+
})
24+
.concat(
25+
// The `progressbar` is descended from `widget`, but in practice, its
26+
// value is always `readonly`, so we treat it as a non-interactive role.
27+
'progressbar'
28+
)
29+
);
30+
31+
const interactive_roles = new Set(
32+
roles
33+
.filter((name) => {
34+
const role = roles_map.get(name);
35+
return (
36+
!role.abstract &&
37+
// The `progressbar` is descended from `widget`, but in practice, its
38+
// value is always `readonly`, so we treat it as a non-interactive role.
39+
name !== 'progressbar' &&
40+
role.superClass.some((classes) => classes.includes('widget'))
41+
);
42+
})
43+
.concat(
44+
// 'toolbar' does not descend from widget, but it does support
45+
// aria-activedescendant, thus in practice we treat it as a widget.
46+
'toolbar'
47+
)
48+
);
49+
50+
export function is_non_interactive_roles(role: ARIARoleDefintionKey) {
51+
return non_interactive_roles.has(role);
52+
}
53+
54+
const presentation_roles = new Set(['presentation', 'none']);
55+
56+
export function is_presentation_role(role: ARIARoleDefintionKey) {
57+
return presentation_roles.has(role);
58+
}
59+
60+
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
61+
62+
elementRoles.entries().forEach(([schema, roles]) => {
63+
if ([...roles].every((role) => non_interactive_roles.has(role))) {
64+
non_interactive_element_role_schemas.push(schema);
65+
}
66+
});
67+
68+
const interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
69+
70+
elementRoles.entries().forEach(([schema, roles]) => {
71+
if ([...roles].every((role) => interactive_roles.has(role))) {
72+
interactive_element_role_schemas.push(schema);
73+
}
74+
});
75+
76+
const interactive_ax_objects = new Set(
77+
[...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget')
78+
);
79+
80+
const interactive_element_ax_object_schemas: ARIARoleRelationConcept[] = [];
81+
82+
elementAXObjects.entries().forEach(([schema, ax_object]) => {
83+
if ([...ax_object].every((role) => interactive_ax_objects.has(role))) {
84+
interactive_element_ax_object_schemas.push(schema);
85+
}
86+
});
87+
88+
function match_schema(
89+
schema: ARIARoleRelationConcept,
90+
tag_name: string,
91+
attribute_map: Map<string, Attribute>
92+
) {
93+
if (schema.name !== tag_name) return false;
94+
if (!schema.attributes) return true;
95+
return schema.attributes.every((schema_attribute) => {
96+
const attribute = attribute_map.get(schema_attribute.name);
97+
if (!attribute) return false;
98+
if (
99+
schema_attribute.value &&
100+
schema_attribute.value !== attribute.get_static_value()
101+
) {
102+
return false;
103+
}
104+
return true;
105+
});
106+
}
107+
108+
export function is_interactive_element(
109+
tag_name: string,
110+
attribute_map: Map<string, Attribute>
111+
): boolean {
112+
if (
113+
interactive_element_role_schemas.some((schema) =>
114+
match_schema(schema, tag_name, attribute_map)
115+
)
116+
) {
117+
return true;
118+
}
119+
120+
if (
121+
non_interactive_element_role_schemas.some((schema) =>
122+
match_schema(schema, tag_name, attribute_map)
123+
)
124+
) {
125+
return false;
126+
}
127+
128+
if (
129+
interactive_element_ax_object_schemas.some((schema) =>
130+
match_schema(schema, tag_name, attribute_map)
131+
)
132+
) {
133+
return true;
134+
}
135+
136+
return false;
137+
}

0 commit comments

Comments
 (0)