@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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@out-of-order/core",
3
+ "version": "0.1.0",
4
+ "description": "Pure focus & keyboard-accessibility analyzer. Wraps tabbable, returns a plain result.",
5
+ "keywords": [
6
+ "a11y",
7
+ "accessibility",
8
+ "focus",
9
+ "tab-order",
10
+ "tabbable",
11
+ "tabindex"
12
+ ],
13
+ "homepage": "https://github.com/spaansba/out-of-order#readme",
14
+ "bugs": "https://github.com/spaansba/out-of-order/issues",
15
+ "license": "MIT",
16
+ "author": "Bart Spaans",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/spaansba/out-of-order.git",
20
+ "directory": "packages/core"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "dom-accessibility-api": "^0.7.1",
41
+ "tabbable": "^6.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@vitest/browser": "^2.1.0",
45
+ "playwright": "^1.47.0",
46
+ "tsup": "^8.2.0",
47
+ "typescript": "^5.5.0",
48
+ "vitest": "^2.1.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsup src/index.ts --format esm --dts --clean",
52
+ "dev": "tsup src/index.ts --format esm --dts --watch",
53
+ "test": "vitest run",
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { tabbable, getTabIndex } from "tabbable";
2
+ import type {
3
+ AuditOptions,
4
+ AuditFormat,
5
+ Formatted,
6
+ SequenceEntry,
7
+ Severity,
8
+ AuditResult,
9
+ Issue,
10
+ Violation,
11
+ ByElement,
12
+ RuleId,
13
+ } from "./types.js";
14
+ import { selectorFor, isRuleIgnored } from "./dom.js";
15
+ import { ALL_RULES, type Finding, type Rule } from "./rules.js";
16
+
17
+ /** Fold a rule's caller override against its default into a final decision: is it
18
+ on, and at what severity? A missing override keeps the default; `"off"`
19
+ disables it; a severity string re-grades it. */
20
+ function resolveRule(options: AuditOptions, rule: Rule): { enabled: boolean; severity: Severity } {
21
+ // Custom rule ids aren't in the RuleId union. A miss returns undefined.
22
+ const setting = options.rules?.[rule.id as RuleId];
23
+ if (setting === undefined) {
24
+ return { enabled: true, severity: rule.defaultSeverity };
25
+ }
26
+ if (setting === "off") {
27
+ return { enabled: false, severity: rule.defaultSeverity };
28
+ }
29
+
30
+ return { enabled: true, severity: setting };
31
+ }
32
+
33
+ function toIssue(finding: Finding, rule: Rule, severity: Severity): Issue {
34
+ return {
35
+ rule: rule.id,
36
+ severity,
37
+ message: finding.message,
38
+ docs: rule.docs,
39
+ relatedElements: finding.relatedElements,
40
+ };
41
+ }
42
+
43
+ function locate(finding: Finding): {
44
+ element: Element;
45
+ selector: string;
46
+ orderIndex?: number;
47
+ } {
48
+ const { target } = finding;
49
+ return "orderIndex" in target
50
+ ? {
51
+ element: target.element,
52
+ selector: target.selector,
53
+ orderIndex: target.orderIndex,
54
+ }
55
+ : { element: target, selector: selectorFor(target) };
56
+ }
57
+
58
+ /**
59
+ * Compute the tab sequence for `root` and grade it against the enabled rules.
60
+ *
61
+ * Browser-only by design: `tabbable` uses real CSS layout to decide visibility and
62
+ * the visual-order rule reads bounding rects, neither meaningful under jsdom.
63
+ *
64
+ * Always returns an `AuditResult`; `options.format` only changes the type of its
65
+ * `violations`, from the structured `Violation[]` to the matching {@link Formatted}
66
+ * view (a string for `"text"`, an array of the named shape otherwise).
67
+ */
68
+ export function audit<F extends AuditFormat>(
69
+ root: ParentNode | undefined,
70
+ options: AuditOptions & { format: F },
71
+ customRules?: Rule[],
72
+ ): AuditResult<Formatted<F>>;
73
+ export function audit(root?: ParentNode, options?: AuditOptions, customRules?: Rule[]): AuditResult;
74
+ export function audit(
75
+ root: ParentNode = document,
76
+ options: AuditOptions = {},
77
+ customRules: Rule[] = [],
78
+ ): AuditResult<Violation[] | string | ByElement[]> {
79
+ const container =
80
+ root.nodeType === 9 /* Node.DOCUMENT_NODE */
81
+ ? (root as Document).documentElement
82
+ : (root as Element);
83
+
84
+ if (!container) {
85
+ return finalize({ valid: true, sequence: [], violations: [] }, options.format);
86
+ }
87
+
88
+ const elements = tabbable(container, {
89
+ getShadowRoot: true,
90
+ });
91
+
92
+ const sequence: SequenceEntry[] = elements.map((element, orderIndex) => ({
93
+ element,
94
+ orderIndex,
95
+ selector: selectorFor(element),
96
+ tabIndex: getTabIndex(element),
97
+ rect: element.getBoundingClientRect(),
98
+ }));
99
+
100
+ const ctx = {
101
+ container,
102
+ inSequence: new Set(sequence.map((entry) => entry.element)),
103
+ };
104
+
105
+ const builtins: Rule[] = Object.entries(ALL_RULES).map(([id, def]) => ({
106
+ id,
107
+ ...def,
108
+ }));
109
+
110
+ const byElement = new Map<Element, Violation>();
111
+ for (const rule of [...builtins, ...customRules]) {
112
+ const { enabled, severity } = resolveRule(options, rule);
113
+ if (!enabled) {
114
+ continue;
115
+ }
116
+
117
+ for (const finding of rule.run(sequence, ctx)) {
118
+ const { element, selector, orderIndex } = locate(finding);
119
+ let violation = byElement.get(element);
120
+ if (!violation) {
121
+ violation = { element, selector, orderIndex, issues: [] };
122
+ byElement.set(element, violation);
123
+ }
124
+ const issue = toIssue(finding, rule, severity);
125
+ if (isRuleIgnored(element, rule.id)) {
126
+ issue.ignored = true;
127
+ }
128
+ violation.issues.push(issue);
129
+ }
130
+ }
131
+
132
+ const violations = [...byElement.values()].sort(
133
+ (a, b) => (a.orderIndex ?? Infinity) - (b.orderIndex ?? Infinity),
134
+ );
135
+ for (const violation of violations) {
136
+ violation.issues.sort((a, b) =>
137
+ a.severity === b.severity ? 0 : a.severity === "error" ? -1 : 1,
138
+ );
139
+ }
140
+
141
+ const hasErrors = violations.some((violation) =>
142
+ violation.issues.some((issue) => issue.severity === "error" && !issue.ignored),
143
+ );
144
+
145
+ return finalize({ valid: !hasErrors, sequence, violations }, options.format);
146
+ }
147
+
148
+ // Leaves `valid` and `sequence` untouched; only reshapes `violations`.
149
+ function finalize(
150
+ result: AuditResult,
151
+ format?: AuditFormat,
152
+ ): AuditResult<Violation[] | string | ByElement[]> {
153
+ if (!format) {
154
+ return result;
155
+ }
156
+ return { ...result, violations: reshape(result.violations, format) };
157
+ }
158
+
159
+ function reshape(violations: Violation[], format: AuditFormat): string | ByElement[] {
160
+ switch (format) {
161
+ case "text":
162
+ return renderText(violations);
163
+ case "by-element":
164
+ return byElement(violations);
165
+ }
166
+ }
167
+
168
+ const related = (issue: Issue): string[] | undefined => issue.relatedElements?.map(selectorFor);
169
+
170
+ function byElement(violations: Violation[]): ByElement[] {
171
+ return violations.map((violation) => ({
172
+ selector: violation.selector,
173
+ orderIndex: violation.orderIndex,
174
+ issueCount: violation.issues.length,
175
+ issues: violation.issues.map((issue) => ({
176
+ rule: issue.rule,
177
+ severity: issue.severity,
178
+ message: issue.message,
179
+ docs: issue.docs,
180
+ related: related(issue),
181
+ ignored: issue.ignored,
182
+ })),
183
+ }));
184
+ }
185
+
186
+ function renderText(violations: Violation[]): string {
187
+ if (!violations.length) {
188
+ return "No tab-order issues.";
189
+ }
190
+ return violations
191
+ .map((violation) => {
192
+ const pos = violation.orderIndex !== undefined ? `#${violation.orderIndex + 1} ` : "";
193
+ const issues = violation.issues
194
+ .map((issue) => {
195
+ const rel = related(issue);
196
+ return (
197
+ ` - ${issue.severity.toUpperCase()} [${issue.rule}] ${issue.message}` +
198
+ (rel?.length ? ` (related: ${rel.join(", ")})` : "") +
199
+ (issue.ignored ? " (ignored via data-ooo-ignore)" : "")
200
+ );
201
+ })
202
+ .join("\n");
203
+ return `${pos}${violation.selector}\n${issues}`;
204
+ })
205
+ .join("\n\n");
206
+ }