@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bart Spaans
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @out-of-order/core
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Under heavy development.** Released, but the API is still changing and may break between versions.
|
|
4
|
+
|
|
5
|
+
Pure focus & keyboard-accessibility analyzer. Wraps [`tabbable`](https://github.com/focus-trap/tabbable) for the focus sequence and applies a rules layer to decide whether that sequence is _valid_: correct order, every stop reachable, visible, and announced. No test-runner or framework deps, just the DOM.
|
|
6
|
+
|
|
7
|
+
> Runs in a real browser only. It reads CSS layout (visibility + bounding rects), which jsdom does not provide.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { audit } from "@out-of-order/core";
|
|
11
|
+
|
|
12
|
+
console.log(audit(document.body, { format: "text" }).violations);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## API
|
|
16
|
+
|
|
17
|
+
`audit(root = document, options?) => AuditResult`
|
|
18
|
+
|
|
19
|
+
Each rule carries a default severity (see the table below), and can be changed or turned off completely.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// e.g. demote a noisy rule, promote another, and turn one off:
|
|
23
|
+
audit(document.body, {
|
|
24
|
+
rules: {
|
|
25
|
+
"visual-order-mismatch": "warning",
|
|
26
|
+
"redundant-tabindex": "error",
|
|
27
|
+
"prefer-native-element": "off",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
All rules are on by default. "Severity" is the default grade, overridable per `AuditOptions.rules`.
|
|
35
|
+
|
|
36
|
+
| Rule | Severity | What it catches |
|
|
37
|
+
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
|
|
38
|
+
| `no-positive-tabindex` | error | `tabindex > 0`, which hijacks natural order |
|
|
39
|
+
| `visual-order-mismatch` | warning | tab order that doesn't follow top→bottom, left→right reading order |
|
|
40
|
+
| `missing-accessible-name` | error | focusable interactive elements with no accessible name |
|
|
41
|
+
| `aria-hidden-focusable` | error | tabbable element inside `aria-hidden="true"` |
|
|
42
|
+
| `hidden-while-focusable` | error | tabbable but invisible (opacity:0, zero size, off-screen, clipped) |
|
|
43
|
+
| `clickable-not-focusable` | error | mouse-only control (role/onclick) the keyboard can't reach |
|
|
44
|
+
| `composite-roving-tabindex` | warning | composite widget (toolbar, tablist, …) with one tab stop per item |
|
|
45
|
+
| `focus-escapes-modal` | error | background controls still tabbable while a modal is open |
|
|
46
|
+
| `tabindex-on-noninteractive` | error | `tabindex="0"` on a role-less, non-interactive element |
|
|
47
|
+
| `prefer-native-element` | warning | generic tag reimplementing a native control via an interactive role |
|
|
48
|
+
| `duplicate-autofocus` | warning | more than one focusable `autofocus` element (only the first ever wins) |
|
|
49
|
+
| `autofocus-not-focusable` | warning | `autofocus` on a non-focusable element (a no-op on load) |
|
|
50
|
+
| `nested-interactive` | error | a focusable control nested inside another focusable element |
|
|
51
|
+
| `redundant-tabindex` | warning | `tabindex="0"` on an already-focusable native control (a no-op) |
|
|
52
|
+
|
|
53
|
+
Adding a rule is just a pure function `(sequence, ctx) => Finding[]` (a `Finding` is an `Issue` minus `severity`, which `audit` stamps on from the rule's default severity or the caller's override, then groups by element into `Violation`s); see `src/rules.ts`.
|
|
54
|
+
|
|
55
|
+
## Live overlay
|
|
56
|
+
|
|
57
|
+
The visual overlay (a numbered path through the tab sequence, every finding ringed in place, details on hover) lives in its own package, [`@out-of-order/trace`](../trace), built on this analyzer. Keeping it separate is deliberate: `audit` stays dependency-light apart from `tabbable`, so the core export remains easy to run inside `page.evaluate` (e.g. for a Playwright adapter).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/** Everything a rule may need beyond the sequence itself. */
|
|
2
|
+
interface RuleContext {
|
|
3
|
+
/** The analyzed root element (lets rules look beyond the tab sequence). */
|
|
4
|
+
container: Element;
|
|
5
|
+
inSequence: Set<Element>;
|
|
6
|
+
}
|
|
7
|
+
/** One problem a rule reports, before grading. */
|
|
8
|
+
interface Finding {
|
|
9
|
+
/** Human-readable description of what's wrong. */
|
|
10
|
+
message: string;
|
|
11
|
+
/** The element the finding points at. A {@link SequenceEntry} when it is a tab
|
|
12
|
+
stop (carries orderIndex and a selector), or a bare Element when it is not. */
|
|
13
|
+
target: SequenceEntry | Element;
|
|
14
|
+
/** Other elements with the same root cause. Ringed alongside `target` but not
|
|
15
|
+
reported as separate findings, so one missing fix doesn't become N violations. */
|
|
16
|
+
relatedElements?: Element[];
|
|
17
|
+
}
|
|
18
|
+
/** Takes the tab sequence (plus context) and returns any findings. Pure. */
|
|
19
|
+
type RuleRun = (sequence: SequenceEntry[], ctx: RuleContext) => Finding[];
|
|
20
|
+
interface Rule {
|
|
21
|
+
/** Stable rule identifier, surfaced on every Violation it produces. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Spec link the rule is grounded in (WCAG, WAI-ARIA, or ARIA APG). */
|
|
24
|
+
docs: string;
|
|
25
|
+
/** Severity the rule fires at unless overridden via `AuditOptions.rules`. */
|
|
26
|
+
defaultSeverity: Severity;
|
|
27
|
+
run: RuleRun;
|
|
28
|
+
}
|
|
29
|
+
declare const ALL_RULES: {
|
|
30
|
+
"no-positive-tabindex": {
|
|
31
|
+
docs: string;
|
|
32
|
+
defaultSeverity: "error";
|
|
33
|
+
run: RuleRun;
|
|
34
|
+
};
|
|
35
|
+
"visual-order-mismatch": {
|
|
36
|
+
docs: string;
|
|
37
|
+
defaultSeverity: "warning";
|
|
38
|
+
run: RuleRun;
|
|
39
|
+
};
|
|
40
|
+
"missing-accessible-name": {
|
|
41
|
+
docs: string;
|
|
42
|
+
defaultSeverity: "error";
|
|
43
|
+
run: RuleRun;
|
|
44
|
+
};
|
|
45
|
+
"aria-hidden-focusable": {
|
|
46
|
+
docs: string;
|
|
47
|
+
defaultSeverity: "error";
|
|
48
|
+
run: RuleRun;
|
|
49
|
+
};
|
|
50
|
+
"hidden-while-focusable": {
|
|
51
|
+
docs: string;
|
|
52
|
+
defaultSeverity: "error";
|
|
53
|
+
run: RuleRun;
|
|
54
|
+
};
|
|
55
|
+
"clickable-not-focusable": {
|
|
56
|
+
docs: string;
|
|
57
|
+
defaultSeverity: "error";
|
|
58
|
+
run: RuleRun;
|
|
59
|
+
};
|
|
60
|
+
"composite-roving-tabindex": {
|
|
61
|
+
docs: string;
|
|
62
|
+
defaultSeverity: "warning";
|
|
63
|
+
run: RuleRun;
|
|
64
|
+
};
|
|
65
|
+
"focus-escapes-modal": {
|
|
66
|
+
docs: string;
|
|
67
|
+
defaultSeverity: "error";
|
|
68
|
+
run: RuleRun;
|
|
69
|
+
};
|
|
70
|
+
"tabindex-on-noninteractive": {
|
|
71
|
+
docs: string;
|
|
72
|
+
defaultSeverity: "error";
|
|
73
|
+
run: RuleRun;
|
|
74
|
+
};
|
|
75
|
+
"prefer-native-element": {
|
|
76
|
+
docs: string;
|
|
77
|
+
defaultSeverity: "warning";
|
|
78
|
+
run: RuleRun;
|
|
79
|
+
};
|
|
80
|
+
"duplicate-autofocus": {
|
|
81
|
+
docs: string;
|
|
82
|
+
defaultSeverity: "warning";
|
|
83
|
+
run: RuleRun;
|
|
84
|
+
};
|
|
85
|
+
"autofocus-not-focusable": {
|
|
86
|
+
docs: string;
|
|
87
|
+
defaultSeverity: "warning";
|
|
88
|
+
run: RuleRun;
|
|
89
|
+
};
|
|
90
|
+
"nested-interactive": {
|
|
91
|
+
docs: string;
|
|
92
|
+
defaultSeverity: "error";
|
|
93
|
+
run: RuleRun;
|
|
94
|
+
};
|
|
95
|
+
"redundant-tabindex": {
|
|
96
|
+
docs: string;
|
|
97
|
+
defaultSeverity: "warning";
|
|
98
|
+
run: RuleRun;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
declare const DEFAULT_SEVERITY: Record<RuleId, Severity>;
|
|
102
|
+
|
|
103
|
+
type Severity = "error" | "warning";
|
|
104
|
+
type RuleOverride = Severity | "off";
|
|
105
|
+
/** Serialized shape `audit` returns when asked to format its result: the findings
|
|
106
|
+
grouped by element, or as a human-readable text block. */
|
|
107
|
+
type AuditFormat = "by-element" | "text";
|
|
108
|
+
/** A single rule failure on one element. */
|
|
109
|
+
interface Issue {
|
|
110
|
+
/** Stable rule identifier. */
|
|
111
|
+
rule: RuleId | (string & {});
|
|
112
|
+
/** How serious this finding is. */
|
|
113
|
+
severity: Severity;
|
|
114
|
+
/** Human-readable description of what's wrong. */
|
|
115
|
+
message: string;
|
|
116
|
+
/** Spec link for the rule (WCAG, WAI-ARIA, or ARIA APG). */
|
|
117
|
+
docs?: string;
|
|
118
|
+
/** Other elements sharing this issue's root cause. */
|
|
119
|
+
relatedElements?: Element[];
|
|
120
|
+
/** Approved (silenced) by a `data-ooo-ignore` on the element */
|
|
121
|
+
ignored?: boolean;
|
|
122
|
+
}
|
|
123
|
+
/** One offending element and every rule it failed. */
|
|
124
|
+
interface Violation {
|
|
125
|
+
/** The offending element. */
|
|
126
|
+
element: Element;
|
|
127
|
+
/** A CSS-ish path to the element, for messages and logs. */
|
|
128
|
+
selector: string;
|
|
129
|
+
/** Position in the tab sequence, when the element is a tab stop. */
|
|
130
|
+
orderIndex?: number;
|
|
131
|
+
/** The rules this element failed. */
|
|
132
|
+
issues: Issue[];
|
|
133
|
+
}
|
|
134
|
+
/** A rule failure with its element references flattened to selector strings, so it
|
|
135
|
+
survives serialization (e.g. `JSON.stringify`) unlike {@link Issue}, which holds
|
|
136
|
+
live `Element`s. The issue shape shared by the formatted views. */
|
|
137
|
+
interface SerializedIssue {
|
|
138
|
+
rule: AnyRuleId;
|
|
139
|
+
severity: Severity;
|
|
140
|
+
message: string;
|
|
141
|
+
docs?: string;
|
|
142
|
+
/** Selectors of the elements sharing this issue's root cause. */
|
|
143
|
+
related?: string[];
|
|
144
|
+
/** Approved (silenced) by a `data-ooo-ignore` */
|
|
145
|
+
ignored?: boolean;
|
|
146
|
+
}
|
|
147
|
+
/** `"by-element"` format: one entry per offending element, its issues nested. The
|
|
148
|
+
serializable twin of {@link Violation}. */
|
|
149
|
+
interface ByElement {
|
|
150
|
+
selector: string;
|
|
151
|
+
orderIndex?: number;
|
|
152
|
+
/** How many issues this element failed (`issues.length`). */
|
|
153
|
+
issueCount: number;
|
|
154
|
+
issues: SerializedIssue[];
|
|
155
|
+
}
|
|
156
|
+
/** The type of {@link AuditResult.violations} for a given `format`: a string for
|
|
157
|
+
`"text"`, the matching structured array for the others. */
|
|
158
|
+
type Formatted<F extends AuditFormat> = F extends "text" ? string : F extends "by-element" ? ByElement[] : never;
|
|
159
|
+
type RuleId = keyof typeof ALL_RULES;
|
|
160
|
+
type AnyRuleId = RuleId | (string & {});
|
|
161
|
+
interface SequenceEntry {
|
|
162
|
+
element: Element;
|
|
163
|
+
selector: string;
|
|
164
|
+
/** Zero-based position in the tab sequence. */
|
|
165
|
+
orderIndex: number;
|
|
166
|
+
/** Resolved tabindex (0 if unset but focusable). */
|
|
167
|
+
tabIndex: number;
|
|
168
|
+
/** Bounding rect at analysis time (real layout, browser only). */
|
|
169
|
+
rect: DOMRect;
|
|
170
|
+
}
|
|
171
|
+
interface AuditResult<V = Violation[]> {
|
|
172
|
+
/** True when no enabled rule produced an `error` severity finding. */
|
|
173
|
+
valid: boolean;
|
|
174
|
+
/** Elements in the exact order tabbing will reach them. */
|
|
175
|
+
sequence: SequenceEntry[];
|
|
176
|
+
/** One entry per offending element, each carrying its failed rules. When
|
|
177
|
+
`AuditOptions.format` is set these same findings are reshaped to that view:
|
|
178
|
+
a string for `"text"`, else a {@link Formatted} array. */
|
|
179
|
+
violations: V;
|
|
180
|
+
}
|
|
181
|
+
interface AuditOptions {
|
|
182
|
+
/** Per-rule overrides. Every rule runs at its default severity unless listed
|
|
183
|
+
here: set `"off"` to disable it, or `"error"`/`"warning"` to
|
|
184
|
+
re-grade it. See {@link RuleOverride}. */
|
|
185
|
+
rules?: Partial<Record<RuleId, RuleOverride>>;
|
|
186
|
+
/** Reshape the result's `violations` to this view. Omit to keep the structured
|
|
187
|
+
{@link Violation}`[]`. See {@link Formatted} for the type each one yields. */
|
|
188
|
+
format?: AuditFormat;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Compute the tab sequence for `root` and grade it against the enabled rules.
|
|
193
|
+
*
|
|
194
|
+
* Browser-only by design: `tabbable` uses real CSS layout to decide visibility and
|
|
195
|
+
* the visual-order rule reads bounding rects, neither meaningful under jsdom.
|
|
196
|
+
*
|
|
197
|
+
* Always returns an `AuditResult`; `options.format` only changes the type of its
|
|
198
|
+
* `violations`, from the structured `Violation[]` to the matching {@link Formatted}
|
|
199
|
+
* view (a string for `"text"`, an array of the named shape otherwise).
|
|
200
|
+
*/
|
|
201
|
+
declare function audit<F extends AuditFormat>(root: ParentNode | undefined, options: AuditOptions & {
|
|
202
|
+
format: F;
|
|
203
|
+
}, customRules?: Rule[]): AuditResult<Formatted<F>>;
|
|
204
|
+
declare function audit(root?: ParentNode, options?: AuditOptions, customRules?: Rule[]): AuditResult;
|
|
205
|
+
|
|
206
|
+
/** Build a short, readable selector path for messages (not guaranteed unique). */
|
|
207
|
+
declare function selectorFor(element: Element): string;
|
|
208
|
+
declare function isInteractive(element: Element): boolean;
|
|
209
|
+
/** Whether `element` opts out of `ruleId` via {@link IGNORE_ATTRIBUTE}. Element-scoped:
|
|
210
|
+
the attribute must sit on the flagged element itself, it is not inherited by
|
|
211
|
+
descendants, so approving one control never silences a whole subtree. */
|
|
212
|
+
declare function isRuleIgnored(element: Element, ruleId: string): boolean;
|
|
213
|
+
/** The intentional ".sr-only"/"visually-hidden" utility: tiny + clipped. We must
|
|
214
|
+
NOT flag it as a bug, since it's the standard way to expose text to screen readers. */
|
|
215
|
+
declare function isScreenReaderOnly(element: Element, rect?: DOMRect): boolean;
|
|
216
|
+
|
|
217
|
+
/** The overlay's CSS namespace; every class the overlay injects (ring, badge,
|
|
218
|
+
tooltip) starts with this, so analysis strips any matching class when building
|
|
219
|
+
selectors and never mistakes the overlay's own markup for page content. The
|
|
220
|
+
overlay package (@out-of-order/trace) redeclares the same prefix for the classes
|
|
221
|
+
it applies; the two must stay in sync. A side-effect-free leaf so the
|
|
222
|
+
page-evaluatable dom.ts can import it without pulling in any CSS. */
|
|
223
|
+
declare const OVERLAY_CLASS_PREFIX = "ooo-";
|
|
224
|
+
|
|
225
|
+
export { type AuditFormat, type AuditOptions, type AuditResult, type ByElement, DEFAULT_SEVERITY, type Finding, type Formatted, type Issue, OVERLAY_CLASS_PREFIX, type Rule, type RuleId, type RuleOverride, type SequenceEntry, type SerializedIssue, type Severity, type Violation, audit, isInteractive, isRuleIgnored, isScreenReaderOnly, selectorFor };
|