@out-of-order/core 0.1.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/dist/index.js ADDED
@@ -0,0 +1,761 @@
1
+ // src/audit.ts
2
+ import { tabbable, getTabIndex } from "tabbable";
3
+
4
+ // src/overlay-classes.ts
5
+ var OVERLAY_CLASS_PREFIX = "ooo-";
6
+
7
+ // src/dom.ts
8
+ function closestAncestor(start, test) {
9
+ for (let node = start; node; node = node.parentElement) {
10
+ if (test(node)) {
11
+ return node;
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function selectorFor(element) {
17
+ const parts = [];
18
+ let node = element;
19
+ let depth = 0;
20
+ while (node && depth < 4) {
21
+ let part = node.tagName.toLowerCase();
22
+ if (node.id) {
23
+ part += `#${node.id}`;
24
+ parts.unshift(part);
25
+ break;
26
+ }
27
+ const cls = (node.getAttribute("class") || "").trim().split(/\s+/).filter((c) => c && !c.startsWith(OVERLAY_CLASS_PREFIX));
28
+ if (cls.length) {
29
+ part += `.${cls[0]}`;
30
+ }
31
+ parts.unshift(part);
32
+ node = node.parentElement;
33
+ depth++;
34
+ }
35
+ return parts.join(" > ");
36
+ }
37
+ var NATIVE_FOR_ROLE = {
38
+ button: "<button>",
39
+ link: "<a href>",
40
+ checkbox: '<input type="checkbox">',
41
+ radio: '<input type="radio">',
42
+ switch: '<input type="checkbox" role="switch">',
43
+ slider: '<input type="range">',
44
+ spinbutton: '<input type="number">',
45
+ searchbox: '<input type="search">',
46
+ textbox: "<input> or <textarea>",
47
+ combobox: "<select>",
48
+ option: "<option>"
49
+ };
50
+ var COMPOSITE_ROLES_NO_NATIVE = [
51
+ "menuitem",
52
+ "menuitemcheckbox",
53
+ "menuitemradio",
54
+ "tab",
55
+ "treeitem"
56
+ ];
57
+ var INTERACTIVE_ROLES = [...Object.keys(NATIVE_FOR_ROLE), ...COMPOSITE_ROLES_NO_NATIVE];
58
+ function isInteractive(element) {
59
+ const tag = element.tagName.toLowerCase();
60
+ if (tag === "a" && element.hasAttribute("href")) {
61
+ return true;
62
+ }
63
+ if (["button", "select", "textarea", "summary"].includes(tag)) {
64
+ return true;
65
+ }
66
+ if (tag === "input") {
67
+ const type = (element.getAttribute("type") || "text").toLowerCase();
68
+ return type !== "hidden";
69
+ }
70
+ const role = element.getAttribute("role");
71
+ return !!role && INTERACTIVE_ROLES.includes(role);
72
+ }
73
+ function hasExplicitName(element) {
74
+ return (element.getAttribute("aria-label") || "").trim() !== "" || (element.getAttribute("title") || "").trim() !== "";
75
+ }
76
+ function inAriaHidden(element) {
77
+ return element.closest('[aria-hidden="true"]') !== null;
78
+ }
79
+ function isInert(element) {
80
+ return element.closest("[inert]") !== null;
81
+ }
82
+ var IGNORE_ATTRIBUTE = "data-ooo-ignore";
83
+ function isRuleIgnored(element, ruleId) {
84
+ const value = element.getAttribute(IGNORE_ATTRIBUTE);
85
+ if (value === null) {
86
+ return false;
87
+ }
88
+ const ids = value.trim().split(/\s+/).filter(Boolean);
89
+ return ids.length === 0 || ids.includes(ruleId);
90
+ }
91
+ function isTransparent(element) {
92
+ const check = element.checkVisibility;
93
+ if (typeof check === "function") {
94
+ return !check.call(element, { opacityProperty: true });
95
+ }
96
+ return closestAncestor(element, (node) => parseFloat(getComputedStyle(node).opacity || "1") === 0) !== null;
97
+ }
98
+ function isScreenReaderOnly(element, rect = element.getBoundingClientRect()) {
99
+ if (rect.width > 2 || rect.height > 2) {
100
+ return false;
101
+ }
102
+ const style = getComputedStyle(element);
103
+ return style.clip !== "" && style.clip !== "auto" || style.clipPath !== "" && style.clipPath !== "none" || style.overflow === "hidden";
104
+ }
105
+ function isClipped(element, rect) {
106
+ for (let node = element.parentElement; node; node = node.parentElement) {
107
+ const containerRect = node.getBoundingClientRect();
108
+ if (containerRect.width === 0 && containerRect.height === 0) {
109
+ continue;
110
+ }
111
+ const style = getComputedStyle(node);
112
+ const outX = rect.right <= containerRect.left || rect.left >= containerRect.right;
113
+ const outY = rect.bottom <= containerRect.top || rect.top >= containerRect.bottom;
114
+ const clipX = style.overflowX === "clip";
115
+ const clipY = style.overflowY === "clip";
116
+ if (outX && clipX || outY && clipY) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+ function isOffPage(element, rect) {
123
+ const win = element.ownerDocument?.defaultView;
124
+ if (!win) {
125
+ return false;
126
+ }
127
+ const pageRight = rect.right + win.scrollX;
128
+ const pageBottom = rect.bottom + win.scrollY;
129
+ return pageRight <= 0 || pageBottom <= 0;
130
+ }
131
+ function staticHiddenReason(element, rect) {
132
+ if (isScreenReaderOnly(element, rect)) {
133
+ return null;
134
+ }
135
+ if (element.tagName.toLowerCase() === "area") {
136
+ return null;
137
+ }
138
+ if (isTransparent(element)) {
139
+ return "opacity:0, invisible but still tabbable";
140
+ }
141
+ if (rect.width < 1 || rect.height < 1) {
142
+ return "zero size, no visible target";
143
+ }
144
+ if (isOffPage(element, rect)) {
145
+ return "positioned off-screen (e.g. left:-9999px), invisible but still tabbable";
146
+ }
147
+ if (isClipped(element, rect)) {
148
+ return "clipped by an overflow:clip ancestor";
149
+ }
150
+ return null;
151
+ }
152
+ var REVEALING_PROPS = [
153
+ "opacity",
154
+ "visibility",
155
+ "display",
156
+ "position",
157
+ "left",
158
+ "right",
159
+ "top",
160
+ "bottom",
161
+ "inset",
162
+ "clip",
163
+ "clip-path",
164
+ "transform",
165
+ "translate",
166
+ "scale",
167
+ "width",
168
+ "height",
169
+ "max-width",
170
+ "max-height",
171
+ "overflow"
172
+ ];
173
+ function revealSelectors(rules) {
174
+ return Array.from(rules).flatMap((rule) => {
175
+ if (rule instanceof CSSStyleRule) {
176
+ if (!/:focus/i.test(rule.selectorText) || !REVEALING_PROPS.some((p) => rule.style.getPropertyValue(p) !== "")) {
177
+ return [];
178
+ }
179
+ const resting = rule.selectorText.replace(/:focus(?:-visible|-within)?/gi, "").trim();
180
+ return resting ? [resting] : [];
181
+ }
182
+ return "cssRules" in rule ? revealSelectors(rule.cssRules) : [];
183
+ });
184
+ }
185
+ function focusRevealSelectors(doc) {
186
+ const sheets = [...Array.from(doc.styleSheets), ...doc.adoptedStyleSheets ?? []];
187
+ return sheets.flatMap((sheet) => {
188
+ try {
189
+ return revealSelectors(sheet.cssRules);
190
+ } catch {
191
+ return [];
192
+ }
193
+ });
194
+ }
195
+ function hiddenReason(element, rect, revealOnFocus = []) {
196
+ const reason = staticHiddenReason(element, rect);
197
+ if (!reason) {
198
+ return null;
199
+ }
200
+ const revealed = revealOnFocus.some((selector) => {
201
+ try {
202
+ return element.matches(selector);
203
+ } catch {
204
+ return false;
205
+ }
206
+ });
207
+ return revealed ? null : reason;
208
+ }
209
+ var NATIVE_INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "option"];
210
+ function looksClickable(element) {
211
+ const tag = element.tagName.toLowerCase();
212
+ if (NATIVE_INTERACTIVE_TAGS.includes(tag)) {
213
+ return false;
214
+ }
215
+ const role = element.getAttribute("role");
216
+ if (role && INTERACTIVE_ROLES.includes(role)) {
217
+ return true;
218
+ }
219
+ return element.hasAttribute("onclick");
220
+ }
221
+ function nativeReplacement(element) {
222
+ if (NATIVE_INTERACTIVE_TAGS.includes(element.tagName.toLowerCase())) {
223
+ return null;
224
+ }
225
+ const role = element.getAttribute("role");
226
+ if (!role) {
227
+ return null;
228
+ }
229
+ return NATIVE_FOR_ROLE[role] ?? null;
230
+ }
231
+ function isNativelyFocusable(element) {
232
+ const tag = element.tagName.toLowerCase();
233
+ if (tag === "a" || tag === "area") {
234
+ return element.hasAttribute("href");
235
+ }
236
+ if (["button", "select", "textarea", "iframe"].includes(tag)) {
237
+ return true;
238
+ }
239
+ if (tag === "input") {
240
+ return (element.getAttribute("type") || "text").toLowerCase() !== "hidden";
241
+ }
242
+ if (tag === "audio" || tag === "video") {
243
+ return element.hasAttribute("controls");
244
+ }
245
+ if (tag === "summary") {
246
+ const parent = element.parentElement;
247
+ return parent?.tagName.toLowerCase() === "details" && parent.querySelector("summary") === element;
248
+ }
249
+ return false;
250
+ }
251
+ var COMPOSITE_ROLES = [
252
+ "toolbar",
253
+ "tablist",
254
+ "menu",
255
+ "menubar",
256
+ "radiogroup",
257
+ "listbox",
258
+ "tree",
259
+ "grid"
260
+ ];
261
+ function compositeAncestor(element) {
262
+ return closestAncestor(element.parentElement, (node) => {
263
+ const role = node.getAttribute("role");
264
+ return !!role && COMPOSITE_ROLES.includes(role);
265
+ });
266
+ }
267
+ function isFocusManaged(element) {
268
+ const tabindex = element.getAttribute("tabindex");
269
+ if (tabindex !== null && Number(tabindex) < 0) {
270
+ return true;
271
+ }
272
+ if (compositeAncestor(element)) {
273
+ return true;
274
+ }
275
+ return element.closest("[aria-activedescendant]") !== null;
276
+ }
277
+ function floatingAncestor(element) {
278
+ return closestAncestor(element, (node) => {
279
+ const pos = getComputedStyle(node).position;
280
+ return pos === "fixed" || pos === "sticky";
281
+ });
282
+ }
283
+ function isScrollContainer(element) {
284
+ const scrollable = (value) => value === "auto" || value === "scroll" || value === "overlay";
285
+ const style = getComputedStyle(element);
286
+ return scrollable(style.overflowX) || scrollable(style.overflowY);
287
+ }
288
+ function scrollAncestor(element) {
289
+ return closestAncestor(element.parentElement, isScrollContainer);
290
+ }
291
+ function isDisplayed(element) {
292
+ const check = element.checkVisibility;
293
+ if (typeof check === "function") {
294
+ return check.call(element, { visibilityProperty: true });
295
+ }
296
+ return closestAncestor(element, (node) => getComputedStyle(node).display === "none") === null;
297
+ }
298
+ function openModal(root) {
299
+ for (const element of root.querySelectorAll('dialog:modal, [aria-modal="true"]')) {
300
+ if (isDisplayed(element)) {
301
+ return element;
302
+ }
303
+ }
304
+ return null;
305
+ }
306
+
307
+ // src/rules.ts
308
+ import { computeAccessibleName } from "dom-accessibility-api";
309
+ import { isFocusable } from "tabbable";
310
+ var ROW_TOLERANCE_PX = 8;
311
+ var flagEntries = (sequence, message) => sequence.flatMap((entry) => {
312
+ const msg = message(entry);
313
+ return msg ? [{ message: msg, target: entry }] : [];
314
+ });
315
+ var noPositiveTabIndex = (sequence) => flagEntries(
316
+ sequence,
317
+ (entry) => entry.tabIndex > 0 ? `Element has tabindex="${entry.tabIndex}". Positive tabindex overrides the natural DOM order and is fragile; use 0 or restructure the DOM.` : null
318
+ );
319
+ var visualOrderMismatch = (sequence) => {
320
+ const floats = sequence.map((entry) => floatingAncestor(entry.element));
321
+ const scrollers = sequence.map((entry) => scrollAncestor(entry.element));
322
+ const out = [];
323
+ for (let idx = 1; idx < sequence.length; idx++) {
324
+ const prev = sequence[idx - 1];
325
+ const cur = sequence[idx];
326
+ if (floats[idx - 1] !== floats[idx] || scrollers[idx - 1] !== scrollers[idx]) {
327
+ continue;
328
+ }
329
+ const prevX = prev.rect.left + prev.rect.width / 2;
330
+ const curX = cur.rect.left + cur.rect.width / 2;
331
+ const nextColumn = cur.rect.left >= prev.rect.right;
332
+ const earlierRow = cur.rect.bottom <= prev.rect.top + ROW_TOLERANCE_PX;
333
+ const sameRow = !earlierRow && cur.rect.top < prev.rect.bottom - ROW_TOLERANCE_PX;
334
+ const backwardHop = !nextColumn && (earlierRow || sameRow && curX < prevX - 1);
335
+ if (!backwardHop) {
336
+ continue;
337
+ }
338
+ out.push({
339
+ message: `"${cur.selector}" comes after "${prev.selector}" in the tab order, but sits visually before it (reading order is top\u2192bottom, left\u2192right). Tab makes a backward hop here.`,
340
+ target: cur
341
+ });
342
+ }
343
+ return out;
344
+ };
345
+ var missingAccessibleName = (sequence) => flagEntries(sequence, (entry) => {
346
+ if (!isInteractive(entry.element)) {
347
+ return null;
348
+ }
349
+ if (hasExplicitName(entry.element)) {
350
+ return null;
351
+ }
352
+ if (computeAccessibleName(entry.element).trim() !== "") {
353
+ return null;
354
+ }
355
+ return `Focusable element "${entry.selector}" has no accessible name (no text, aria-label, aria-labelledby, associated label, alt, or title).`;
356
+ });
357
+ var ariaHiddenFocusable = (sequence) => flagEntries(
358
+ sequence,
359
+ (entry) => inAriaHidden(entry.element) ? `"${entry.selector}" is tabbable but inside aria-hidden="true", so a screen-reader user lands on a control the SR won't announce. Add tabindex="-1"/inert, or remove aria-hidden.` : null
360
+ );
361
+ var hiddenWhileFocusable = (sequence, { container }) => {
362
+ const revealOnFocus = focusRevealSelectors(container.ownerDocument);
363
+ return flagEntries(sequence, (entry) => {
364
+ const reason = hiddenReason(entry.element, entry.rect, revealOnFocus);
365
+ return reason ? `"${entry.selector}" is tabbable but ${reason}. Hide it from the tab order too (display:none, the hidden attribute, or tabindex="-1").` : null;
366
+ });
367
+ };
368
+ var clickableNotFocusable = (_sequence, { container, inSequence }) => {
369
+ const wrapsFocusable = /* @__PURE__ */ new Set();
370
+ for (const stop of inSequence) {
371
+ for (let node = stop; node; node = node.parentElement) {
372
+ if (wrapsFocusable.has(node)) {
373
+ break;
374
+ }
375
+ wrapsFocusable.add(node);
376
+ }
377
+ }
378
+ const out = [];
379
+ for (const element of container.querySelectorAll("*")) {
380
+ if (inSequence.has(element)) {
381
+ continue;
382
+ }
383
+ if (wrapsFocusable.has(element)) {
384
+ continue;
385
+ }
386
+ if (!looksClickable(element)) {
387
+ continue;
388
+ }
389
+ if (isFocusManaged(element)) {
390
+ continue;
391
+ }
392
+ if (isInert(element)) {
393
+ continue;
394
+ }
395
+ const rect = element.getBoundingClientRect();
396
+ if (rect.width < 1 || rect.height < 1) {
397
+ continue;
398
+ }
399
+ const selector = selectorFor(element);
400
+ out.push({
401
+ message: `"${selector}" looks interactive (role or onclick) but is not in the tab order, so keyboard users can't reach it. Use a <button>/<a>, or add tabindex="0" plus Enter/Space handlers.`,
402
+ target: element
403
+ });
404
+ }
405
+ return out;
406
+ };
407
+ var compositeRovingTabindex = (sequence) => {
408
+ const groups = /* @__PURE__ */ new Map();
409
+ for (const entry of sequence) {
410
+ const container = compositeAncestor(entry.element);
411
+ if (!container) {
412
+ continue;
413
+ }
414
+ const list = groups.get(container) ?? [];
415
+ list.push(entry);
416
+ groups.set(container, list);
417
+ }
418
+ const out = [];
419
+ for (const [container, members] of groups) {
420
+ if (members.length < 2) {
421
+ continue;
422
+ }
423
+ const role = container.getAttribute("role");
424
+ for (const member of members) {
425
+ out.push({
426
+ message: `${members.length} items inside role="${role}" are separate tab stops. A ${role} should expose one tab stop and move between items with the arrow keys (roving tabindex).`,
427
+ target: member
428
+ });
429
+ }
430
+ }
431
+ return out;
432
+ };
433
+ var focusEscapesModal = (sequence, { container }) => {
434
+ const modal = openModal(container);
435
+ if (!modal) {
436
+ return [];
437
+ }
438
+ const leaked = sequence.filter(
439
+ (entry) => !modal.contains(entry.element) && !isInert(entry.element)
440
+ );
441
+ if (leaked.length === 0) {
442
+ return [];
443
+ }
444
+ const first = leaked[0];
445
+ const subject = leaked.length === 1 ? `"${first.selector}" outside it is still tabbable` : `${leaked.length} controls outside it are still tabbable (e.g. "${first.selector}")`;
446
+ return [
447
+ {
448
+ message: `A modal dialog is open, but ${subject}, so focus can leak behind the dialog. Mark background content inert (or aria-hidden + remove it from the tab order).`,
449
+ target: first,
450
+ // One finding, anchored on the first leaked control, but every other leaked
451
+ // control shares the root cause (background not inert) and is ringed too.
452
+ relatedElements: leaked.slice(1).map((entry) => entry.element)
453
+ }
454
+ ];
455
+ };
456
+ var tabindexOnNoninteractive = (sequence) => flagEntries(sequence, (entry) => {
457
+ if (entry.tabIndex !== 0) {
458
+ return null;
459
+ }
460
+ if (entry.element.getAttribute("tabindex") === null) {
461
+ return null;
462
+ }
463
+ const element = entry.element;
464
+ if (isNativelyFocusable(element)) {
465
+ return null;
466
+ }
467
+ if (isInteractive(element)) {
468
+ return null;
469
+ }
470
+ if (element.isContentEditable) {
471
+ return null;
472
+ }
473
+ const role = element.getAttribute("role");
474
+ if (role && role !== "presentation" && role !== "none") {
475
+ return null;
476
+ }
477
+ if (isScrollContainer(element)) {
478
+ return null;
479
+ }
480
+ return `"${entry.selector}" has tabindex="0" but is non-interactive (no role, not a control). If it's decorative, remove the tabindex, since it adds a dead stop to the tab order; if it's meant to be a control, give it a real role (or use a <button>).`;
481
+ });
482
+ var preferNativeElement = (sequence) => flagEntries(sequence, (entry) => {
483
+ const native = nativeReplacement(entry.element);
484
+ if (!native) {
485
+ return null;
486
+ }
487
+ const tag = entry.element.tagName.toLowerCase();
488
+ const role = entry.element.getAttribute("role");
489
+ return `"${entry.selector}" is a <${tag}> with role="${role}". Prefer a native ${native}: focus, keyboard activation (Enter/Space), and screen-reader semantics come for free, instead of being reimplemented with ARIA + JS.`;
490
+ });
491
+ var duplicateAutofocus = (_sequence, { container }) => {
492
+ const focusableAutofocus = Array.from(container.querySelectorAll("[autofocus]")).filter(
493
+ (element) => isFocusable(element, { getShadowRoot: true })
494
+ );
495
+ if (focusableAutofocus.length < 2) {
496
+ return [];
497
+ }
498
+ return focusableAutofocus.slice(1).map((element) => {
499
+ const selector = selectorFor(element);
500
+ return {
501
+ message: `"${selector}" also has autofocus, but a page can autofocus only one element; the first focusable one in document order wins, so this one is silently ignored. Remove the extra autofocus.`,
502
+ target: element
503
+ };
504
+ });
505
+ };
506
+ var autofocusNotFocusable = (_sequence, { container }) => {
507
+ const out = [];
508
+ for (const element of container.querySelectorAll("[autofocus]")) {
509
+ if (isFocusable(element, { getShadowRoot: true })) {
510
+ continue;
511
+ }
512
+ const selector = selectorFor(element);
513
+ out.push({
514
+ message: `"${selector}" has autofocus but isn't focusable (no tabindex, not a form control), so it's ignored on load. Remove the autofocus, or make the element focusable (e.g. tabindex="-1").`,
515
+ target: element
516
+ });
517
+ }
518
+ return out;
519
+ };
520
+ var nestedInteractive = (sequence, { container, inSequence }) => {
521
+ const stop = container.parentElement;
522
+ const out = [];
523
+ for (const entry of sequence) {
524
+ for (let node = entry.element.parentElement; node && node !== stop; node = node.parentElement) {
525
+ if (!inSequence.has(node) && !isInteractive(node)) {
526
+ continue;
527
+ }
528
+ out.push({
529
+ message: `"${entry.selector}" is focusable but nested inside another focusable element ("${selectorFor(node)}"). Nesting interactive controls stacks two tab stops in one place and can hide the inner control's role/name from screen readers; don't put a focusable element inside another.`,
530
+ target: entry
531
+ });
532
+ break;
533
+ }
534
+ }
535
+ return out;
536
+ };
537
+ var redundantTabindex = (sequence) => flagEntries(sequence, (entry) => {
538
+ if (entry.tabIndex !== 0) {
539
+ return null;
540
+ }
541
+ if (entry.element.getAttribute("tabindex") === null) {
542
+ return null;
543
+ }
544
+ if (!isNativelyFocusable(entry.element)) {
545
+ return null;
546
+ }
547
+ return `"${entry.selector}" is already focusable, so its tabindex="0" is redundant. Remove the attribute; the element stays in the tab order on its own.`;
548
+ });
549
+ var ALL_RULES = {
550
+ "no-positive-tabindex": {
551
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
552
+ defaultSeverity: "error",
553
+ run: noPositiveTabIndex
554
+ },
555
+ "visual-order-mismatch": {
556
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
557
+ defaultSeverity: "warning",
558
+ run: visualOrderMismatch
559
+ },
560
+ "missing-accessible-name": {
561
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
562
+ defaultSeverity: "error",
563
+ run: missingAccessibleName
564
+ },
565
+ "aria-hidden-focusable": {
566
+ docs: "https://www.w3.org/TR/wai-aria-1.2/#aria-hidden",
567
+ defaultSeverity: "error",
568
+ run: ariaHiddenFocusable
569
+ },
570
+ "hidden-while-focusable": {
571
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
572
+ defaultSeverity: "error",
573
+ run: hiddenWhileFocusable
574
+ },
575
+ "clickable-not-focusable": {
576
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
577
+ defaultSeverity: "error",
578
+ run: clickableNotFocusable
579
+ },
580
+ "composite-roving-tabindex": {
581
+ docs: "https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/",
582
+ defaultSeverity: "warning",
583
+ run: compositeRovingTabindex
584
+ },
585
+ "focus-escapes-modal": {
586
+ docs: "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/",
587
+ defaultSeverity: "error",
588
+ run: focusEscapesModal
589
+ },
590
+ "tabindex-on-noninteractive": {
591
+ docs: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/",
592
+ defaultSeverity: "error",
593
+ run: tabindexOnNoninteractive
594
+ },
595
+ "prefer-native-element": {
596
+ docs: "https://www.w3.org/TR/using-aria/#firstrule",
597
+ defaultSeverity: "warning",
598
+ run: preferNativeElement
599
+ },
600
+ "duplicate-autofocus": {
601
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
602
+ defaultSeverity: "warning",
603
+ run: duplicateAutofocus
604
+ },
605
+ "autofocus-not-focusable": {
606
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
607
+ defaultSeverity: "warning",
608
+ run: autofocusNotFocusable
609
+ },
610
+ "nested-interactive": {
611
+ docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
612
+ defaultSeverity: "error",
613
+ run: nestedInteractive
614
+ },
615
+ "redundant-tabindex": {
616
+ docs: "https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex",
617
+ defaultSeverity: "warning",
618
+ run: redundantTabindex
619
+ }
620
+ };
621
+ var DEFAULT_SEVERITY = Object.fromEntries(
622
+ Object.entries(ALL_RULES).map(([id, rule]) => [id, rule.defaultSeverity])
623
+ );
624
+
625
+ // src/audit.ts
626
+ function resolveRule(options, rule) {
627
+ const setting = options.rules?.[rule.id];
628
+ if (setting === void 0) {
629
+ return { enabled: true, severity: rule.defaultSeverity };
630
+ }
631
+ if (setting === "off") {
632
+ return { enabled: false, severity: rule.defaultSeverity };
633
+ }
634
+ return { enabled: true, severity: setting };
635
+ }
636
+ function toIssue(finding, rule, severity) {
637
+ return {
638
+ rule: rule.id,
639
+ severity,
640
+ message: finding.message,
641
+ docs: rule.docs,
642
+ relatedElements: finding.relatedElements
643
+ };
644
+ }
645
+ function locate(finding) {
646
+ const { target } = finding;
647
+ return "orderIndex" in target ? {
648
+ element: target.element,
649
+ selector: target.selector,
650
+ orderIndex: target.orderIndex
651
+ } : { element: target, selector: selectorFor(target) };
652
+ }
653
+ function audit(root = document, options = {}, customRules = []) {
654
+ const container = root.nodeType === 9 ? root.documentElement : root;
655
+ if (!container) {
656
+ return finalize({ valid: true, sequence: [], violations: [] }, options.format);
657
+ }
658
+ const elements = tabbable(container, {
659
+ getShadowRoot: true
660
+ });
661
+ const sequence = elements.map((element, orderIndex) => ({
662
+ element,
663
+ orderIndex,
664
+ selector: selectorFor(element),
665
+ tabIndex: getTabIndex(element),
666
+ rect: element.getBoundingClientRect()
667
+ }));
668
+ const ctx = {
669
+ container,
670
+ inSequence: new Set(sequence.map((entry) => entry.element))
671
+ };
672
+ const builtins = Object.entries(ALL_RULES).map(([id, def]) => ({
673
+ id,
674
+ ...def
675
+ }));
676
+ const byElement2 = /* @__PURE__ */ new Map();
677
+ for (const rule of [...builtins, ...customRules]) {
678
+ const { enabled, severity } = resolveRule(options, rule);
679
+ if (!enabled) {
680
+ continue;
681
+ }
682
+ for (const finding of rule.run(sequence, ctx)) {
683
+ const { element, selector, orderIndex } = locate(finding);
684
+ let violation = byElement2.get(element);
685
+ if (!violation) {
686
+ violation = { element, selector, orderIndex, issues: [] };
687
+ byElement2.set(element, violation);
688
+ }
689
+ const issue = toIssue(finding, rule, severity);
690
+ if (isRuleIgnored(element, rule.id)) {
691
+ issue.ignored = true;
692
+ }
693
+ violation.issues.push(issue);
694
+ }
695
+ }
696
+ const violations = [...byElement2.values()].sort(
697
+ (a, b) => (a.orderIndex ?? Infinity) - (b.orderIndex ?? Infinity)
698
+ );
699
+ for (const violation of violations) {
700
+ violation.issues.sort(
701
+ (a, b) => a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1
702
+ );
703
+ }
704
+ const hasErrors = violations.some(
705
+ (violation) => violation.issues.some((issue) => issue.severity === "error" && !issue.ignored)
706
+ );
707
+ return finalize({ valid: !hasErrors, sequence, violations }, options.format);
708
+ }
709
+ function finalize(result, format) {
710
+ if (!format) {
711
+ return result;
712
+ }
713
+ return { ...result, violations: reshape(result.violations, format) };
714
+ }
715
+ function reshape(violations, format) {
716
+ switch (format) {
717
+ case "text":
718
+ return renderText(violations);
719
+ case "by-element":
720
+ return byElement(violations);
721
+ }
722
+ }
723
+ var related = (issue) => issue.relatedElements?.map(selectorFor);
724
+ function byElement(violations) {
725
+ return violations.map((violation) => ({
726
+ selector: violation.selector,
727
+ orderIndex: violation.orderIndex,
728
+ issueCount: violation.issues.length,
729
+ issues: violation.issues.map((issue) => ({
730
+ rule: issue.rule,
731
+ severity: issue.severity,
732
+ message: issue.message,
733
+ docs: issue.docs,
734
+ related: related(issue),
735
+ ignored: issue.ignored
736
+ }))
737
+ }));
738
+ }
739
+ function renderText(violations) {
740
+ if (!violations.length) {
741
+ return "No tab-order issues.";
742
+ }
743
+ return violations.map((violation) => {
744
+ const pos = violation.orderIndex !== void 0 ? `#${violation.orderIndex + 1} ` : "";
745
+ const issues = violation.issues.map((issue) => {
746
+ const rel = related(issue);
747
+ return ` - ${issue.severity.toUpperCase()} [${issue.rule}] ${issue.message}` + (rel?.length ? ` (related: ${rel.join(", ")})` : "") + (issue.ignored ? " (ignored via data-ooo-ignore)" : "");
748
+ }).join("\n");
749
+ return `${pos}${violation.selector}
750
+ ${issues}`;
751
+ }).join("\n\n");
752
+ }
753
+ export {
754
+ DEFAULT_SEVERITY,
755
+ OVERLAY_CLASS_PREFIX,
756
+ audit,
757
+ isInteractive,
758
+ isRuleIgnored,
759
+ isScreenReaderOnly,
760
+ selectorFor
761
+ };