Skip to content

Commit 5bdc92e

Browse files
committed
refactor to match the eslint-plugin-jsx-a11y implementation
1 parent 751d927 commit 5bdc92e

File tree

10 files changed

+986
-633
lines changed

10 files changed

+986
-633
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
@@ -111,6 +111,10 @@ export default {
111111
code: 'a11y-no-redundant-roles',
112112
message: `A11y: Redundant role '${role}'`
113113
}),
114+
a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
115+
code: 'a11y-no-interactive-element-to-noninteractive-role',
116+
message: `A11y: <${element}> cannot have role '${role}'`
117+
}),
114118
a11y_role_has_required_aria_props: (role: string, props: string[]) => ({
115119
code: 'a11y-role-has-required-aria-props',
116120
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: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +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 { noninteractive_roles } from '../utils/aria_roles';
28-
import { interactive_elements } from '../utils/elements';
27+
import { is_interactive_element, is_non_interactive_roles, is_presentation_role } from '../utils/a11y';
2928

3029
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)$/;
3130

3231
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(' ');
3332
const aria_attribute_set = new Set(aria_attributes);
3433

35-
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();
3635
const aria_role_set = new Set(aria_roles);
3736

3837
const a11y_required_attributes = {
@@ -438,6 +437,11 @@ export default class Element extends Node {
438437
validate_attributes_a11y() {
439438
const { component, attributes } = this;
440439

440+
const attribute_map = new Map<string, Attribute>();
441+
attributes.forEach(attribute => (
442+
attribute_map.set(attribute.name, attribute)
443+
));
444+
441445
attributes.forEach(attribute => {
442446
if (attribute.is_spread) return;
443447

@@ -480,7 +484,7 @@ export default class Element extends Node {
480484
component.warn(attribute, compiler_warnings.a11y_misplaced_role(this.name));
481485
}
482486

483-
const value = attribute.get_static_value();
487+
const value = attribute.get_static_value() as ARIARoleDefintionKey;
484488
// @ts-ignore
485489
if (value && !aria_role_set.has(value)) {
486490
// @ts-ignore
@@ -505,7 +509,7 @@ export default class Element extends Node {
505509
}
506510

507511
// role-has-required-aria-props
508-
const role = roles.get(value as ARIARoleDefintionKey);
512+
const role = roles.get(value);
509513
if (role) {
510514
const required_role_props = Object.keys(role.requiredProps);
511515
const has_missing_props = required_role_props.some(prop => !attributes.find(a => a.name === prop));
@@ -514,6 +518,11 @@ export default class Element extends Node {
514518
component.warn(attribute, compiler_warnings.a11y_role_has_required_aria_props(value as string, required_role_props));
515519
}
516520
}
521+
522+
// no-interactive-element-to-noninteractive-role
523+
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(value) || is_presentation_role(value))) {
524+
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(value, this.name));
525+
}
517526
}
518527

519528
// no-access-key
@@ -683,18 +692,6 @@ export default class Element extends Node {
683692
if (handlers_map.has('mouseout') && !handlers_map.has('blur')) {
684693
component.warn(this, compiler_warnings.a11y_mouse_events_have_key_events('mouseout', 'blur'));
685694
}
686-
687-
if (interactive_elements.has(this.name)) {
688-
if (attribute_map.has('role')) {
689-
const roleValue = this.attributes.find(a => a.name === 'role').get_static_value().toString() as ARIARoleDefintionKey;
690-
if (noninteractive_roles.has(roleValue)) {
691-
component.warn(this, {
692-
code: 'a11y-no-interactive-element-to-noninteractive-role',
693-
message: `A11y: <${this.name}> cannot have role ${roleValue}`
694-
});
695-
}
696-
}
697-
}
698695
}
699696

700697
validate_bindings_foreign() {

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+
}

src/compiler/compile/utils/aria_roles.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/compiler/compile/utils/elements.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)