Skip to content

Commit 8e3b70f

Browse files
committed
refactor to match the eslint-plugin-jsx-a11y implementation
1 parent bde3236 commit 8e3b70f

File tree

10 files changed

+988
-636
lines changed

10 files changed

+988
-636
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 & 20 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
const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract));
3837

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

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

@@ -481,12 +485,11 @@ export default class Element extends Node {
481485
component.warn(attribute, compiler_warnings.a11y_misplaced_role(this.name));
482486
}
483487

484-
const value = attribute.get_static_value();
488+
const value = attribute.get_static_value() as ARIARoleDefintionKey;
485489

486-
if (value && aria_role_abstract_set.has(value as ARIARoleDefintionKey)) {
490+
if (value && aria_role_abstract_set.has(value)) {
487491
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value));
488-
} else if (value && !aria_role_set.has(value as string)) {
489-
// @ts-ignore
492+
} else if (value && !aria_role_set.has(value)) {
490493
const match = fuzzymatch(value, aria_roles);
491494
component.warn(attribute, compiler_warnings.a11y_unknown_role(value, match));
492495
}
@@ -508,7 +511,7 @@ export default class Element extends Node {
508511
}
509512

510513
// role-has-required-aria-props
511-
const role = roles.get(value as ARIARoleDefintionKey);
514+
const role = roles.get(value);
512515
if (role) {
513516
const required_role_props = Object.keys(role.requiredProps);
514517
const has_missing_props = required_role_props.some(prop => !attributes.find(a => a.name === prop));
@@ -517,6 +520,11 @@ export default class Element extends Node {
517520
component.warn(attribute, compiler_warnings.a11y_role_has_required_aria_props(value as string, required_role_props));
518521
}
519522
}
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+
}
520528
}
521529

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

703699
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)