@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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +761 -0
- package/package.json +56 -0
- package/src/audit.ts +206 -0
- package/src/dom.ts +464 -0
- package/src/index.ts +19 -0
- package/src/overlay-classes.ts +7 -0
- package/src/rules.ts +473 -0
- package/src/types.ts +105 -0
package/src/rules.ts
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { computeAccessibleName } from "dom-accessibility-api";
|
|
2
|
+
import { isFocusable } from "tabbable";
|
|
3
|
+
import type { RuleId, SequenceEntry, Severity } from "./types.js";
|
|
4
|
+
import {
|
|
5
|
+
isInteractive,
|
|
6
|
+
hasExplicitName,
|
|
7
|
+
selectorFor,
|
|
8
|
+
hiddenReason,
|
|
9
|
+
focusRevealSelectors,
|
|
10
|
+
inAriaHidden,
|
|
11
|
+
isInert,
|
|
12
|
+
looksClickable,
|
|
13
|
+
isFocusManaged,
|
|
14
|
+
compositeAncestor,
|
|
15
|
+
floatingAncestor,
|
|
16
|
+
scrollAncestor,
|
|
17
|
+
isScrollContainer,
|
|
18
|
+
openModal,
|
|
19
|
+
nativeReplacement,
|
|
20
|
+
isNativelyFocusable,
|
|
21
|
+
} from "./dom.js";
|
|
22
|
+
|
|
23
|
+
/** Everything a rule may need beyond the sequence itself. */
|
|
24
|
+
export interface RuleContext {
|
|
25
|
+
/** The analyzed root element (lets rules look beyond the tab sequence). */
|
|
26
|
+
container: Element;
|
|
27
|
+
inSequence: Set<Element>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One problem a rule reports, before grading. */
|
|
31
|
+
export interface Finding {
|
|
32
|
+
/** Human-readable description of what's wrong. */
|
|
33
|
+
message: string;
|
|
34
|
+
/** The element the finding points at. A {@link SequenceEntry} when it is a tab
|
|
35
|
+
stop (carries orderIndex and a selector), or a bare Element when it is not. */
|
|
36
|
+
target: SequenceEntry | Element;
|
|
37
|
+
/** Other elements with the same root cause. Ringed alongside `target` but not
|
|
38
|
+
reported as separate findings, so one missing fix doesn't become N violations. */
|
|
39
|
+
relatedElements?: Element[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Takes the tab sequence (plus context) and returns any findings. Pure. */
|
|
43
|
+
export type RuleRun = (sequence: SequenceEntry[], ctx: RuleContext) => Finding[];
|
|
44
|
+
|
|
45
|
+
export interface Rule {
|
|
46
|
+
/** Stable rule identifier, surfaced on every Violation it produces. */
|
|
47
|
+
id: string;
|
|
48
|
+
/** Spec link the rule is grounded in (WCAG, WAI-ARIA, or ARIA APG). */
|
|
49
|
+
docs: string;
|
|
50
|
+
/** Severity the rule fires at unless overridden via `AuditOptions.rules`. */
|
|
51
|
+
defaultSeverity: Severity;
|
|
52
|
+
run: RuleRun;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** px tolerance for treating two stops as the same visual row. Elements on one row
|
|
56
|
+
rarely share an exact vertical center (height/padding/baseline differ), and below
|
|
57
|
+
~8px a sighted user doesn't perceive a row break. */
|
|
58
|
+
const ROW_TOLERANCE_PX = 8;
|
|
59
|
+
|
|
60
|
+
/** Map the tab sequence to at most one finding per entry: return a message to flag
|
|
61
|
+
the entry, or null to pass it. Collapses the boilerplate of the per-entry rules. */
|
|
62
|
+
const flagEntries = (
|
|
63
|
+
sequence: SequenceEntry[],
|
|
64
|
+
message: (entry: SequenceEntry) => string | null,
|
|
65
|
+
): Finding[] =>
|
|
66
|
+
sequence.flatMap((entry) => {
|
|
67
|
+
const msg = message(entry);
|
|
68
|
+
return msg ? [{ message: msg, target: entry }] : [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const noPositiveTabIndex: RuleRun = (sequence) =>
|
|
72
|
+
flagEntries(sequence, (entry) =>
|
|
73
|
+
entry.tabIndex > 0
|
|
74
|
+
? `Element has tabindex="${entry.tabIndex}". Positive tabindex overrides the natural DOM order and is fragile; use 0 or restructure the DOM.`
|
|
75
|
+
: null,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The tab sequence should match the visual reading order (top→bottom,
|
|
80
|
+
* left→right). A mismatch is local: it happens between two consecutive tab
|
|
81
|
+
* stops when the second one sits visually *before* the first (an earlier row,
|
|
82
|
+
* or the same row but to its left), i.e. Tab makes a backward hop.
|
|
83
|
+
*/
|
|
84
|
+
const visualOrderMismatch: RuleRun = (sequence) => {
|
|
85
|
+
// Each element's floating ancestor, computed once (it walks the tree calling
|
|
86
|
+
// getComputedStyle); the adjacent-pair loop below would otherwise recompute
|
|
87
|
+
// every element's twice, as the "cur" of one pair and the "prev" of the next.
|
|
88
|
+
const floats = sequence.map((entry) => floatingAncestor(entry.element));
|
|
89
|
+
const scrollers = sequence.map((entry) => scrollAncestor(entry.element));
|
|
90
|
+
const out: Finding[] = [];
|
|
91
|
+
for (let idx = 1; idx < sequence.length; idx++) {
|
|
92
|
+
const prev = sequence[idx - 1]!;
|
|
93
|
+
const cur = sequence[idx]!;
|
|
94
|
+
// Only compare stops that share a scroll context. If they ride different
|
|
95
|
+
// fixed/sticky layers (page chrome floating over scrolling content), or
|
|
96
|
+
// different scroll containers (one inside an overflow:auto/scroll box, the
|
|
97
|
+
// other outside, or in separate boxes), then their up/down/left/right
|
|
98
|
+
// relationship moves with a scrollbar, so a "backward hop" between them isn't
|
|
99
|
+
// real. Skip the pair.
|
|
100
|
+
if (floats[idx - 1] !== floats[idx] || scrollers[idx - 1] !== scrollers[idx]) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const prevX = prev.rect.left + prev.rect.width / 2;
|
|
105
|
+
const curX = cur.rect.left + cur.rect.width / 2;
|
|
106
|
+
|
|
107
|
+
// A stop whose box sits entirely to the right of prev starts a later column.
|
|
108
|
+
// Multi-column reading runs down one column then jumps to the TOP of the next,
|
|
109
|
+
// so this upward move is a forward column advance, not a backward hop. Without
|
|
110
|
+
// this, every column break (left column's end → right column's start) misfires.
|
|
111
|
+
const nextColumn = cur.rect.left >= prev.rect.right;
|
|
112
|
+
|
|
113
|
+
const earlierRow = cur.rect.bottom <= prev.rect.top + ROW_TOLERANCE_PX;
|
|
114
|
+
const sameRow = !earlierRow && cur.rect.top < prev.rect.bottom - ROW_TOLERANCE_PX;
|
|
115
|
+
const backwardHop = !nextColumn && (earlierRow || (sameRow && curX < prevX - 1));
|
|
116
|
+
if (!backwardHop) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
out.push({
|
|
121
|
+
message: `"${cur.selector}" comes after "${prev.selector}" in the tab order, but sits visually before it (reading order is top→bottom, left→right). Tab makes a backward hop here.`,
|
|
122
|
+
target: cur,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const missingAccessibleName: RuleRun = (sequence) =>
|
|
130
|
+
flagEntries(sequence, (entry) => {
|
|
131
|
+
if (!isInteractive(entry.element)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Skip the costly subtree walk when a name is already guaranteed.
|
|
135
|
+
if (hasExplicitName(entry.element)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (computeAccessibleName(entry.element).trim() !== "") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return `Focusable element "${entry.selector}" has no accessible name (no text, aria-label, aria-labelledby, associated label, alt, or title).`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const ariaHiddenFocusable: RuleRun = (sequence) =>
|
|
145
|
+
flagEntries(sequence, (entry) =>
|
|
146
|
+
inAriaHidden(entry.element)
|
|
147
|
+
? `"${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.`
|
|
148
|
+
: null,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const hiddenWhileFocusable: RuleRun = (sequence, { container }) => {
|
|
152
|
+
// Scan the page's focus-reveal rules once, not per element.
|
|
153
|
+
const revealOnFocus = focusRevealSelectors(container.ownerDocument);
|
|
154
|
+
return flagEntries(sequence, (entry) => {
|
|
155
|
+
const reason = hiddenReason(entry.element, entry.rect, revealOnFocus);
|
|
156
|
+
return reason
|
|
157
|
+
? `"${entry.selector}" is tabbable but ${reason}. Hide it from the tab order too (display:none, the hidden attribute, or tabindex="-1").`
|
|
158
|
+
: null;
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const clickableNotFocusable: RuleRun = (_sequence, { container, inSequence }) => {
|
|
163
|
+
// Every ancestor-or-self of a tab stop, collected once. A clickable element
|
|
164
|
+
// that's in this set merely wraps a real focusable control, so the keyboard
|
|
165
|
+
// can still get in, so skip it.
|
|
166
|
+
const wrapsFocusable = new Set<Element>();
|
|
167
|
+
for (const stop of inSequence) {
|
|
168
|
+
for (let node: Element | null = stop; node; node = node.parentElement) {
|
|
169
|
+
if (wrapsFocusable.has(node)) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
wrapsFocusable.add(node);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const out: Finding[] = [];
|
|
177
|
+
for (const element of container.querySelectorAll("*")) {
|
|
178
|
+
if (inSequence.has(element)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (wrapsFocusable.has(element)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!looksClickable(element)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Reachable by arrow keys / a virtual cursor rather than Tab (roving tabindex,
|
|
191
|
+
// a composite container, or aria-activedescendant): deliberately out of the Tab
|
|
192
|
+
// sequence, not unreachable. Its container is the keyboard entry point.
|
|
193
|
+
if (isFocusManaged(element)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Inside an inert subtree (e.g. the background while a modal is open): nothing
|
|
198
|
+
// here is reachable by mouse OR keyboard, by design. That's a focus-trap concern
|
|
199
|
+
// (focus-escapes-modal), not a clickable-but-unfocusable bug, so don't flag it.
|
|
200
|
+
if (isInert(element)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rect = element.getBoundingClientRect();
|
|
205
|
+
if (rect.width < 1 || rect.height < 1) {
|
|
206
|
+
continue;
|
|
207
|
+
} // not rendered / no target
|
|
208
|
+
const selector = selectorFor(element);
|
|
209
|
+
out.push({
|
|
210
|
+
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.`,
|
|
211
|
+
target: element,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return out;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const compositeRovingTabindex: RuleRun = (sequence) => {
|
|
219
|
+
const groups = new Map<Element, SequenceEntry[]>();
|
|
220
|
+
for (const entry of sequence) {
|
|
221
|
+
const container = compositeAncestor(entry.element);
|
|
222
|
+
if (!container) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const list = groups.get(container) ?? [];
|
|
226
|
+
list.push(entry);
|
|
227
|
+
groups.set(container, list);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const out: Finding[] = [];
|
|
231
|
+
for (const [container, members] of groups) {
|
|
232
|
+
if (members.length < 2) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const role = container.getAttribute("role");
|
|
237
|
+
for (const member of members) {
|
|
238
|
+
out.push({
|
|
239
|
+
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).`,
|
|
240
|
+
target: member,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return out;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const focusEscapesModal: RuleRun = (sequence, { container }) => {
|
|
249
|
+
const modal = openModal(container);
|
|
250
|
+
if (!modal) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const leaked = sequence.filter(
|
|
255
|
+
(entry) => !modal.contains(entry.element) && !isInert(entry.element),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (leaked.length === 0) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const first = leaked[0]!;
|
|
263
|
+
const subject =
|
|
264
|
+
leaked.length === 1
|
|
265
|
+
? `"${first.selector}" outside it is still tabbable`
|
|
266
|
+
: `${leaked.length} controls outside it are still tabbable (e.g. "${first.selector}")`;
|
|
267
|
+
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
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).`,
|
|
271
|
+
target: first,
|
|
272
|
+
// One finding, anchored on the first leaked control, but every other leaked
|
|
273
|
+
// control shares the root cause (background not inert) and is ringed too.
|
|
274
|
+
relatedElements: leaked.slice(1).map((entry) => entry.element),
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const tabindexOnNoninteractive: RuleRun = (sequence) =>
|
|
280
|
+
flagEntries(sequence, (entry) => {
|
|
281
|
+
if (entry.tabIndex !== 0) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (entry.element.getAttribute("tabindex") === null) {
|
|
285
|
+
return null;
|
|
286
|
+
} // implicitly focusable
|
|
287
|
+
const element = entry.element as HTMLElement;
|
|
288
|
+
if (isNativelyFocusable(element)) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
if (isInteractive(element)) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (element.isContentEditable) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const role = element.getAttribute("role");
|
|
298
|
+
if (role && role !== "presentation" && role !== "none") {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
// A keyboard-scrollable region legitimately takes tabindex="0", even when it
|
|
302
|
+
// isn't currently overflowing, so key on computed overflow, not a live size.
|
|
303
|
+
if (isScrollContainer(element)) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
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>).`;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const preferNativeElement: RuleRun = (sequence) =>
|
|
310
|
+
flagEntries(sequence, (entry) => {
|
|
311
|
+
const native = nativeReplacement(entry.element);
|
|
312
|
+
if (!native) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const tag = entry.element.tagName.toLowerCase();
|
|
316
|
+
const role = entry.element.getAttribute("role");
|
|
317
|
+
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.`;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/** autofocus applies to any *focusable* element (even tabindex="-1" non-stops), and
|
|
321
|
+
the browser focuses the first in *document* order — so scan the whole container,
|
|
322
|
+
not `sequence`. The first focusable one wins; the rest are dead intent. */
|
|
323
|
+
const duplicateAutofocus: RuleRun = (_sequence, { container }) => {
|
|
324
|
+
const focusableAutofocus = Array.from(container.querySelectorAll("[autofocus]")).filter(
|
|
325
|
+
(element) => isFocusable(element, { getShadowRoot: true }),
|
|
326
|
+
);
|
|
327
|
+
if (focusableAutofocus.length < 2) {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return focusableAutofocus.slice(1).map((element) => {
|
|
332
|
+
const selector = selectorFor(element);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
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.`,
|
|
336
|
+
target: element,
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const autofocusNotFocusable: RuleRun = (_sequence, { container }) => {
|
|
342
|
+
const out: Finding[] = [];
|
|
343
|
+
for (const element of container.querySelectorAll("[autofocus]")) {
|
|
344
|
+
if (isFocusable(element, { getShadowRoot: true })) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const selector = selectorFor(element);
|
|
349
|
+
out.push({
|
|
350
|
+
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").`,
|
|
351
|
+
target: element,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return out;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const nestedInteractive: RuleRun = (sequence, { container, inSequence }) => {
|
|
359
|
+
const stop = container.parentElement;
|
|
360
|
+
const out: Finding[] = [];
|
|
361
|
+
for (const entry of sequence) {
|
|
362
|
+
for (let node = entry.element.parentElement; node && node !== stop; node = node.parentElement) {
|
|
363
|
+
if (!inSequence.has(node) && !isInteractive(node)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
out.push({
|
|
368
|
+
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.`,
|
|
369
|
+
target: entry,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return out;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** A natively focusable element (<button>, <a href>, <input>, …) carrying an
|
|
380
|
+
explicit tabindex="0". It's already a tab stop, so the attribute is a no-op:
|
|
381
|
+
redundant markup that adds noise and invites a positive value to creep in later.
|
|
382
|
+
tabindex="-1" (removed from the sequence) and positive values (no-positive-tabindex)
|
|
383
|
+
aren't redundant and never reach here. */
|
|
384
|
+
const redundantTabindex: RuleRun = (sequence) =>
|
|
385
|
+
flagEntries(sequence, (entry) => {
|
|
386
|
+
if (entry.tabIndex !== 0) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (entry.element.getAttribute("tabindex") === null) {
|
|
390
|
+
return null;
|
|
391
|
+
} // implicitly focusable, no attribute to remove
|
|
392
|
+
if (!isNativelyFocusable(entry.element)) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
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.`;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export const ALL_RULES = {
|
|
399
|
+
"no-positive-tabindex": {
|
|
400
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
|
|
401
|
+
defaultSeverity: "error",
|
|
402
|
+
run: noPositiveTabIndex,
|
|
403
|
+
},
|
|
404
|
+
"visual-order-mismatch": {
|
|
405
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
|
|
406
|
+
defaultSeverity: "warning",
|
|
407
|
+
run: visualOrderMismatch,
|
|
408
|
+
},
|
|
409
|
+
"missing-accessible-name": {
|
|
410
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
|
|
411
|
+
defaultSeverity: "error",
|
|
412
|
+
run: missingAccessibleName,
|
|
413
|
+
},
|
|
414
|
+
"aria-hidden-focusable": {
|
|
415
|
+
docs: "https://www.w3.org/TR/wai-aria-1.2/#aria-hidden",
|
|
416
|
+
defaultSeverity: "error",
|
|
417
|
+
run: ariaHiddenFocusable,
|
|
418
|
+
},
|
|
419
|
+
"hidden-while-focusable": {
|
|
420
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
|
|
421
|
+
defaultSeverity: "error",
|
|
422
|
+
run: hiddenWhileFocusable,
|
|
423
|
+
},
|
|
424
|
+
"clickable-not-focusable": {
|
|
425
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
|
|
426
|
+
defaultSeverity: "error",
|
|
427
|
+
run: clickableNotFocusable,
|
|
428
|
+
},
|
|
429
|
+
"composite-roving-tabindex": {
|
|
430
|
+
docs: "https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/",
|
|
431
|
+
defaultSeverity: "warning",
|
|
432
|
+
run: compositeRovingTabindex,
|
|
433
|
+
},
|
|
434
|
+
"focus-escapes-modal": {
|
|
435
|
+
docs: "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/",
|
|
436
|
+
defaultSeverity: "error",
|
|
437
|
+
run: focusEscapesModal,
|
|
438
|
+
},
|
|
439
|
+
"tabindex-on-noninteractive": {
|
|
440
|
+
docs: "https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/",
|
|
441
|
+
defaultSeverity: "error",
|
|
442
|
+
run: tabindexOnNoninteractive,
|
|
443
|
+
},
|
|
444
|
+
"prefer-native-element": {
|
|
445
|
+
docs: "https://www.w3.org/TR/using-aria/#firstrule",
|
|
446
|
+
defaultSeverity: "warning",
|
|
447
|
+
run: preferNativeElement,
|
|
448
|
+
},
|
|
449
|
+
"duplicate-autofocus": {
|
|
450
|
+
docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
|
|
451
|
+
defaultSeverity: "warning",
|
|
452
|
+
run: duplicateAutofocus,
|
|
453
|
+
},
|
|
454
|
+
"autofocus-not-focusable": {
|
|
455
|
+
docs: "https://html.spec.whatwg.org/multipage/interaction.html#the-autofocus-attribute",
|
|
456
|
+
defaultSeverity: "warning",
|
|
457
|
+
run: autofocusNotFocusable,
|
|
458
|
+
},
|
|
459
|
+
"nested-interactive": {
|
|
460
|
+
docs: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
|
|
461
|
+
defaultSeverity: "error",
|
|
462
|
+
run: nestedInteractive,
|
|
463
|
+
},
|
|
464
|
+
"redundant-tabindex": {
|
|
465
|
+
docs: "https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex",
|
|
466
|
+
defaultSeverity: "warning",
|
|
467
|
+
run: redundantTabindex,
|
|
468
|
+
},
|
|
469
|
+
} satisfies Record<string, Omit<Rule, "id">>;
|
|
470
|
+
|
|
471
|
+
export const DEFAULT_SEVERITY = Object.fromEntries(
|
|
472
|
+
Object.entries(ALL_RULES).map(([id, rule]) => [id, rule.defaultSeverity]),
|
|
473
|
+
) as Record<RuleId, Severity>;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ALL_RULES } from "./rules.js";
|
|
2
|
+
|
|
3
|
+
export type Severity = "error" | "warning";
|
|
4
|
+
|
|
5
|
+
export type RuleOverride = Severity | "off";
|
|
6
|
+
|
|
7
|
+
/** Serialized shape `audit` returns when asked to format its result: the findings
|
|
8
|
+
grouped by element, or as a human-readable text block. */
|
|
9
|
+
export type AuditFormat = "by-element" | "text";
|
|
10
|
+
|
|
11
|
+
/** A single rule failure on one element. */
|
|
12
|
+
export interface Issue {
|
|
13
|
+
/** Stable rule identifier. */
|
|
14
|
+
rule: RuleId | (string & {});
|
|
15
|
+
/** How serious this finding is. */
|
|
16
|
+
severity: Severity;
|
|
17
|
+
/** Human-readable description of what's wrong. */
|
|
18
|
+
message: string;
|
|
19
|
+
/** Spec link for the rule (WCAG, WAI-ARIA, or ARIA APG). */
|
|
20
|
+
docs?: string;
|
|
21
|
+
/** Other elements sharing this issue's root cause. */
|
|
22
|
+
relatedElements?: Element[];
|
|
23
|
+
/** Approved (silenced) by a `data-ooo-ignore` on the element */
|
|
24
|
+
ignored?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One offending element and every rule it failed. */
|
|
28
|
+
export interface Violation {
|
|
29
|
+
/** The offending element. */
|
|
30
|
+
element: Element;
|
|
31
|
+
/** A CSS-ish path to the element, for messages and logs. */
|
|
32
|
+
selector: string;
|
|
33
|
+
/** Position in the tab sequence, when the element is a tab stop. */
|
|
34
|
+
orderIndex?: number;
|
|
35
|
+
/** The rules this element failed. */
|
|
36
|
+
issues: Issue[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A rule failure with its element references flattened to selector strings, so it
|
|
40
|
+
survives serialization (e.g. `JSON.stringify`) unlike {@link Issue}, which holds
|
|
41
|
+
live `Element`s. The issue shape shared by the formatted views. */
|
|
42
|
+
export interface SerializedIssue {
|
|
43
|
+
rule: AnyRuleId;
|
|
44
|
+
severity: Severity;
|
|
45
|
+
message: string;
|
|
46
|
+
docs?: string;
|
|
47
|
+
/** Selectors of the elements sharing this issue's root cause. */
|
|
48
|
+
related?: string[];
|
|
49
|
+
/** Approved (silenced) by a `data-ooo-ignore` */
|
|
50
|
+
ignored?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `"by-element"` format: one entry per offending element, its issues nested. The
|
|
54
|
+
serializable twin of {@link Violation}. */
|
|
55
|
+
export interface ByElement {
|
|
56
|
+
selector: string;
|
|
57
|
+
orderIndex?: number;
|
|
58
|
+
/** How many issues this element failed (`issues.length`). */
|
|
59
|
+
issueCount: number;
|
|
60
|
+
issues: SerializedIssue[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The type of {@link AuditResult.violations} for a given `format`: a string for
|
|
64
|
+
`"text"`, the matching structured array for the others. */
|
|
65
|
+
export type Formatted<F extends AuditFormat> = F extends "text"
|
|
66
|
+
? string
|
|
67
|
+
: F extends "by-element"
|
|
68
|
+
? ByElement[]
|
|
69
|
+
: never;
|
|
70
|
+
|
|
71
|
+
export type RuleId = keyof typeof ALL_RULES;
|
|
72
|
+
|
|
73
|
+
type AnyRuleId = RuleId | (string & {});
|
|
74
|
+
|
|
75
|
+
export interface SequenceEntry {
|
|
76
|
+
element: Element;
|
|
77
|
+
selector: string;
|
|
78
|
+
/** Zero-based position in the tab sequence. */
|
|
79
|
+
orderIndex: number;
|
|
80
|
+
/** Resolved tabindex (0 if unset but focusable). */
|
|
81
|
+
tabIndex: number;
|
|
82
|
+
/** Bounding rect at analysis time (real layout, browser only). */
|
|
83
|
+
rect: DOMRect;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AuditResult<V = Violation[]> {
|
|
87
|
+
/** True when no enabled rule produced an `error` severity finding. */
|
|
88
|
+
valid: boolean;
|
|
89
|
+
/** Elements in the exact order tabbing will reach them. */
|
|
90
|
+
sequence: SequenceEntry[];
|
|
91
|
+
/** One entry per offending element, each carrying its failed rules. When
|
|
92
|
+
`AuditOptions.format` is set these same findings are reshaped to that view:
|
|
93
|
+
a string for `"text"`, else a {@link Formatted} array. */
|
|
94
|
+
violations: V;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AuditOptions {
|
|
98
|
+
/** Per-rule overrides. Every rule runs at its default severity unless listed
|
|
99
|
+
here: set `"off"` to disable it, or `"error"`/`"warning"` to
|
|
100
|
+
re-grade it. See {@link RuleOverride}. */
|
|
101
|
+
rules?: Partial<Record<RuleId, RuleOverride>>;
|
|
102
|
+
/** Reshape the result's `violations` to this view. Omit to keep the structured
|
|
103
|
+
{@link Violation}`[]`. See {@link Formatted} for the type each one yields. */
|
|
104
|
+
format?: AuditFormat;
|
|
105
|
+
}
|