Public: anyone can read this; the crew can change it.

One spec helper checks a component's controls, names, tap targets and right-to-left rendering #12021

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12016-one-spec-helper-checks-a-component-s-con → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/12016-one-spec-helper-checks-a-component-s-con was merged into main as 78a993c.

Review

Sign in to review this change.
12 files changed +559 −0
test/ui-spec/README.md added +11
@@ -0,0 +1,11 @@
1 # Component specs
2
3 `helper.ts` checks a component's rendered HTML: every control has a role and an accessible name,
4 a non-native control has `tabindex` and a key handler, a control is 44 px on a thumb (read from
5 `src/web/style/components.ts`), and nothing is hard-coded left or right: inline `style`, `align`,
6 and every stylesheet rule that matches a rendered node.
7
8 Add a spec: `test/ui-spec/<export>.test.ts` calling `expectAccessible(component(...))`, then delete
9 the export's name from `EXCLUDED` in `coverage.test.ts` (that test fails until you do). A known gap
10 is named in the call, never passed silently: `{ unsized: ["a"] }` for a control the stylesheet gives
11 no size, `{ physical: ["select.input"] }` for a rule with a physical side, `{ tapTargets: false }`.
test/ui-spec/btn.test.ts added +10
@@ -0,0 +1,10 @@
1 import { test } from "bun:test";
2 import { btn } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 test("btn: link, submit, labelled, toggle", () => {
6 expectAccessible(btn("Save"));
7 expectAccessible(btn("Docs", { href: "/docs", external: true, variant: "outline" }));
8 expectAccessible(btn("Big", { size: "lg", variant: "primary" }));
9 expectAccessible(btn("Pin", { pressed: true, type: "button" }));
10 });
test/ui-spec/confirmAction.test.ts added +8
@@ -0,0 +1,8 @@
1 import { test } from "bun:test";
2 import { confirmAction } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 test("confirmAction: a plain link to the confirm page", () => {
6 expectAccessible(confirmAction({ action: "/x/delete", verb: "Delete" }));
7 expectAccessible(confirmAction({ action: "/x/delete", verb: "Delete", label: "Delete app notes" }));
8 });
test/ui-spec/coverage.test.ts added +39
@@ -0,0 +1,39 @@
1 import { expect, test } from "bun:test";
2 import { existsSync } from "node:fs";
3 import * as ui from "../../src/web/ui.ts";
4
5 /**
6 * Every component function `src/web/ui.ts` exports has `test/ui-spec/<name>.test.ts`,
7 * or is named here. Shrink this list: write the spec, delete the name. Reasons
8 * are in the comment: "text" returns a string or a number, not markup;
9 * "size" is a control the stylesheet does not yet make 44 px on a thumb
10 * (tabs 40, seg 28, a small button 36); "todo" is simply unwritten.
11 */
12 const EXCLUDED: Record<string, string> = {
13 // not markup
14 timeAgo: "text", fileSize: "text", tokenCount: "text", queuedPhrase: "text", servedPhrase: "text",
15 envPhrase: "text", envTone: "text", hueOf: "text", labelCss: "text", hintAttr: "attribute text", when: "helper",
16 // stylesheet gaps the helper found
17 tabs: "size", seg: "size", pager: "size", copyField: "size",
18 // todo
19 avatar: "todo", badge: "todo", card: "todo", chartBox: "todo", counts: "todo", dormantNote: "todo",
20 dot: "todo", envActions: "todo", envFacts: "todo", envLine: "todo", envStateBadge: "todo",
21 icon: "todo", keyHint: "todo", kbd: "todo", labelChip: "todo", listCard: "todo", menu: "todo",
22 messageText: "todo", meter: "todo", nameMark: "todo", originTag: "todo", pill: "todo",
23 previewDataNote: "todo", repoCounts: "todo", row: "todo", runStateBadge: "todo", seriesDot: "todo",
24 sha: "todo", sizeWithTokens: "todo", stat: "todo", stateBadge: "todo", terminalCommand: "todo",
25 terminalLine: "todo", time: "todo", tlComment: "todo", tlEvent: "todo", userLink: "todo",
26 userTag: "todo", verdictBadge: "todo",
27 };
28
29 const components = Object.entries(ui).filter(([, v]) => typeof v === "function").map(([k]) => k);
30
31 test("every exported component has a spec or a named exclusion", () => {
32 const missing = components.filter((n) => !(n in EXCLUDED) && !existsSync(`${import.meta.dir}/${n}.test.ts`));
33 expect(missing).toEqual([]);
34 });
35
36 test("the exclusion list only shrinks: no stale or already-specced names", () => {
37 const stale = Object.keys(EXCLUDED).filter((n) => !components.includes(n) || existsSync(`${import.meta.dir}/${n}.test.ts`));
38 expect(stale).toEqual([]);
39 });
test/ui-spec/crumbs.test.ts added +11
@@ -0,0 +1,11 @@
1 import { test } from "bun:test";
2 import { crumbs } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 // The stylesheet gives a breadcrumb link no height: named here, not passed silently.
6 const UNSIZED = { unsized: ["a"] };
7
8 test("crumbs: links, a current page, a mono path", () => {
9 expectAccessible(crumbs([{ label: "Apps", href: "/apps" }, { label: "notes", href: "/notes" }, { label: "Settings" }]), UNSIZED);
10 expectAccessible(crumbs([{ label: "src", href: "/s" }, { label: "a.ts" }], { mono: true, ariaLabel: "Path" }), UNSIZED);
11 });
test/ui-spec/disclosure.test.ts added +8
@@ -0,0 +1,8 @@
1 import { test } from "bun:test";
2 import { disclosure } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 test("disclosure: a summary names the fold", () => {
6 expectAccessible(disclosure("Details", "Body text"));
7 expectAccessible(disclosure("More", "Body", { small: true, label: "More about this" }));
8 });
test/ui-spec/empty.test.ts added +9
@@ -0,0 +1,9 @@
1 import { test } from "bun:test";
2 import { btn, empty } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5
outdated

