@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/src/dom.ts ADDED
@@ -0,0 +1,464 @@
1
+ // Skip the overlay's own ring/badge classes so they never leak into a selector.
2
+ import { OVERLAY_CLASS_PREFIX } from "./overlay-classes.js";
3
+
4
+ /** Walk up from `start` (inclusive) and return the first ancestor matching
5
+ `test`, or null. Pass `element.parentElement` as `start` to exclude `element` itself. */
6
+ function closestAncestor(
7
+ start: Element | null,
8
+ test: (element: Element) => boolean,
9
+ ): Element | null {
10
+ for (let node = start; node; node = node.parentElement) {
11
+ if (test(node)) {
12
+ return node;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
18
+ /** Build a short, readable selector path for messages (not guaranteed unique). */
19
+ export function selectorFor(element: Element): string {
20
+ const parts: string[] = [];
21
+ let node: Element | null = element;
22
+ let depth = 0;
23
+
24
+ while (node && depth < 4) {
25
+ let part = node.tagName.toLowerCase();
26
+ if (node.id) {
27
+ part += `#${node.id}`;
28
+ parts.unshift(part);
29
+
30
+ break;
31
+ }
32
+
33
+ const cls = (node.getAttribute("class") || "")
34
+ .trim()
35
+ .split(/\s+/)
36
+ .filter((c) => c && !c.startsWith(OVERLAY_CLASS_PREFIX));
37
+
38
+ if (cls.length) {
39
+ part += `.${cls[0]}`;
40
+ }
41
+
42
+ parts.unshift(part);
43
+ node = node.parentElement;
44
+ depth++;
45
+ }
46
+
47
+ return parts.join(" > ");
48
+ }
49
+
50
+ /** The native HTML element each interactive ARIA role reimplements, when one
51
+ exists. Roles with no native equivalent live in COMPOSITE_ROLES_NO_NATIVE. */
52
+ const NATIVE_FOR_ROLE: Record<string, string> = {
53
+ button: "<button>",
54
+ link: "<a href>",
55
+ checkbox: '<input type="checkbox">',
56
+ radio: '<input type="radio">',
57
+ switch: '<input type="checkbox" role="switch">',
58
+ slider: '<input type="range">',
59
+ spinbutton: '<input type="number">',
60
+ searchbox: '<input type="search">',
61
+ textbox: "<input> or <textarea>",
62
+ combobox: "<select>",
63
+ option: "<option>",
64
+ };
65
+
66
+ /** Interactive roles with no native HTML equivalent: legitimate custom widgets,
67
+ not controls to swap out (so absent from NATIVE_FOR_ROLE). */
68
+ const COMPOSITE_ROLES_NO_NATIVE = [
69
+ "menuitem",
70
+ "menuitemcheckbox",
71
+ "menuitemradio",
72
+ "tab",
73
+ "treeitem",
74
+ ];
75
+
76
+ const INTERACTIVE_ROLES = [...Object.keys(NATIVE_FOR_ROLE), ...COMPOSITE_ROLES_NO_NATIVE];
77
+
78
+ export function isInteractive(element: Element): boolean {
79
+ const tag = element.tagName.toLowerCase();
80
+ // An <a> is a link (role=link, focusable) only with an href; without one it has
81
+ // no implicit role and isn't focusable. A href-less <a> may still be interactive
82
+ // via an explicit role, so fall through to the role check rather than returning.
83
+ if (tag === "a" && element.hasAttribute("href")) {
84
+ return true;
85
+ }
86
+ if (["button", "select", "textarea", "summary"].includes(tag)) {
87
+ return true;
88
+ }
89
+ if (tag === "input") {
90
+ const type = (element.getAttribute("type") || "text").toLowerCase();
91
+ return type !== "hidden";
92
+ }
93
+ const role = element.getAttribute("role");
94
+ return !!role && INTERACTIVE_ROLES.includes(role);
95
+ }
96
+
97
+ export function hasExplicitName(element: Element): boolean {
98
+ return (
99
+ (element.getAttribute("aria-label") || "").trim() !== "" ||
100
+ (element.getAttribute("title") || "").trim() !== ""
101
+ );
102
+ }
103
+
104
+ export function inAriaHidden(element: Element): boolean {
105
+ return element.closest('[aria-hidden="true"]') !== null;
106
+ }
107
+
108
+ export function isInert(element: Element): boolean {
109
+ return element.closest("[inert]") !== null;
110
+ }
111
+
112
+ const IGNORE_ATTRIBUTE = "data-ooo-ignore";
113
+
114
+ /** Whether `element` opts out of `ruleId` via {@link IGNORE_ATTRIBUTE}. Element-scoped:
115
+ the attribute must sit on the flagged element itself, it is not inherited by
116
+ descendants, so approving one control never silences a whole subtree. */
117
+ export function isRuleIgnored(element: Element, ruleId: string): boolean {
118
+ const value = element.getAttribute(IGNORE_ATTRIBUTE);
119
+ if (value === null) {
120
+ return false;
121
+ }
122
+
123
+ const ids = value.trim().split(/\s+/).filter(Boolean);
124
+ return ids.length === 0 || ids.includes(ruleId);
125
+ }
126
+
127
+ /** Resolved opacity is 0 on the element or any ancestor (so it paints nothing).
128
+ Prefers the native checkVisibility() (which folds in the opacity chain), falling
129
+ back to walking the ancestors on engines that lack it. */
130
+ function isTransparent(element: Element): boolean {
131
+ const check = (element as HTMLElement).checkVisibility;
132
+ if (typeof check === "function") {
133
+ return !check.call(element, { opacityProperty: true });
134
+ }
135
+ return (
136
+ closestAncestor(element, (node) => parseFloat(getComputedStyle(node).opacity || "1") === 0) !==
137
+ null
138
+ );
139
+ }
140
+
141
+ /** The intentional ".sr-only"/"visually-hidden" utility: tiny + clipped. We must
142
+ NOT flag it as a bug, since it's the standard way to expose text to screen readers. */
143
+ export function isScreenReaderOnly(
144
+ element: Element,
145
+ rect: DOMRect = element.getBoundingClientRect(),
146
+ ): boolean {
147
+ if (rect.width > 2 || rect.height > 2) {
148
+ return false;
149
+ }
150
+ const style = getComputedStyle(element);
151
+ return (
152
+ (style.clip !== "" && style.clip !== "auto") ||
153
+ (style.clipPath !== "" && style.clipPath !== "none") ||
154
+ style.overflow === "hidden"
155
+ );
156
+ }
157
+
158
+ /** `element` lies entirely outside an ancestor that clips it away for good on that
159
+ axis. Only `overflow:clip` counts: it establishes no scroll container, so the
160
+ element can never be brought into view. `overflow:hidden` is a scroll container
161
+ the browser scrolls to reveal a focused descendant, so it isn't a dead end and is
162
+ excluded here (see the reveal-on-focus handling in `hiddenReason`). */
163
+ function isClipped(element: Element, rect: DOMRect): boolean {
164
+ for (let node = element.parentElement; node; node = node.parentElement) {
165
+ const containerRect = node.getBoundingClientRect();
166
+ if (containerRect.width === 0 && containerRect.height === 0) {
167
+ continue;
168
+ }
169
+ const style = getComputedStyle(node);
170
+ const outX = rect.right <= containerRect.left || rect.left >= containerRect.right;
171
+ const outY = rect.bottom <= containerRect.top || rect.top >= containerRect.bottom;
172
+ const clipX = style.overflowX === "clip";
173
+ const clipY = style.overflowY === "clip";
174
+ if ((outX && clipX) || (outY && clipY)) {
175
+ return true;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /** Positioned past the page's top-left origin (the classic `left:-9999px` hack).
182
+ Page-relative, not viewport-relative: you can't scroll past 0,0, so only content
183
+ entirely above/left of it is unreachable, not merely scrolled out of view. */
184
+ function isOffPage(element: Element, rect: DOMRect): boolean {
185
+ const win = element.ownerDocument?.defaultView;
186
+ if (!win) {
187
+ return false;
188
+ }
189
+ const pageRight = rect.right + win.scrollX;
190
+ const pageBottom = rect.bottom + win.scrollY;
191
+ return pageRight <= 0 || pageBottom <= 0;
192
+ }
193
+
194
+ /** Why `element` is invisible given a single measured `rect`, or null if it's
195
+ perceivable. Reads only the state as measured; the focus-reveal check lives in
196
+ the exported `hiddenReason`. Skips the intentional screen-reader-only pattern. */
197
+ function staticHiddenReason(element: Element, rect: DOMRect): string | null {
198
+ if (isScreenReaderOnly(element, rect)) {
199
+ return null;
200
+ }
201
+ // An <area> has no box of its own; its hit region lives on the associated
202
+ // <img usemap>, so getBoundingClientRect() is always 0×0 and would trip the
203
+ // zero-size check below. tabbable already gates an area on its image's
204
+ // visibility, so an area that's in the sequence is perceivable; never flag it.
205
+ if (element.tagName.toLowerCase() === "area") {
206
+ return null;
207
+ }
208
+ if (isTransparent(element)) {
209
+ return "opacity:0, invisible but still tabbable";
210
+ }
211
+ if (rect.width < 1 || rect.height < 1) {
212
+ return "zero size, no visible target";
213
+ }
214
+ if (isOffPage(element, rect)) {
215
+ return "positioned off-screen (e.g. left:-9999px), invisible but still tabbable";
216
+ }
217
+ if (isClipped(element, rect)) {
218
+ return "clipped by an overflow:clip ancestor";
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /** Properties whose value can flip an element from hidden to visible. A focus rule
224
+ that touches one of these can reveal the element; a focus rule that only tweaks
225
+ e.g. `outline` or `color` cannot, so it must not exonerate a hidden control. */
226
+ const REVEALING_PROPS = [
227
+ "opacity",
228
+ "visibility",
229
+ "display",
230
+ "position",
231
+ "left",
232
+ "right",
233
+ "top",
234
+ "bottom",
235
+ "inset",
236
+ "clip",
237
+ "clip-path",
238
+ "transform",
239
+ "translate",
240
+ "scale",
241
+ "width",
242
+ "height",
243
+ "max-width",
244
+ "max-height",
245
+ "overflow",
246
+ ];
247
+
248
+ function revealSelectors(rules: CSSRuleList): string[] {
249
+ return Array.from(rules).flatMap((rule) => {
250
+ // A CSSStyleRule also exposes cssRules (CSS nesting), so match it before the
251
+ // grouping-rule descent below, or every style rule gets swallowed as a group.
252
+ if (rule instanceof CSSStyleRule) {
253
+ if (
254
+ !/:focus/i.test(rule.selectorText) ||
255
+ !REVEALING_PROPS.some((p) => rule.style.getPropertyValue(p) !== "")
256
+ ) {
257
+ return [];
258
+ }
259
+ // Drop the focus pseudos so the selector matches the element at rest: what it
260
+ // looks like *before* focus is what we're grading against.
261
+ const resting = rule.selectorText.replace(/:focus(?:-visible|-within)?/gi, "").trim();
262
+ return resting ? [resting] : [];
263
+ }
264
+ return "cssRules" in rule ? revealSelectors((rule as CSSGroupingRule).cssRules) : [];
265
+ });
266
+ }
267
+
268
+ /**
269
+ * The resting-state selectors of every readable stylesheet rule that keys on focus
270
+ * *and* sets a property that could reveal a hidden element. A hidden tab stop that
271
+ * matches one of these is the reveal-on-focus pattern (skip links, on-focus
272
+ * controls), visible exactly when a keyboard user reaches it, so it isn't a bug.
273
+ */
274
+ export function focusRevealSelectors(doc: Document): string[] {
275
+ const sheets = [...Array.from(doc.styleSheets), ...(doc.adoptedStyleSheets ?? [])];
276
+ return sheets.flatMap((sheet) => {
277
+ try {
278
+ return revealSelectors(sheet.cssRules);
279
+ } catch {
280
+ return [];
281
+ }
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Why a tab stop is effectively invisible while still being tabbable, or null if
287
+ * it's genuinely perceivable. A control hidden at rest but revealed when it
288
+ * receives focus (the skip-link / reveal-on-focus pattern) is perceivable exactly
289
+ * when a keyboard user reaches it, so it's exonerated. Pass `revealOnFocus` from
290
+ * {@link focusRevealSelectors} to enable that exemption.
291
+ */
292
+ export function hiddenReason(
293
+ element: Element,
294
+ rect: DOMRect,
295
+ revealOnFocus: string[] = [],
296
+ ): string | null {
297
+ const reason = staticHiddenReason(element, rect);
298
+ if (!reason) {
299
+ return null;
300
+ }
301
+ // Hidden at rest, but a focus rule reveals it: not a bug. A stripped selector can
302
+ // be invalid (e.g. an emptied :not()), which matches nothing and exonerates nothing.
303
+ const revealed = revealOnFocus.some((selector) => {
304
+ try {
305
+ return element.matches(selector);
306
+ } catch {
307
+ return false;
308
+ }
309
+ });
310
+ return revealed ? null : reason;
311
+ }
312
+
313
+ /** Native HTML elements that are interactive (and focusable) on their own. A role
314
+ on one of these is at most redundant, never a reimplementation, so they're the
315
+ elements `looksClickable`/`nativeReplacement` skip. */
316
+ const NATIVE_INTERACTIVE_TAGS = ["a", "button", "input", "select", "textarea", "summary", "option"];
317
+
318
+ /** Looks interactive (an interactive role or an inline click handler) but is not
319
+ a natively focusable element, the signature of a mouse-only control. */
320
+ export function looksClickable(element: Element): boolean {
321
+ const tag = element.tagName.toLowerCase();
322
+ if (NATIVE_INTERACTIVE_TAGS.includes(tag)) {
323
+ return false;
324
+ }
325
+ const role = element.getAttribute("role");
326
+ if (role && INTERACTIVE_ROLES.includes(role)) {
327
+ return true;
328
+ }
329
+ return element.hasAttribute("onclick");
330
+ }
331
+
332
+ /** If `element` reimplements a native control via an interactive role on a generic
333
+ tag, the native element to use instead; else null. Native interactive tags (and a
334
+ merely-redundant role on one) return null; there's nothing to swap. */
335
+ export function nativeReplacement(element: Element): string | null {
336
+ if (NATIVE_INTERACTIVE_TAGS.includes(element.tagName.toLowerCase())) {
337
+ return null;
338
+ }
339
+ const role = element.getAttribute("role");
340
+ if (!role) {
341
+ return null;
342
+ }
343
+ return NATIVE_FOR_ROLE[role] ?? null;
344
+ }
345
+
346
+ /** Elements that are focusable on their own, before any tabindex, mirroring the
347
+ native-focusable set the HTML spec defines (sans the tabindex attribute) */
348
+ export function isNativelyFocusable(element: Element): boolean {
349
+ const tag = element.tagName.toLowerCase();
350
+ if (tag === "a" || tag === "area") {
351
+ return element.hasAttribute("href");
352
+ }
353
+
354
+ if (["button", "select", "textarea", "iframe"].includes(tag)) {
355
+ return true;
356
+ }
357
+
358
+ if (tag === "input") {
359
+ return (element.getAttribute("type") || "text").toLowerCase() !== "hidden";
360
+ }
361
+
362
+ if (tag === "audio" || tag === "video") {
363
+ return element.hasAttribute("controls");
364
+ }
365
+
366
+ if (tag === "summary") {
367
+ // Only the first <summary> child of a <details> is focusable.
368
+ const parent = element.parentElement;
369
+ return (
370
+ parent?.tagName.toLowerCase() === "details" && parent.querySelector("summary") === element
371
+ );
372
+ }
373
+ return false;
374
+ }
375
+
376
+ const COMPOSITE_ROLES = [
377
+ "toolbar",
378
+ "tablist",
379
+ "menu",
380
+ "menubar",
381
+ "radiogroup",
382
+ "listbox",
383
+ "tree",
384
+ "grid",
385
+ ];
386
+
387
+ /** The nearest ancestor that is a composite widget (per ARIA, these should expose
388
+ a single tab stop and move between items with the arrow keys). */
389
+ export function compositeAncestor(element: Element): Element | null {
390
+ return closestAncestor(element.parentElement, (node) => {
391
+ const role = node.getAttribute("role");
392
+
393
+ return !!role && COMPOSITE_ROLES.includes(role);
394
+ });
395
+ }
396
+
397
+ /** Reachable by keyboard via something other than Tab (negative/roving tabindex, an
398
+ aria-activedescendant ancestor, or a composite widget), so it's deliberately out
399
+ of the Tab sequence, not unreachable. Read the tabindex *attribute*, not the
400
+ .tabIndex IDL property, which is -1 for everything non-focusable. */
401
+ export function isFocusManaged(element: Element): boolean {
402
+ const tabindex = element.getAttribute("tabindex");
403
+ if (tabindex !== null && Number(tabindex) < 0) {
404
+ return true;
405
+ }
406
+ if (compositeAncestor(element)) {
407
+ return true;
408
+ }
409
+ return element.closest("[aria-activedescendant]") !== null;
410
+ }
411
+
412
+ /** The nearest fixed/sticky ancestor-or-self: the scroll-detached "chrome" layer
413
+ (sticky navbar, fixed header) the element rides in, or null if it sits in
414
+ normal flow. */
415
+ export function floatingAncestor(element: Element): Element | null {
416
+ return closestAncestor(element, (node) => {
417
+ const pos = getComputedStyle(node).position;
418
+
419
+ return pos === "fixed" || pos === "sticky";
420
+ });
421
+ }
422
+
423
+ /** A keyboard-scrollable region: computed overflow is auto/scroll on either axis.
424
+ Keys on computed overflow, not a live scrollWidth>clientWidth test, so a
425
+ scrollable box isn't missed just because its content currently fits. */
426
+ export function isScrollContainer(element: Element): boolean {
427
+ const scrollable = (value: string) =>
428
+ value === "auto" || value === "scroll" || value === "overlay";
429
+ const style = getComputedStyle(element);
430
+ return scrollable(style.overflowX) || scrollable(style.overflowY);
431
+ }
432
+
433
+ /** The nearest ancestor that independently scrolls `element` (excluding itself), or
434
+ null if it only rides the document scroll. Two stops with different scroll
435
+ ancestors don't share a scroll context, so the visual-order check skips the pair
436
+ (their on-screen relationship moves with the scrollbar). */
437
+ export function scrollAncestor(element: Element): Element | null {
438
+ return closestAncestor(element.parentElement, isScrollContainer);
439
+ }
440
+
441
+ /** Whether `element` is actually rendered, so a modal toggled shut with `hidden` or
442
+ `display:none` doesn't count as open (its background would otherwise be flagged
443
+ as leaking focus while nothing is on screen). */
444
+ function isDisplayed(element: Element): boolean {
445
+ const check = (element as HTMLElement).checkVisibility;
446
+ if (typeof check === "function") {
447
+ return check.call(element, { visibilityProperty: true });
448
+ }
449
+ // Fallback for engines without checkVisibility: walk the display chain.
450
+ return closestAncestor(element, (node) => getComputedStyle(node).display === "none") === null;
451
+ }
452
+
453
+ /** The first genuinely-modal, on-screen container in `root`: a `<dialog>:modal`
454
+ (opened via showModal()) or an `aria-modal="true"` element. Non-modal dialogs
455
+ (show() or a bare `open`) and hidden ones are excluded, since their background is
456
+ meant to stay interactive. */
457
+ export function openModal(root: ParentNode): Element | null {
458
+ for (const element of root.querySelectorAll('dialog:modal, [aria-modal="true"]')) {
459
+ if (isDisplayed(element)) {
460
+ return element;
461
+ }
462
+ }
463
+ return null;
464
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { audit } from "./audit.js";
2
+ export { DEFAULT_SEVERITY } from "./rules.js";
3
+ export type { Rule, Finding } from "./rules.js";
4
+ export { isInteractive, isScreenReaderOnly, selectorFor, isRuleIgnored } from "./dom.js";
5
+ export { OVERLAY_CLASS_PREFIX } from "./overlay-classes.js";
6
+ export type {
7
+ AuditOptions,
8
+ AuditFormat,
9
+ Formatted,
10
+ ByElement,
11
+ SerializedIssue,
12
+ RuleId,
13
+ RuleOverride,
14
+ SequenceEntry,
15
+ Severity,
16
+ AuditResult,
17
+ Issue,
18
+ Violation,
19
+ } from "./types.js";
@@ -0,0 +1,7 @@
1
+ /** The overlay's CSS namespace; every class the overlay injects (ring, badge,
2
+ tooltip) starts with this, so analysis strips any matching class when building
3
+ selectors and never mistakes the overlay's own markup for page content. The
4
+ overlay package (@out-of-order/trace) redeclares the same prefix for the classes
5
+ it applies; the two must stay in sync. A side-effect-free leaf so the
6
+ page-evaluatable dom.ts can import it without pulling in any CSS. */
7
+ export const OVERLAY_CLASS_PREFIX = "ooo-";