@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/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
|
+
}
|