Second import from the same module, after other code: merge into line 2.

6 test("empty: a line, a note, an action", () => {
7 expectAccessible(empty({ icon: "check", title: "Nothing here", note: "Yet" }));
8 expectAccessible(empty({ title: "No apps", action: btn("Make one", { href: "/new", variant: "primary" }) }));
9 });
test/ui-spec/field.test.ts added +14
@@ -0,0 +1,14 @@
1 import { test } from "bun:test";
2 import { field } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 // `select.input` pads its right side for the arrow: physical, and named here until it flips.
6 const SELECT = { physical: ["select.input"] };
7
8 test("field: text, textarea, select, checkbox, error, hidden label", () => {
9 expectAccessible(field({ label: "Name", name: "name", hint: "Shown to others", required: true }));
10 expectAccessible(field({ label: "Notes", name: "notes", type: "textarea", error: "Too long" }));
11 expectAccessible(field({ label: "Kind", name: "kind", type: "select", options: [{ value: "a", label: "A" }] }), SELECT);
12 expectAccessible(field({ label: "Public", name: "pub", type: "checkbox", checked: true }));
13 expectAccessible(field({ label: "Search", name: "q", labelHidden: true }));
14 });
test/ui-spec/helper.test.ts added +45
@@ -0,0 +1,45 @@
1 import { describe, expect, test } from "bun:test";
2 import { expectAccessible, physicalRules, parse, problems } from "./helper.ts";
3
4 describe("ui-spec helper catches what it says it catches", () => {
5 test("a control with no name or role", () => {
6 expect(problems(`<button class="btn"></button>`).join()).toContain("no accessible name");
7 expect(problems(`<div role="" tabindex="0" onclick="x()"></div>`).join()).toContain("no role");
8 });
9 test("a clickable div needs tabindex and a key handler", () => {
10 const p = problems(`<div role="button" onclick="x()">Go</div>`).join();
11 expect(p).toContain("no tabindex");
12 expect(p).toContain("no key handler");
13 expect(problems(`<div role="button" tabindex="0" onclick="x()" onkeydown="x()">Go</div>`, { unsized: ["div"] })).toEqual([]);
14 });
15 test("a small tap target fails, a 44px one passes", () => {
16 expect(problems(`<a class="btn" data-size="sm" href="/">Go</a>`).join()).toContain("36px");
17 expect(problems(`<a class="btn" href="/">Go</a>`)).toEqual([]);
18 });
19 test("left/right in inline style fails", () => {
20 expect(problems(`<a href="/" style="margin-left:4px">Go</a>`).join()).toContain("left/right");
21 expect(problems(`<a class="btn" href="/" style="margin-inline-start:4px">Go</a>`)).toEqual([]);
22 });
23 test("labels name inputs", () => {
24 expect(problems(`<input id="a" class="input">`).join()).toContain("no accessible name");
25 expectAccessible(`<label for="a">Name</label><input id="a" class="input">`);
26 });
27 test("a control with no size rule fails unless named, and a link in prose is exempt", () => {
28 expect(problems(`<div role="button" tabindex="0" onclick="x()" onkeydown="x()">Go</div>`).join()).toContain(
29 "no size rule",
30 );
31 expect(problems(`<a href="/x">Go</a>`).join()).toContain("no size rule");
32 expect(problems(`<a href="/x">Go</a>`, { unsized: ["a"] })).toEqual([]);
33 expect(problems(`<p>Read the <a href="/docs">docs</a> first.</p>`)).toEqual([]);
34 });
35 test("right-to-left: a stylesheet rule on a rendered node that names left or right fails", () => {
36 const bad = problems(`<select class="input" id="s" name="s"></select><label for="s">K</label>`);
37 expect(bad.join()).toContain('stylesheet rule "select.input" hard-codes padding-right');
38 // Named on purpose, it passes; a node the rule does not match is not blamed.
39 expect(physicalRules(parse(`<select class="input"></select>`), ["select.input"])).toEqual([]);
40 expect(physicalRules(parse(`<span class="pill">1</span>`))).toEqual([]);
41 });
42 test("expectAccessible throws for physical left/right", () => {
43 expect(() => expectAccessible(`<a class="btn" href="/" style="float:left">Go</a>`)).toThrow();
44 });
45 });
test/ui-spec/helper.ts added +383
@@ -0,0 +1,383 @@
1 /**
2 * One helper for component specs (#12016). It takes a component's rendered
3 * HTML and reports what a keyboard, a screen reader, a thumb and a
4 * right-to-left reader would trip over. No browser, no dependency: a small
5 * tag parser and the forge's own stylesheet, read as text.
6 *
7 * expectAccessible(btn("Save")); // throws, listing every problem
8 * problems(markup) // the list, for a negative test
9 */
10 import { expect } from "bun:test";
11 import { components } from "../../src/web/style/components.ts";
12
13 export interface Node {
14 tag: string;
15 attrs: Record<string, string>;
16 children: Node[];
17 parent: Node | null;
18 /** Text directly inside this element, in order with children flattened by `text()`. */
19 texts: string[];
20 }
21
22 const VOID = new Set(["input", "br", "hr", "img", "meta", "link", "use", "path", "circle", "rect", "source"]);
23
24 export function parse(markup: string | { toString(): string }): Node {
25 const src = String(markup).replace(/<!--[\s\S]*?-->/g, "");
26 const root: Node = { tag: "#root", attrs: {}, children: [], parent: null, texts: [] };
27 let cur = root;
28 const re = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>|([^<]+)/g;
29 for (let m = re.exec(src); m; m = re.exec(src)) {
30 if (m[4] !== undefined) {
31 cur.texts.push(decode(m[4]));
32 continue;
33 }
34 const tag = m[2]!.toLowerCase();
35 if (m[1]) {
36 let n: Node | null = cur;
37 while (n && n.tag !== tag) n = n.parent;
38 if (n?.parent) cur = n.parent;
39 continue;
40 }
41 const attrs: Record<string, string> = {};
42 const are = /([^\s=\/"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
43 for (let a = are.exec(m[3]!); a; a = are.exec(m[3]!)) {
44 attrs[a[1]!.toLowerCase()] = decode(a[2] ?? a[3] ?? a[4] ?? "");
45 }
46 const node: Node = { tag, attrs, children: [], parent: cur, texts: [] };
47 cur.children.push(node);
48 if (!VOID.has(tag) && !m[3]!.trimEnd().endsWith("/")) cur = node;
49 }
50 return root;
51 }
52
53 function decode(s: string): string {
54 return s
55 .replace(/&lt;/g, "<")
56 .replace(/&gt;/g, ">")
57 .replace(/&quot;/g, '"')
58 .replace(/&#0?39;/g, "'")
59 .replace(/&amp;/g, "&");
60 }
61
62 export function all(n: Node): Node[] {
63 return n.children.flatMap((c) => [c, ...all(c)]);
64 }
65
66 /** Visible-to-a-screen-reader text of an element. */
67 function text(n: Node): string {
68 if (n.attrs["aria-hidden"] === "true") return "";
69 let out = "";
70 const walk = (x: Node) => {
71 if (x.attrs["aria-hidden"] === "true") return;
72 if (x.tag === "img" && x.attrs.alt) out += ` ${x.attrs.alt}`;
73 out += ` ${x.texts.join(" ")}`;
74 x.children.forEach(walk);
75 };
76 walk(n);
77 return out.replace(/\s+/g, " ").trim();
78 }
79
80 function byId(root: Node, id: string): Node | undefined {
81 return all(root).find((n) => n.attrs.id === id);
82 }
83
84 function rootOf(n: Node): Node {
85 let r = n;
86 while (r.parent) r = r.parent;
87 return r;
88 }
89
90 export function accessibleName(n: Node): string {
91 const root = rootOf(n);
92 const by = n.attrs["aria-labelledby"];
93 if (by) {
94 const t = by.split(/\s+/).map((id) => byId(root, id)).filter(Boolean).map((x) => text(x!)).join(" ").trim();
95 if (t) return t;
96 }
97 if (n.attrs["aria-label"]?.trim()) return n.attrs["aria-label"]!.trim();
98 if (n.tag === "input" && ["submit", "button", "reset"].includes(n.attrs.type ?? "")) {
99 if (n.attrs.value?.trim()) return n.attrs.value.trim();
100 }
101 if (["input", "select", "textarea"].includes(n.tag)) {
102 const id = n.attrs.id;
103 const lab = all(root).find((l) => l.tag === "label" && id !== undefined && l.attrs.for === id);
104 if (lab && text(lab)) return text(lab);
105 for (let p = n.parent; p; p = p.parent) if (p.tag === "label" && text(p)) return text(p);
106 } else {
107 const t = text(n);
108 if (t) return t;
109 }
110 return n.attrs.title?.trim() ?? "";
111 }
112
113 const IMPLICIT: Record<string, string> = {
114 button: "button", summary: "button", select: "combobox", textarea: "textbox",
115 };
116
117 export function roleOf(n: Node): string {
118 if (n.attrs.role) return n.attrs.role;
119 if (n.tag === "a") return n.attrs.href !== undefined ? "link" : "";
120 if (n.tag === "input") {
121 const t = n.attrs.type ?? "text";
122 if (t === "hidden") return "";
123 if (["submit", "button", "reset", "image"].includes(t)) return "button";
124 if (t === "checkbox") return "checkbox";
125 if (t === "radio") return "radio";
126 if (t === "range") return "slider";
127 if (t === "number") return "spinbutton";
128 return "textbox";
129 }
130 return IMPLICIT[n.tag] ?? "";
131 }
132
133 const NATIVE = new Set(["a", "button", "input", "select", "textarea", "summary"]);
134
135 /**
136 * An element a person can act on: a native control, an ARIA widget role, a tabindex, or a
137 * click handler.
138 */
139 export function controls(root: Node): Node[] {
140 return all(root).filter((n) => {
141 if (n.tag === "input" && n.attrs.type === "hidden") return false;
142 if (n.tag === "a" && n.attrs.href === undefined && !n.attrs.role && n.attrs.tabindex === undefined) return false;
143 return NATIVE.has(n.tag) || n.attrs.role !== undefined || n.attrs.tabindex !== undefined || hasHandler(n, /^onclick$/);
144 });
145 }
146
147 const hasHandler = (n: Node, re: RegExp) => Object.keys(n.attrs).some((k) => re.test(k));
148 const WIDGET = /^(button|link|checkbox|radio|tab|menuitem|menuitemcheckbox|menuitemradio|switch|option|combobox|textbox|slider|spinbutton)$/;
149
150 // ---- the stylesheet, as the tap-target rules it holds ----
151
152 interface Rule { media: string; selector: string; body: string; order: number }
153
154 function stripComments(s: string): string {
155 return s.replace(/\/\*[\s\S]*?\*\//g, "");
156 }
157
158 export function parseCss(css: string): Rule[] {
159 const out: Rule[] = [];
160 let order = 0;
161 const block = (src: string, media: string) => {
162 let i = 0;
163 while (i < src.length) {
164 const open = src.indexOf("{", i);
165 if (open < 0) break;
166 const head = src.slice(i, open).trim();
167 let depth = 1, j = open + 1;
168 while (j < src.length && depth > 0) {
169 if (src[j] === "{") depth++;
170 else if (src[j] === "}") depth--;
171 j++;
172 }
173 const body = src.slice(open + 1, j - 1);
174 if (head.startsWith("@media")) block(body, head);
175 else if (head.startsWith("@layer") || head.startsWith("@supports")) block(body, media);
176 else if (!head.startsWith("@")) {
177 for (const selector of head.split(",")) out.push({ media, selector: selector.trim(), body, order: order++ });
178 }
179 i = j;
180 }
181 };
182 block(stripComments(css), "");
183 return out;
184 }
185
186 const RULES = parseCss(components);
187
188 interface Compound { tag?: string; classes: string[]; attrs: [string, string | undefined][] }
189
190 function compounds(selector: string): { c: Compound; comb: " " | ">" }[] | null {
191 if (/[:+~*]/.test(selector.replace(/\[[^\]]*\]/g, ""))) return null; // states, pseudo-elements, siblings: not a static size
192 const parts = selector.replace(/\s*>\s*/g, " > ").split(/\s+/);
193 const out: { c: Compound; comb: " " | ">" }[] = [];
194 let comb: " " | ">" = " ";
195 for (const p of parts) {
196 if (p === ">") { comb = ">"; continue; }
197 const c: Compound = { classes: [], attrs: [] };
198 const tag = /^[a-z][\w-]*/i.exec(p);
199 if (tag) c.tag = tag[0].toLowerCase();
200 for (const m of p.matchAll(/\.([\w-]+)/g)) c.classes.push(m[1]!);
201 for (const m of p.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) c.attrs.push([m[1]!, m[2]]);
202 out.push({ c, comb });
203 comb = " ";
204 }
205 return out;
206 }
207
208 function matches(n: Node, c: Compound): boolean {
209 if (c.tag && n.tag !== c.tag) return false;
210 const cls = (n.attrs.class ?? "").split(/\s+/);
211 if (!c.classes.every((k) => cls.includes(k))) return false;
212 return c.attrs.every(([k, v]) => (v === undefined ? n.attrs[k] !== undefined : n.attrs[k] === v));
213 }
214
215 function selectorMatches(n: Node, chain: { c: Compound; comb: " " | ">" }[]): boolean {
216 const go = (node: Node | null, i: number): boolean => {
217 if (!node || !matches(node, chain[i]!.c)) return false;
218 if (i === 0) return true;
219 const comb = chain[i]!.comb;
220 if (comb === ">") return go(node.parent, i - 1);
221 for (let p = node.parent; p; p = p.parent) if (go(p, i - 1)) return true;
222 return false;
223 };
224 return go(n, chain.length - 1);
225 }
226
227 const specificity = (c: { c: Compound }[]) => c.reduce((s, x) => s + x.c.classes.length * 10 + x.c.attrs.length * 10 + (x.c.tag ? 1 : 0), 0);
228
229 export interface Size { height?: number; width?: number }
230
231 /**
232 * The size a thumb gets: base rules, then `(pointer: coarse)` rules, each by specificity then
233 * source order.
234 */
235 export function coarseSize(n: Node): Size {
236 const hits: { rank: [number, number, number]; body: string }[] = [];
237 for (const r of RULES) {
238 const coarse = /pointer:\s*coarse/.test(r.media);
239 if (r.media && !coarse) continue;
240 const chain = compounds(r.selector);
241 if (!chain || !selectorMatches(n, chain)) continue;
242 hits.push({ rank: [coarse ? 1 : 0, specificity(chain), r.order], body: r.body });
243 }
244 hits.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2]);
245 const size: Size = {};
246 const px = (body: string, prop: string) => {
247 const m = new RegExp(`(?:^|[;\\s])${prop}:\\s*(\\d+(?:\\.\\d+)?)px`).exec(body);
248 return m ? Number(m[1]) : undefined;
249 };
250 const num0 = (body: string, prop: string) => new RegExp(`(?:^|[;\\s])${prop}:\\s*0\\s*[;}]?`).test(body + ";");
251 for (const h of hits) {
252 const mh = px(h.body, "min-height") ?? px(h.body, "height");
253 if (mh !== undefined) size.height = mh;
254 else if (num0(h.body, "min-height")) size.height = 0;
255 const mw = px(h.body, "min-width") ?? px(h.body, "width");
256 if (mw !== undefined) size.width = mw;
257 }
258 return size;
259 }
260
261 export const TAP = 44;
262
263 // ---- the checks ----
264
265 const PHYSICAL = /(^|[;\s])(margin|padding|border)-(left|right)\s*:|(^|[;\s])(left|right)\s*:|(^|[;\s])float\s*:|text-align\s*:\s*(left|right)\b|(^|[;\s])(border-(top|bottom)-(left|right)-radius)\s*:/i;
266
267 export interface Options {
268 /** Skip the tap-target check (a component the stylesheet does not yet size to 44 px). */
269 tapTargets?: boolean;
270 /**
271 * Tags allowed to have no height or width rule at all, named by the spec that needs it: a
272 * control the stylesheet leaves unsized is a gap, and the spec says so out loud.
273 */
274 unsized?: string[];
275 /** Rule selectors, as written in the stylesheet, whose physical left/right is known and kept. */
276 physical?: string[];
277 }
278
279 /** Every problem in one component's rendered HTML; empty when it passes. */
280 export function problems(markup: string | { toString(): string }, o: Options = {}): string[] {
281 const root = parse(markup);
282 const out: string[] = [];
283 const label = (n: Node) => `<${n.tag}${n.attrs.class ? ` class="${n.attrs.class}"` : ""}>`;
284 const ids = new Map<string, number>();
285 for (const n of all(root)) if (n.attrs.id) ids.set(n.attrs.id, (ids.get(n.attrs.id) ?? 0) + 1);
286 for (const [id, k] of ids) if (k > 1) out.push(`duplicate id "${id}"`);
287
288 for (const n of controls(root)) {
289 const role = roleOf(n);
290 if (!role) out.push(`${label(n)} has no role`);
291 if (!accessibleName(n)) out.push(`${label(n)} (${role || "no role"}) has no accessible name`);
292 const native = NATIVE.has(n.tag);
293 if (!native && (role === "" || WIDGET.test(role) || hasHandler(n, /^onclick$/))) {
294 if (n.attrs.tabindex === undefined) out.push(`${label(n)} acts like a control but is not a real control and has no tabindex`);
295 if (!hasHandler(n, /^on(keydown|keyup|keypress)$/) && n.attrs["data-key"] === undefined) {
296 out.push(`${label(n)} acts like a control but has no key handler`);
297 }
298 }
299 if (o.tapTargets !== false && !(n.tag === "input" && n.attrs.type === "hidden")) {
300 // A checkbox or radio is tapped through its label: the label is the target.
301 let size = coarseSize(n);
302 if (n.tag === "input" && /^(checkbox|radio)$/.test(n.attrs.type ?? "")) {
303 for (let p = n.parent; p; p = p.parent) {
304 if (p.tag === "label") { size = coarseSize(p); break; }
305 }
306 }
307 if (size.height === undefined && size.width === undefined) {
308 // Only a link inside running text is exempt: its line is its target.
309 const prose = n.tag === "a" && n.parent !== null && n.parent.texts.join("").trim() !== "";
310 if (!prose && !(o.unsized ?? []).includes(n.tag)) {
311 out.push(`${label(n)} has no size rule: not measured at ${TAP}px (name it in "unsized")`);
312 }
313 }
314 if (size.height !== undefined && size.height < TAP) out.push(`${label(n)} is ${size.height}px tall on a thumb; ${TAP}px needed`);
315 if (size.width !== undefined && size.width < TAP) out.push(`${label(n)} is ${size.width}px wide on a thumb; ${TAP}px needed`);
316 }
317 }
318
319 out.push(...physicalRules(root, o.physical ?? []));
320
321 for (const n of all(root)) {
322 const style = n.attrs.style;
323 if (style && PHYSICAL.test(style)) out.push(`${label(n)} hard-codes left/right in inline style: ${style}`);
324 if (["left", "right"].includes(n.attrs.align ?? "")) out.push(`${label(n)} hard-codes align="${n.attrs.align}"`);
325 }
326 return out;
outdated

Tautology: rtl() only wraps in a div and problems() never reads dir, so this equals the ltr result for any markup. Make the RTL check able to fail (check CSS rules matched by the nodes for physical left/right) and test that it does.

327 }
328
329 // ---- right-to-left: the stylesheet rules the markup meets ----
330
331 /** A declaration that points at a side of the page, not at the start or end of a line. */
332 const PHYSICAL_DECL =
333 /(?:^|[;\s])((?:margin|padding|border)-(?:left|right)(?:-[a-z]+)?|border-(?:top|bottom)-(?:left|right)-radius|left|right|float|clear|text-align|text-align-last)\s*:\s*([^;]*)/g;
334
335 /**
336 * The rule's selector with states and pseudo-elements dropped: they do not change which node it
337 * is on.
338 */
339 function loose(selector: string): string {
340 return selector.replace(/::?[a-z-]+(\([^)]*\))?/gi, "").replace(/\s+/g, " ").trim();
341 }
342
343 /** Physical-side declarations in the (every-media) rules that match a rendered node. */
344 export function physicalRules(root: Node, allowed: string[] = []): string[] {
345 const out = new Set<string>();
346 const nodes = all(root);
347 for (const r of RULES) {
348 if (allowed.includes(r.selector)) continue;
349 const found: string[] = [];
350 for (const m of r.body.matchAll(PHYSICAL_DECL)) {
351 const [, prop, value] = m;
352 if (prop === "text-align" || prop === "text-align-last") {
353 if (!/^(left|right)\b/.test(value!.trim())) continue;
354 } else if (prop === "float" || prop === "clear") {
355 if (!/^(left|right)\b/.test(value!.trim())) continue;
356 } else if (/^(left|right)$/.test(prop!) && /^(auto|unset|initial|inherit)\b/.test(value!.trim())) {
357 continue;
358 }
359 found.push(`${prop}: ${value!.trim()}`);
360 }
361 if (found.length === 0) continue;
362 const chain = compounds(loose(r.selector));
363 if (!chain || !nodes.some((n) => selectorMatches(n, chain))) continue;
364 for (const f of found) out.add(`stylesheet rule "${r.selector}" hard-codes ${f}`);
365 }
366 return [...out];
367 }
368
369 /** The markup as a right-to-left reader gets it. */
370 export function rtl(markup: string | { toString(): string }): string {
371 return `<div dir="rtl">${markup}</div>`;
372 }
373
374 /**
375 * Passes when the markup has no problems, and still none inside `dir="rtl"`. What makes the
376 * right-to-left half real is `problems`: inline style, `align`, and every stylesheet rule that
377 * matches a rendered node, are scanned for physical left/right, which does not flip with `dir`.
378 * The wrapper adds `dir="rtl"` ancestry, so rules scoped to `[dir="rtl"]` are matched too.
379 */
380 export function expectAccessible(markup: string | { toString(): string }, o: Options = {}): void {
381 expect(problems(markup, o)).toEqual([]);
382 expect(problems(rtl(markup), o)).toEqual([]);
383 }
test/ui-spec/iconBtn.test.ts added +8
@@ -0,0 +1,8 @@
1 import { test } from "bun:test";
2 import { iconBtn } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 test("iconBtn: its label is its name", () => {
6 expectAccessible(iconBtn("copy", "Copy address", { type: "button" }));
7 expectAccessible(iconBtn("copy", "Copy address", { href: "/x" }));
8 });
test/ui-spec/selectField.test.ts added +13
@@ -0,0 +1,13 @@
1 import { test } from "bun:test";
2 import { selectField } from "../../src/web/ui.ts";
3 import { expectAccessible } from "./helper.ts";
4
5 // `select.input` pads its right side for the arrow: physical, and named here until it flips.
6 const SELECT = { physical: ["select.input"] };
7
8 test("selectField: label, hint and error", () => {
9 expectAccessible(
10 selectField({ label: "Kind", name: "kind", hint: "Pick one", error: "Required", options: [{ value: "a", label: "A" }] }),
11 SELECT,
12 );
13 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs