Skip to content

Commit 82f6481

Browse files
chore: clean up a11y analysis code (#16345)
* chore: clean up a11y analysis code * lint * apply changes from #16340 and #16341 * `utils.js` -> `index.js` * remove unused exports * remove unused exports * newlines are free :) * consolidate loops * put the exported function up top --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 6d8f39f commit 82f6481

File tree

5 files changed

+975
-882
lines changed

5 files changed

+975
-882
lines changed

.changeset/warm-olives-applaud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
chore: clean up a11y analysis code

packages/svelte/src/compiler/phases/2-analyze/visitors/RegularElement.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as e from '../../../errors.js';
99
import * as w from '../../../warnings.js';
1010
import { create_attribute, is_custom_element_node } from '../../nodes.js';
1111
import { regex_starts_with_newline } from '../../patterns.js';
12-
import { check_element } from './shared/a11y.js';
12+
import { check_element } from './shared/a11y/index.js';
1313
import { validate_element } from './shared/element.js';
1414
import { mark_subtree_dynamic } from './shared/fragment.js';
1515

packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteElement.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/** @import { Context } from '../types' */
33
import { NAMESPACE_MATHML, NAMESPACE_SVG } from '../../../../constants.js';
44
import { is_text_attribute } from '../../../utils/ast.js';
5-
import { check_element } from './shared/a11y.js';
5+
import { check_element } from './shared/a11y/index.js';
66
import { validate_element } from './shared/element.js';
77
import { mark_subtree_dynamic } from './shared/fragment.js';
88

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/** @import { ARIARoleRelationConcept } from 'aria-query' */
2+
import { roles as roles_map, elementRoles } from 'aria-query';
3+
// @ts-expect-error package doesn't provide typings
4+
import { AXObjects, elementAXObjects } from 'axobject-query';
5+
6+
export const aria_attributes =
7+
'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(
8+
' '
9+
);
10+
11+
/** @type {Record<string, string[]>} */
12+
export const a11y_required_attributes = {
13+
a: ['href'],
14+
area: ['alt', 'aria-label', 'aria-labelledby'],
15+
// html-has-lang
16+
html: ['lang'],
17+
// iframe-has-title
18+
iframe: ['title'],
19+
img: ['alt'],
20+
object: ['title', 'aria-label', 'aria-labelledby']
21+
};
22+
23+
export const a11y_distracting_elements = ['blink', 'marquee'];
24+
25+
// this excludes `<a>` and `<button>` because they are handled separately
26+
export const a11y_required_content = [
27+
// heading-has-content
28+
'h1',
29+
'h2',
30+
'h3',
31+
'h4',
32+
'h5',
33+
'h6'
34+
];
35+
36+
export const a11y_labelable = [
37+
'button',
38+
'input',
39+
'keygen',
40+
'meter',
41+
'output',
42+
'progress',
43+
'select',
44+
'textarea'
45+
];
46+
47+
export const a11y_interactive_handlers = [
48+
// Keyboard events
49+
'keypress',
50+
'keydown',
51+
'keyup',
52+
// Click events
53+
'click',
54+
'contextmenu',
55+
'dblclick',
56+
'drag',
57+
'dragend',
58+
'dragenter',
59+
'dragexit',
60+
'dragleave',
61+
'dragover',
62+
'dragstart',
63+
'drop',
64+
'mousedown',
65+
'mouseenter',
66+
'mouseleave',
67+
'mousemove',
68+
'mouseout',
69+
'mouseover',
70+
'mouseup'
71+
];
72+
73+
export const a11y_recommended_interactive_handlers = [
74+
'click',
75+
'mousedown',
76+
'mouseup',
77+
'keypress',
78+
'keydown',
79+
'keyup'
80+
];
81+
82+
export const a11y_nested_implicit_semantics = new Map([
83+
['header', 'banner'],
84+
['footer', 'contentinfo']
85+
]);
86+
87+
export const a11y_implicit_semantics = new Map([
88+
['a', 'link'],
89+
['area', 'link'],
90+
['article', 'article'],
91+
['aside', 'complementary'],
92+
['body', 'document'],
93+
['button', 'button'],
94+
['datalist', 'listbox'],
95+
['dd', 'definition'],
96+
['dfn', 'term'],
97+
['dialog', 'dialog'],
98+
['details', 'group'],
99+
['dt', 'term'],
100+
['fieldset', 'group'],
101+
['figure', 'figure'],
102+
['form', 'form'],
103+
['h1', 'heading'],
104+
['h2', 'heading'],
105+
['h3', 'heading'],
106+
['h4', 'heading'],
107+
['h5', 'heading'],
108+
['h6', 'heading'],
109+
['hr', 'separator'],
110+
['img', 'img'],
111+
['li', 'listitem'],
112+
['link', 'link'],
113+
['main', 'main'],
114+
['menu', 'list'],
115+
['meter', 'progressbar'],
116+
['nav', 'navigation'],
117+
['ol', 'list'],
118+
['option', 'option'],
119+
['optgroup', 'group'],
120+
['output', 'status'],
121+
['progress', 'progressbar'],
122+
['section', 'region'],
123+
['summary', 'button'],
124+
['table', 'table'],
125+
['tbody', 'rowgroup'],
126+
['textarea', 'textbox'],
127+
['tfoot', 'rowgroup'],
128+
['thead', 'rowgroup'],
129+
['tr', 'row'],
130+
['ul', 'list']
131+
]);
132+
133+
export const menuitem_type_to_implicit_role = new Map([
134+
['command', 'menuitem'],
135+
['checkbox', 'menuitemcheckbox'],
136+
['radio', 'menuitemradio']
137+
]);
138+
139+
export const input_type_to_implicit_role = new Map([
140+
['button', 'button'],
141+
['image', 'button'],
142+
['reset', 'button'],
143+
['submit', 'button'],
144+
['checkbox', 'checkbox'],
145+
['radio', 'radio'],
146+
['range', 'slider'],
147+
['number', 'spinbutton'],
148+
['email', 'textbox'],
149+
['search', 'searchbox'],
150+
['tel', 'textbox'],
151+
['text', 'textbox'],
152+
['url', 'textbox']
153+
]);
154+
155+
/**
156+
* Exceptions to the rule which follows common A11y conventions
157+
* TODO make this configurable by the user
158+
* @type {Record<string, string[]>}
159+
*/
160+
export const a11y_non_interactive_element_to_interactive_role_exceptions = {
161+
ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
162+
ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
163+
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
164+
table: ['grid'],
165+
td: ['gridcell'],
166+
fieldset: ['radiogroup', 'presentation']
167+
};
168+
169+
export const combobox_if_list = ['email', 'search', 'tel', 'text', 'url'];
170+
171+
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
172+
export const address_type_tokens = ['shipping', 'billing'];
173+
174+
export const autofill_field_name_tokens = [
175+
'',
176+
'on',
177+
'off',
178+
'name',
179+
'honorific-prefix',
180+
'given-name',
181+
'additional-name',
182+
'family-name',
183+
'honorific-suffix',
184+
'nickname',
185+
'username',
186+
'new-password',
187+
'current-password',
188+
'one-time-code',
189+
'organization-title',
190+
'organization',
191+
'street-address',
192+
'address-line1',
193+
'address-line2',
194+
'address-line3',
195+
'address-level4',
196+
'address-level3',
197+
'address-level2',
198+
'address-level1',
199+
'country',
200+
'country-name',
201+
'postal-code',
202+
'cc-name',
203+
'cc-given-name',
204+
'cc-additional-name',
205+
'cc-family-name',
206+
'cc-number',
207+
'cc-exp',
208+
'cc-exp-month',
209+
'cc-exp-year',
210+
'cc-csc',
211+
'cc-type',
212+
'transaction-currency',
213+
'transaction-amount',
214+
'language',
215+
'bday',
216+
'bday-day',
217+
'bday-month',
218+
'bday-year',
219+
'sex',
220+
'url',
221+
'photo'
222+
];
223+
224+
export const contact_type_tokens = ['home', 'work', 'mobile', 'fax', 'pager'];
225+
226+
export const autofill_contact_field_name_tokens = [
227+
'tel',
228+
'tel-country-code',
229+
'tel-national',
230+
'tel-area-code',
231+
'tel-local',
232+
'tel-local-prefix',
233+
'tel-local-suffix',
234+
'tel-extension',
235+
'email',
236+
'impp'
237+
];
238+
239+
export const ElementInteractivity = /** @type {const} */ ({
240+
Interactive: 'interactive',
241+
NonInteractive: 'non-interactive',
242+
Static: 'static'
243+
});
244+
245+
export const invisible_elements = ['meta', 'html', 'script', 'style'];
246+
247+
export const aria_roles = roles_map.keys();
248+
249+
export const abstract_roles = aria_roles.filter((role) => roles_map.get(role)?.abstract);
250+
251+
const non_abstract_roles = aria_roles.filter((name) => !abstract_roles.includes(name));
252+
253+
export const non_interactive_roles = non_abstract_roles
254+
.filter((name) => {
255+
const role = roles_map.get(name);
256+
return (
257+
// 'toolbar' does not descend from widget, but it does support
258+
// aria-activedescendant, thus in practice we treat it as a widget.
259+
// focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable.
260+
// 'generic' is meant to have no semantic meaning.
261+
// 'cell' is treated as CellRole by the AXObject which is interactive, so we treat 'cell' it as interactive as well.
262+
!['toolbar', 'tabpanel', 'generic', 'cell'].includes(name) &&
263+
!role?.superClass.some((classes) => classes.includes('widget') || classes.includes('window'))
264+
);
265+
})
266+
.concat(
267+
// The `progressbar` is descended from `widget`, but in practice, its
268+
// value is always `readonly`, so we treat it as a non-interactive role.
269+
'progressbar'
270+
);
271+
272+
export const interactive_roles = non_abstract_roles.filter(
273+
(name) =>
274+
!non_interactive_roles.includes(name) &&
275+
// 'generic' is meant to have no semantic meaning.
276+
name !== 'generic'
277+
);
278+
279+
export const presentation_roles = ['presentation', 'none'];
280+
281+
/** @type {ARIARoleRelationConcept[]} */
282+
export const non_interactive_element_role_schemas = [];
283+
284+
/** @type {ARIARoleRelationConcept[]} */
285+
export const interactive_element_role_schemas = [];
286+
287+
for (const [schema, roles] of elementRoles.entries()) {
288+
if ([...roles].every((role) => role !== 'generic' && non_interactive_roles.includes(role))) {
289+
non_interactive_element_role_schemas.push(schema);
290+
}
291+
292+
if ([...roles].every((role) => interactive_roles.includes(role))) {
293+
interactive_element_role_schemas.push(schema);
294+
}
295+
}
296+
297+
const interactive_ax_objects = [...AXObjects.keys()].filter(
298+
(name) => AXObjects.get(name).type === 'widget'
299+
);
300+
301+
/** @type {ARIARoleRelationConcept[]} */
302+
export const interactive_element_ax_object_schemas = [];
303+
304+
/** @type {ARIARoleRelationConcept[]} */
305+
export const non_interactive_element_ax_object_schemas = [];
306+
307+
const non_interactive_ax_objects = [...AXObjects.keys()].filter((name) =>
308+
['windows', 'structure'].includes(AXObjects.get(name).type)
309+
);
310+
311+
for (const [schema, ax_object] of elementAXObjects.entries()) {
312+
if ([...ax_object].every((role) => interactive_ax_objects.includes(role))) {
313+
interactive_element_ax_object_schemas.push(schema);
314+
}
315+
316+
if ([...ax_object].every((role) => non_interactive_ax_objects.includes(role))) {
317+
non_interactive_element_ax_object_schemas.push(schema);
318+
}
319+
}

0 commit comments

Comments
 (0)