@jxsuite/schema 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/a11y.ts ADDED
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Accessibility rules over a Jx document — the static half of WAI-ARIA and WCAG conformance.
3
+ *
4
+ * The same shape as `overlays.ts` and `dialogs.ts`: one walk, one defect record, a sentence an
5
+ * author can act on, and a rule that stays silent whenever the fact it needs is BOUND rather than
6
+ * written. A `${…}` template or a `{ $ref }` in a name, a role or an id is a value the document
7
+ * decides at run time, and a lint that accused the author of a defect it could not see would teach
8
+ * them to ignore it. Every rule here names the WCAG success criterion it stands on, so a finding
9
+ * reads as a citation rather than an opinion.
10
+ *
11
+ * What is NOT judged, deliberately: colour and contrast (a style question the kit's tokens answer
12
+ * and `check-styles` gates), keyboard behaviour (a runtime question), and reading order. Those
13
+ * belong to a browser, not to a walk over JSON.
14
+ *
15
+ * @docs framework/concepts/accessibility
16
+ */
17
+
18
+ import { isJsonObject, isRef } from "./guards";
19
+ import { attrs, labelOf, tagOf } from "./overlays";
20
+ import type { JxElement } from "../types";
21
+ import type { PopoverPath } from "./overlays";
22
+
23
+ export type A11yRule =
24
+ | "interactive-unnamed"
25
+ | "img-alt-missing"
26
+ | "aria-target-missing"
27
+ | "tab-outside-tablist"
28
+ | "tablist-none-selected"
29
+ | "menuitem-outside-menu"
30
+ | "option-outside-listbox"
31
+ | "custom-element-in-select"
32
+ | "dialog-unnamed"
33
+ | "activedescendant-not-focusable";
34
+
35
+ export interface A11yDefect {
36
+ rule: A11yRule;
37
+ /** Where the defect is. */
38
+ path: PopoverPath;
39
+ /** One sentence naming what is wrong, in the author's language. */
40
+ message: string;
41
+ /** Why it is wrong and what to do instead. */
42
+ detail: string;
43
+ /** `error` fails a conformance check; `warn` is very probably not what was meant. */
44
+ severity: "error" | "warn";
45
+ /** The WCAG 2.2 success criterion the rule stands on, e.g. `4.1.2`. */
46
+ criterion: string;
47
+ }
48
+
49
+ /** The success criteria the rules cite, by number. */
50
+ export const WCAG_CRITERIA: Readonly<Record<string, string>> = {
51
+ "1.1.1": "Non-text Content",
52
+ "1.3.1": "Info and Relationships",
53
+ "2.1.1": "Keyboard",
54
+ "4.1.2": "Name, Role, Value",
55
+ };
56
+
57
+ const DOCS = "See docs/framework/concepts/accessibility.";
58
+
59
+ // ─── Reading a node ─────────────────────────────────────────────────────────────
60
+
61
+ /** A value the document decides at run time: a `$ref`, an expression, or a `${…}` template. */
62
+ function isBound(value: unknown): boolean {
63
+ if (isRef(value) || isJsonObject(value)) {
64
+ return true;
65
+ }
66
+ return typeof value === "string" && value.includes("${");
67
+ }
68
+
69
+ /** An attribute or top-level property, whichever the author wrote. */
70
+ function valueOf(node: JxElement, name: string): unknown {
71
+ const bag = attrs(node);
72
+ if (name in bag) {
73
+ return bag[name];
74
+ }
75
+ return (node as Record<string, unknown>)[name];
76
+ }
77
+
78
+ /** A literal, non-empty string value, or null when absent, empty or bound. */
79
+ function literal(node: JxElement, name: string): string | null {
80
+ const value = valueOf(node, name);
81
+ return typeof value === "string" && value.trim() !== "" && !isBound(value) ? value : null;
82
+ }
83
+
84
+ /** Whether the author wrote the attribute at all, bound or not. */
85
+ function has(node: JxElement, name: string): boolean {
86
+ return valueOf(node, name) !== undefined && valueOf(node, name) !== null;
87
+ }
88
+
89
+ /** The sentinel a reader returns for a value the document decides at run time. */
90
+ const BOUND = "bound";
91
+
92
+ /** The node's role, lowercased; `null` when it has none and `"bound"` when it is not decidable. */
93
+ function roleOf(node: JxElement): string | null {
94
+ const value = valueOf(node, "role");
95
+ if (value === undefined || value === null) {
96
+ return null;
97
+ }
98
+ if (isBound(value)) {
99
+ return "bound";
100
+ }
101
+ return typeof value === "string" ? value.trim().toLowerCase().split(/\s+/)[0]! : null;
102
+ }
103
+
104
+ /** The node's literal id, or `"bound"` when it has one the document decides at run time. */
105
+ function idStateOf(node: JxElement): string | null {
106
+ const value = valueOf(node, "id");
107
+ if (value === undefined || value === null || value === "") {
108
+ return null;
109
+ }
110
+ return isBound(value) ? "bound" : typeof value === "string" ? value : null;
111
+ }
112
+
113
+ /** Whether a name source the platform reads is present, bound or literal and non-empty. */
114
+ function hasNameSource(node: JxElement, name: string): boolean {
115
+ const value = valueOf(node, name);
116
+ if (value === undefined || value === null) {
117
+ return false;
118
+ }
119
+ return isBound(value) || (typeof value === "string" && value.trim() !== "");
120
+ }
121
+
122
+ /**
123
+ * Whether the node's CONTENT can name it — text, a slot that may carry text, a named image, or
124
+ * anything the document decides at run time.
125
+ *
126
+ * Conservative on purpose: a `$map`, a `$switch` with any named case, or a bound `textContent` all
127
+ * count as content, because the lint may not accuse what it cannot read.
128
+ */
129
+ function hasContentName(node: JxElement): boolean {
130
+ const text = node.textContent;
131
+ if (typeof text === "string") {
132
+ if (text.trim() !== "") {
133
+ return true;
134
+ }
135
+ } else if (text !== undefined && text !== null) {
136
+ return true;
137
+ }
138
+ if (hasNameSource(node, "aria-label") || has(node, "aria-labelledby")) {
139
+ return true;
140
+ }
141
+ if (tagOf(node) === "img" && (hasNameSource(node, "alt") || hasNameSource(node, "title"))) {
142
+ return true;
143
+ }
144
+ if (tagOf(node) === "slot") {
145
+ return true;
146
+ }
147
+ const { children } = node;
148
+ if (Array.isArray(children)) {
149
+ for (const child of children) {
150
+ if (typeof child === "string") {
151
+ if (child.trim() !== "") {
152
+ return true;
153
+ }
154
+ } else if (child && typeof child === "object" && hasContentName(child)) {
155
+ return true;
156
+ }
157
+ }
158
+ } else if (children !== undefined && children !== null) {
159
+ // A mapped array: rows the document decides.
160
+ return true;
161
+ }
162
+ if (isJsonObject(node.map)) {
163
+ return true;
164
+ }
165
+ const cases = isJsonObject(node.cases) ? Object.values(node.cases) : [];
166
+ return cases.some((branch) => isJsonObject(branch) && hasContentName(branch as JxElement));
167
+ }
168
+
169
+ /** Roles that take their name from content, as ARIA's "name from: contents" allows. */
170
+ const CONTENT_NAMED_ROLES = new Set([
171
+ "button",
172
+ "checkbox",
173
+ "link",
174
+ "menuitem",
175
+ "menuitemcheckbox",
176
+ "menuitemradio",
177
+ "option",
178
+ "radio",
179
+ "switch",
180
+ "tab",
181
+ "treeitem",
182
+ ]);
183
+
184
+ /** Roles that must be named some other way — an accessible name from content is not allowed. */
185
+ const LABELLED_ROLES = new Set([
186
+ "combobox",
187
+ "listbox",
188
+ "searchbox",
189
+ "slider",
190
+ "spinbutton",
191
+ "textbox",
192
+ ]);
193
+
194
+ /** `<input>` types named by their `value` rather than a label. */
195
+ const VALUE_NAMED_INPUTS = new Set(["button", "reset", "submit"]);
196
+
197
+ /** For each role the container rules judge, the container roles allowed to own it. */
198
+ const OWNING_ROLES: Readonly<Record<string, readonly string[]>> = {
199
+ menuitem: ["menu", "menubar"],
200
+ menuitemcheckbox: ["menu", "menubar"],
201
+ menuitemradio: ["menu", "menubar"],
202
+ option: ["listbox"],
203
+ tab: ["tablist"],
204
+ };
205
+
206
+ /** Every role on the owning side of that map, so one walk can collect what each of them owns. */
207
+ const CONTAINER_ROLES = new Set(Object.values(OWNING_ROLES).flat());
208
+
209
+ // ─── Walking with ancestors ─────────────────────────────────────────────────────
210
+
211
+ interface Ancestry {
212
+ node: JxElement;
213
+ path: PopoverPath;
214
+ /** Roles of every ancestor, nearest last; `"bound"` where one is not decidable. */
215
+ roles: (string | null)[];
216
+ /** Tags of every ancestor, nearest last. */
217
+ tags: string[];
218
+ }
219
+
220
+ function* walkAncestry(
221
+ root: JxElement,
222
+ path: PopoverPath = [],
223
+ roles: (string | null)[] = [],
224
+ tags: string[] = [],
225
+ ): Generator<Ancestry> {
226
+ yield { node: root, path, roles, tags };
227
+ const below = [...roles, roleOf(root)];
228
+ const tagsBelow = [...tags, tagOf(root)];
229
+ const { children } = root;
230
+ if (Array.isArray(children)) {
231
+ for (const [index, child] of children.entries()) {
232
+ if (child && typeof child === "object") {
233
+ yield* walkAncestry(child as JxElement, [...path, "children", index], below, tagsBelow);
234
+ }
235
+ }
236
+ } else if (isJsonObject(children) && isJsonObject((children as JxElement).map)) {
237
+ yield* walkAncestry(
238
+ (children as JxElement).map as JxElement,
239
+ [...path, "children", "map"],
240
+ below,
241
+ tagsBelow,
242
+ );
243
+ }
244
+ if (isJsonObject(root.map)) {
245
+ yield* walkAncestry(root.map as JxElement, [...path, "map"], below, tagsBelow);
246
+ }
247
+ const cases = isJsonObject(root.cases) ? (root.cases as Record<string, JxElement>) : {};
248
+ for (const [key, branch] of Object.entries(cases)) {
249
+ if (branch && typeof branch === "object") {
250
+ yield* walkAncestry(branch, [...path, "cases", key], below, tagsBelow);
251
+ }
252
+ }
253
+ }
254
+
255
+ /** Whether any ancestor is a custom element, whose definition may supply the container role. */
256
+ function underCustomElement(tags: string[]): boolean {
257
+ return tags.some((tag) => tag.includes("-"));
258
+ }
259
+
260
+ // ─── The rules ──────────────────────────────────────────────────────────────────
261
+
262
+ function defect(
263
+ rule: A11yRule,
264
+ path: PopoverPath,
265
+ message: string,
266
+ detail: string,
267
+ criterion: string,
268
+ severity: "error" | "warn" = "error",
269
+ ): A11yDefect {
270
+ return {
271
+ criterion,
272
+ detail: `${detail} ${DOCS}`,
273
+ message,
274
+ path,
275
+ rule,
276
+ severity,
277
+ };
278
+ }
279
+
280
+ /**
281
+ * What the document's `<label>` elements point at, and whether any of them points somewhere
282
+ * unreadable.
283
+ */
284
+ interface LabelTargets {
285
+ /** Every literal `for` value. */
286
+ ids: Set<string>;
287
+ /** A `<label for="row-${index}">` exists, so which control it names is not decidable here. */
288
+ bound: boolean;
289
+ }
290
+
291
+ /**
292
+ * Whether an `<input>`, `<select>` or `<textarea>` is labelled by a `<label>`, by `for` or by
293
+ * nesting.
294
+ *
295
+ * A BOUND id on either side answers true, which is this module's rule about not accusing an author
296
+ * of a defect it cannot see. `<label for="row-${index}">` beside `<input id="row-${index}">` is the
297
+ * only correct way to label a field inside a repeater, and both halves are templates: the id
298
+ * resolves to the sentinel rather than to a value, and the `for` never enters the set at all. Read
299
+ * literally that pairing looked like no label at all, so the one correct form was the one reported
300
+ * — and at `error` severity, which fails `jx validate --strict` on a document that is fine.
301
+ */
302
+ function labelledByLabel(id: string | null, labelledIds: LabelTargets, tags: string[]): boolean {
303
+ if (tags.includes("label")) {
304
+ return true;
305
+ }
306
+ if (id === BOUND || labelledIds.bound) {
307
+ return true;
308
+ }
309
+ return id !== null && labelledIds.ids.has(id);
310
+ }
311
+
312
+ function unnamedInteractive(visit: Ancestry, labelledIds: LabelTargets): A11yDefect | null {
313
+ const { node, path, tags } = visit;
314
+ const tag = tagOf(node);
315
+ const role = roleOf(node);
316
+ if (role === "bound") {
317
+ return null;
318
+ }
319
+ const named =
320
+ hasNameSource(node, "aria-label") ||
321
+ has(node, "aria-labelledby") ||
322
+ hasNameSource(node, "title");
323
+ if (named) {
324
+ return null;
325
+ }
326
+ const what = labelOf(node, "the control");
327
+ const fix = (how: string) =>
328
+ defect(
329
+ "interactive-unnamed",
330
+ path,
331
+ `${what} has no accessible name.`,
332
+ `A control with no name is announced as its role alone, so a reader hears "button" and nothing else. ${how}`,
333
+ "4.1.2",
334
+ );
335
+ /*
336
+ * The NATIVE routes, judged before any role branch.
337
+ *
338
+ * A role on a native control is redundant, legal, and for `role="combobox"` on an `<input>` it is
339
+ * the authoring practice the APG prescribes. Reporting the role first meant a `<label for>` was
340
+ * never consulted for one, so the APG combobox — and a `role="slider"` range, and a
341
+ * `role="spinbutton"` number — were all reported as unnamed while carrying a perfectly good name.
342
+ */
343
+ const nativeRoutes = nativeNameRoute(node, tag, labelledIds, tags);
344
+ if (nativeRoutes === "named" || nativeRoutes === "exempt") {
345
+ return null;
346
+ }
347
+ if (role !== null && CONTENT_NAMED_ROLES.has(role)) {
348
+ return hasContentName(node) ? null : fix("Give it text content, or an aria-label.");
349
+ }
350
+ if (role !== null && LABELLED_ROLES.has(role)) {
351
+ return fix("Give it an aria-label, or aria-labelledby naming its visible label.");
352
+ }
353
+ if (role !== null) {
354
+ return null;
355
+ }
356
+ if (tag === "button" || tag === "summary" || (tag === "a" && has(node, "href"))) {
357
+ return hasContentName(node) ? null : fix("Give it text content, or an aria-label.");
358
+ }
359
+ if (nativeRoutes === "unnamed") {
360
+ return fix("Give it a <label for> naming its id, or an aria-label.");
361
+ }
362
+ if (nativeRoutes === "unnamed-value") {
363
+ return fix("Give it a value, or an aria-label.");
364
+ }
365
+ if (nativeRoutes === "unnamed-alt") {
366
+ return fix("Give it an alt, or an aria-label.");
367
+ }
368
+ return null;
369
+ }
370
+
371
+ /**
372
+ * What the NATIVE labelling routes say about a form control or an image-map area, independently of
373
+ * any role it carries.
374
+ *
375
+ * `"none"` means the node has no native route and these do not apply to it.
376
+ */
377
+ function nativeNameRoute(
378
+ node: JxElement,
379
+ tag: string,
380
+ labelledIds: LabelTargets,
381
+ tags: string[],
382
+ ): "named" | "exempt" | "unnamed" | "unnamed-value" | "unnamed-alt" | "none" {
383
+ /*
384
+ * An image-map hotspot, whose `alt` IS its link text.
385
+ *
386
+ * HTML requires the attribute on every `<area>` that has an `href`, and there is no other route
387
+ * to a name: an area is a void element, so it has no content to fall back on, and no `<label>`
388
+ * points at one. An empty `alt` is therefore NOT the decision it is on an `<img>` — a decorative
389
+ * link is a contradiction — which is why this reports through the name rule rather than through
390
+ * `img-alt-missing`. An `<area>` with no `href` is not a link and owes nothing, so it falls
391
+ * through to the role branches like any other element.
392
+ */
393
+ if (tag === "area") {
394
+ if (!has(node, "href")) {
395
+ return "none";
396
+ }
397
+ return hasNameSource(node, "alt") ? "named" : "unnamed-alt";
398
+ }
399
+ if (tag === "select" || tag === "textarea") {
400
+ return labelledByLabel(idStateOf(node), labelledIds, tags) || hasNameSource(node, "placeholder")
401
+ ? "named"
402
+ : "unnamed";
403
+ }
404
+ if (tag !== "input") {
405
+ return "none";
406
+ }
407
+ const type = (literal(node, "type") ?? "text").toLowerCase();
408
+ if (type === "hidden" || (has(node, "type") && literal(node, "type") === null)) {
409
+ return "exempt";
410
+ }
411
+ if (VALUE_NAMED_INPUTS.has(type)) {
412
+ return hasNameSource(node, "value") ? "named" : "unnamed-value";
413
+ }
414
+ if (type === "image") {
415
+ return hasNameSource(node, "alt") ? "named" : "unnamed-alt";
416
+ }
417
+ return labelledByLabel(idStateOf(node), labelledIds, tags) || hasNameSource(node, "placeholder")
418
+ ? "named"
419
+ : "unnamed";
420
+ }
421
+
422
+ /** The two tags whose rows the platform builds itself, so nothing authored may stand among them. */
423
+ const SELECT_TAGS = new Set(["select", "jx-select"]);
424
+
425
+ /**
426
+ * A custom element anywhere inside a `<select>` — or inside a `jx-select`, which is one.
427
+ *
428
+ * **This rule is what replaced an absence.** `specs/ui.md` §5.1 used to argue that the kit shipping
429
+ * no `jx-option` WAS the defence, because a custom element inside a `<select>` is a convincing
430
+ * forgery: it survives insertion, it draws, and with `role="option"` the open accessibility tree
431
+ * reads `combobox expanded → MenuListPopup → option "Alpha" selectable`. Measured in Chrome against
432
+ * a real one, the control does nothing: `select.options.length` is 0, ArrowDown then Enter gives
433
+ * `value ""` and `selectedIndex -1`, and no `change` event fires. A test asserting the TREE passes
434
+ * while the control is inert, so an absence was the only defence that held.
435
+ *
436
+ * §5.3's `jx-option` is built now, as a `jx-listbox` row, so the absence is gone and this is what
437
+ * stands in its place. It is deliberately broader than the element that provoked it: an
438
+ * `<option>`'s content model is text, so a `jx-icon` inside one is the same forgery with a
439
+ * different tag, and every custom element in that position fails the same way for the same reason.
440
+ * A native `option`, `optgroup`, `hr` or `slot` is untouched, which is the whole legal vocabulary
441
+ * of a `<select>`.
442
+ */
443
+ function customElementInSelect(visit: Ancestry): A11yDefect | null {
444
+ const { node, path, tags } = visit;
445
+ const tag = tagOf(node);
446
+ if (!tag.includes("-") || !tags.some((ancestor) => SELECT_TAGS.has(ancestor))) {
447
+ return null;
448
+ }
449
+ return defect(
450
+ "custom-element-in-select",
451
+ path,
452
+ `<${tag}> is a custom element inside a <select>.`,
453
+ `A <select> builds its own rows: an element it does not know is not in select.options, so it cannot be picked, cannot be reached with the arrow keys, and fires no change event — while role="option" on it still makes the accessibility tree read as though it could. Every row of a <select> is a native <option>; a listbox whose rows are jx-option elements is a jx-listbox.`,
454
+ "4.1.2",
455
+ );
456
+ }
457
+
458
+ function imgAltMissing(visit: Ancestry): A11yDefect | null {
459
+ const { node, path } = visit;
460
+ if (tagOf(node) !== "img") {
461
+ return null;
462
+ }
463
+ const role = roleOf(node);
464
+ if (role === "bound" || role === "presentation" || role === "none") {
465
+ return null;
466
+ }
467
+ if (has(node, "alt") || hasNameSource(node, "aria-label") || has(node, "aria-labelledby")) {
468
+ return null;
469
+ }
470
+ return defect(
471
+ "img-alt-missing",
472
+ path,
473
+ `${labelOf(node, "the image")} has no alt text.`,
474
+ 'An image with no alt is announced by its file name. Describe it in alt, or write alt="" for a decorative one.',
475
+ "1.1.1",
476
+ );
477
+ }
478
+
479
+ const ID_REFERENCE_ATTRS = [
480
+ "aria-activedescendant",
481
+ "aria-controls",
482
+ "aria-describedby",
483
+ "aria-details",
484
+ "aria-errormessage",
485
+ "aria-flowto",
486
+ "aria-labelledby",
487
+ "aria-owns",
488
+ ];
489
+
490
+ function ariaTargetsMissing(visit: Ancestry, ids: Set<string>): A11yDefect[] {
491
+ const { node, path } = visit;
492
+ const defects: A11yDefect[] = [];
493
+ for (const name of ID_REFERENCE_ATTRS) {
494
+ const value = literal(node, name);
495
+ if (value === null) {
496
+ continue;
497
+ }
498
+ for (const token of value.split(/\s+/)) {
499
+ if (token !== "" && !ids.has(token)) {
500
+ defects.push(
501
+ defect(
502
+ "aria-target-missing",
503
+ path,
504
+ `${labelOf(node, "the element")} points ${name} at #${token}, which nothing in this document has as its id.`,
505
+ "A reference to an id that is not in the document is silently dropped, so the relationship it declares does not exist. Name an element that is here, or give that element the id.",
506
+ "1.3.1",
507
+ ),
508
+ );
509
+ }
510
+ }
511
+ }
512
+ return defects;
513
+ }
514
+
515
+ /** What the containers of one role name through `aria-owns`, across the whole document. */
516
+ interface OwnedTargets {
517
+ /** Every literal id such a container owns. */
518
+ ids: Set<string>;
519
+ /** One of them owns `"tab-${state.i}"`, so which elements it owns is not decidable here. */
520
+ bound: boolean;
521
+ }
522
+
523
+ /**
524
+ * Whether a container that may own this role claims it through `aria-owns`.
525
+ *
526
+ * ARIA states containment where the DOM tree cannot: an element a `tablist` names in `aria-owns` is
527
+ * that tablist's child in the accessibility tree wherever it sits in the markup, and the attribute
528
+ * exists for exactly the arrangements a subtree cannot express. Walking ancestors alone therefore
529
+ * reported the one correct way to write those, at `error` severity, so `jx validate --strict`
530
+ * failed on a document that is fine.
531
+ *
532
+ * A BOUND `aria-owns`, or a bound id on the node itself, answers true, which is this module's rule
533
+ * about not accusing an author of a defect it cannot see. The map is keyed by the OWNER's role, so
534
+ * this stays a silence rather than a hole: a `role="banner" aria-owns="t1"` over a tab owns nothing
535
+ * a tab may belong to, and the tab is still reported.
536
+ */
537
+ function ownedByContainer(
538
+ id: string | null,
539
+ role: string,
540
+ owned: Map<string, OwnedTargets>,
541
+ ): boolean {
542
+ for (const container of OWNING_ROLES[role] ?? []) {
543
+ const targets = owned.get(container);
544
+ if (targets === undefined) {
545
+ continue;
546
+ }
547
+ if (targets.bound) {
548
+ return true;
549
+ }
550
+ if (id === BOUND && targets.ids.size > 0) {
551
+ return true;
552
+ }
553
+ if (id !== null && id !== BOUND && targets.ids.has(id)) {
554
+ return true;
555
+ }
556
+ }
557
+ return false;
558
+ }
559
+
560
+ function roleOutsideContainer(
561
+ visit: Ancestry,
562
+ owned: Map<string, OwnedTargets>,
563
+ ): A11yDefect | null {
564
+ const { node, path, roles, tags } = visit;
565
+ const role = roleOf(node);
566
+ if (role === null || role === "bound" || path.length === 0 || underCustomElement(tags)) {
567
+ return null;
568
+ }
569
+ if (roles.includes("bound") || ownedByContainer(idStateOf(node), role, owned)) {
570
+ return null;
571
+ }
572
+ const what = labelOf(node, "the element");
573
+ if (role === "tab" && !roles.includes("tablist")) {
574
+ return defect(
575
+ "tab-outside-tablist",
576
+ path,
577
+ `${what} is a tab outside any tablist.`,
578
+ 'A tab is announced with its position in a tablist, so one with no tablist ancestor has no place to be counted in. Put the tabs in a container with role="tablist".',
579
+ "1.3.1",
580
+ );
581
+ }
582
+ if (
583
+ (role === "menuitem" || role === "menuitemcheckbox" || role === "menuitemradio") &&
584
+ !roles.includes("menu") &&
585
+ !roles.includes("menubar")
586
+ ) {
587
+ return defect(
588
+ "menuitem-outside-menu",
589
+ path,
590
+ `${what} is a ${role} outside any menu.`,
591
+ 'A menu item is owned by a menu, and is announced as one of its items. Put it in a container with role="menu" or role="menubar".',
592
+ "1.3.1",
593
+ );
594
+ }
595
+ if (
596
+ role === "option" &&
597
+ !roles.includes("listbox") &&
598
+ !tags.includes("select") &&
599
+ !tags.includes("datalist")
600
+ ) {
601
+ return defect(
602
+ "option-outside-listbox",
603
+ path,
604
+ `${what} is an option outside any listbox.`,
605
+ 'An option is owned by a listbox, and is announced as one of its choices. Put it in a container with role="listbox", or in a <select>.',
606
+ "1.3.1",
607
+ );
608
+ }
609
+ return null;
610
+ }
611
+
612
+ /** Every literal `aria-selected` in the subtree's tabs, or null when one is bound. */
613
+ function tabSelections(root: JxElement): boolean[] | null {
614
+ const seen: boolean[] = [];
615
+ for (const { node } of walkAncestry(root)) {
616
+ if (roleOf(node) !== "tab") {
617
+ continue;
618
+ }
619
+ const value = valueOf(node, "aria-selected");
620
+ if (isBound(value)) {
621
+ return null;
622
+ }
623
+ seen.push(value === true || value === "true");
624
+ }
625
+ return seen;
626
+ }
627
+
628
+ function tablistNoneSelected(visit: Ancestry): A11yDefect | null {
629
+ const { node, path } = visit;
630
+ if (roleOf(node) !== "tablist") {
631
+ return null;
632
+ }
633
+ const selections = tabSelections(node);
634
+ if (selections === null || selections.length === 0 || selections.includes(true)) {
635
+ return null;
636
+ }
637
+ return defect(
638
+ "tablist-none-selected",
639
+ path,
640
+ `${labelOf(node, "the tablist")} has ${selections.length} tab(s) and none is selected.`,
641
+ 'A tablist always has a current tab, and aria-selected="true" is how it is announced. Mark the tab whose panel is showing.',
642
+ "4.1.2",
643
+ "warn",
644
+ );
645
+ }
646
+
647
+ function dialogUnnamed(visit: Ancestry): A11yDefect | null {
648
+ const { node, path } = visit;
649
+ const role = roleOf(node);
650
+ const isDialog = tagOf(node) === "dialog" || role === "dialog" || role === "alertdialog";
651
+ if (!isDialog || role === "bound") {
652
+ return null;
653
+ }
654
+ if (hasNameSource(node, "aria-label") || has(node, "aria-labelledby")) {
655
+ return null;
656
+ }
657
+ return defect(
658
+ "dialog-unnamed",
659
+ path,
660
+ `${labelOf(node, "the dialog")} has no accessible name.`,
661
+ "A dialog is announced by its name when it opens, and its content does not name it. Give it aria-labelledby naming its heading, or an aria-label.",
662
+ "4.1.2",
663
+ );
664
+ }
665
+
666
+ const FOCUSABLE_TAGS = new Set(["button", "input", "select", "textarea"]);
667
+
668
+ function activedescendantNotFocusable(visit: Ancestry): A11yDefect | null {
669
+ const { node, path } = visit;
670
+ if (!has(node, "aria-activedescendant")) {
671
+ return null;
672
+ }
673
+ const tag = tagOf(node);
674
+ const focusable =
675
+ FOCUSABLE_TAGS.has(tag) ||
676
+ (tag === "a" && has(node, "href")) ||
677
+ has(node, "tabIndex") ||
678
+ has(node, "tabindex") ||
679
+ has(node, "contenteditable");
680
+ if (focusable) {
681
+ return null;
682
+ }
683
+ return defect(
684
+ "activedescendant-not-focusable",
685
+ path,
686
+ `${labelOf(node, "the element")} manages focus with aria-activedescendant but cannot itself take focus.`,
687
+ "aria-activedescendant names the active child of a FOCUSED container; on one that cannot be focused, the keyboard never reaches it. Give the container a tabindex.",
688
+ "2.1.1",
689
+ );
690
+ }
691
+
692
+ // ─── The entry point ────────────────────────────────────────────────────────────
693
+
694
+ /**
695
+ * Every accessibility defect in a document, in document order.
696
+ *
697
+ * @param doc The document (or element definition, or fragment) to judge.
698
+ */
699
+ export function findA11yDefects(doc: JxElement): A11yDefect[] {
700
+ const visits = [...walkAncestry(doc)];
701
+ const ids = new Set<string>();
702
+ const labelledIds: LabelTargets = { bound: false, ids: new Set<string>() };
703
+ const owned = new Map<string, OwnedTargets>();
704
+ let boundIds = false;
705
+ for (const { node } of visits) {
706
+ const role = roleOf(node);
707
+ if (role !== null && CONTAINER_ROLES.has(role)) {
708
+ const targets = owned.get(role) ?? { bound: false, ids: new Set<string>() };
709
+ const literalOwns = literal(node, "aria-owns");
710
+ if (literalOwns === null) {
711
+ // An `aria-owns` the document decides at run time: what it owns is not readable here.
712
+ targets.bound ||= has(node, "aria-owns");
713
+ } else {
714
+ for (const token of literalOwns.split(/\s+/)) {
715
+ if (token !== "") {
716
+ targets.ids.add(token);
717
+ }
718
+ }
719
+ }
720
+ owned.set(role, targets);
721
+ }
722
+ const id = idStateOf(node);
723
+ if (id === BOUND) {
724
+ boundIds = true;
725
+ } else if (id !== null) {
726
+ ids.add(id);
727
+ }
728
+ if (tagOf(node) === "label") {
729
+ const target = literal(node, "for") ?? literal(node, "htmlFor");
730
+ if (target === null) {
731
+ // A `for` the document decides at run time: which control it names is not readable here.
732
+ labelledIds.bound ||= has(node, "for") || has(node, "htmlFor");
733
+ } else {
734
+ labelledIds.ids.add(target);
735
+ }
736
+ }
737
+ }
738
+ const defects: A11yDefect[] = [];
739
+ for (const visit of visits) {
740
+ const found = [
741
+ unnamedInteractive(visit, labelledIds),
742
+ imgAltMissing(visit),
743
+ roleOutsideContainer(visit, owned),
744
+ customElementInSelect(visit),
745
+ tablistNoneSelected(visit),
746
+ dialogUnnamed(visit),
747
+ activedescendantNotFocusable(visit),
748
+ ];
749
+ for (const one of found) {
750
+ if (one) {
751
+ defects.push(one);
752
+ }
753
+ }
754
+ // An id the document decides at run time makes every reference undecidable, so the rule sleeps.
755
+ if (!boundIds) {
756
+ defects.push(...ariaTargetsMissing(visit, ids));
757
+ }
758
+ }
759
+ return defects;
760
+ }