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

Every page works by keyboard and screen reader, and a test proves the labels are there #37

Merged opened this

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

Merged

mason/listing-launch:mason/19-every-page-works-by-keyboard-and-screen → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/19-every-page-works-by-keyboard-and-screen was merged into main as 1558191.

Review

Sign in to review this change.
6 files changed +225 −13
src/a11y.test.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * Every page works by keyboard and screen reader: fetched for the sample
3 * listings, each has one h1, headings that do not skip, a name on every
4 * control, an alt on every image, a skip link to <main id="main">, and ids
5 * that are unique.
6 */
7 import { afterAll, expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { serve } from "./server.ts";
12 import { sign } from "./who.ts";
13
14 const KEY = "a-key-only-the-forge-holds";
15 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "a11y-")), identityKey: KEY });
16 const base = `http://${server.hostname}:${server.port}`;
17 afterAll(() => server.stop(true));
18
19 const identity = `u1|realtor|admin|${Date.now() + 600_000}`;
20 const headers = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
21 const page = async (path: string, signedIn = true): Promise<string> => {
22 const res = await fetch(base + path, { headers: signedIn ? headers : {} });
23 expect([path, res.status]).toEqual([path, 200]);
24 return res.text();
25 };
26
27 const attr = (tag: string, name: string): string | undefined =>
28 new RegExp(`\\s${name}(?:="([^"]*)")?(?=[\\s>])`).exec(tag)?.[1] ??
29 (new RegExp(`\\s${name}(?=[\\s>/])`).test(tag) ? "" : undefined);
30
31 /** One sample asset reworded with a phrase the Fair Housing check flags, so the flagged markup is on a page that is checked. */
32 let flagged: Promise<void> | undefined;
33 const flagOne = (): Promise<void> =>
34 (flagged ??= (async () => {
35 const form = new URLSearchParams({ text: "A quiet home, perfect for a young couple, no children." });
36 const res = await fetch(`${base}/listings/1/assets/mls`, { method: "POST", headers, body: form, redirect: "manual" });
37 expect(res.status).toBe(303);
38 })());
39
40 /** Every page worth checking: the three named, then each sample listing's pages. */
41 async function paths(): Promise<string[]> {
42 await flagOne();
43 const home = await page("/");
44 const ids = [...new Set([...home.matchAll(/href="\/listings\/(\d+)"/g)].map((m) => m[1]!))];
45 expect(ids.length).toBeGreaterThanOrEqual(2);
46 return [
47 "/",
48 "/notes",
49 ...ids.flatMap((id) => [
50 `/listings/${id}`,
51 `/listings/${id}?tab=social`,
52 `/listings/${id}/facts`,
53 `/listings/${id}/flyer`,
54 `/listings/${id}/email`,
55 ]),
56 ];
57 }
58
59 /** Whether a control has a name: a wrapping label, a label for its id, aria-label or aria-labelledby that resolves. */
60 function unnamed(html: string): string[] {
61 const bad: string[] = [];
62 const ids = new Set([...html.matchAll(/\sid="([^"]*)"/g)].map((m) => m[1]!));
63 const labelled = new Set([...html.matchAll(/<label[^>]*\sfor="([^"]*)"/g)].map((m) => m[1]!));
64 for (const m of html.matchAll(/<(input|select|textarea)\b[^>]*>/g)) {
65 const tag = m[0];
66 const type = attr(tag, "type");
67 if (m[1] === "input" && (type === "hidden" || type === "submit")) continue;
68 const id = attr(tag, "id");
69 const by = attr(tag, "aria-labelledby");
70 const inLabel = (() => {
71 const before = html.slice(0, m.index);
72 const open = before.lastIndexOf("<label");
73 return open > before.lastIndexOf("</label>");
74 })();
75 const named =
76 inLabel ||
77 (attr(tag, "aria-label") ?? "") !== "" ||
78 (by !== undefined && by.split(/\s+/).every((i) => ids.has(i))) ||
79 (id !== undefined && labelled.has(id));
80 if (!named) bad.push(tag);
81 }
82 for (const m of html.matchAll(/<button\b[^>]*>([^]*?)<\/button>/g)) {
83 const text = m[1]!.replace(/<[^>]*>/g, "").trim();
84 if (text === "" && (attr(m[0], "aria-label") ?? "") === "") bad.push(m[0]);
85 }
86 return bad;
87 }
88
89 test("every page has a skip link to the main region, one h1 and headings that do not skip", async () => {
90 for (const path of await paths()) {
91 const html = await page(path);
92 expect([path, html.includes('<a class="skip" href="#main">Skip to content</a>')]).toEqual([path, true]);
93 expect([path, /<main id="main"[ >]/.test(html)]).toEqual([path, true]);
94 expect([path, html.match(/<h1[\s>]/g)?.length]).toEqual([path, 1]);
95 // The skip link is the first thing in the tab order.
96 expect(html.indexOf('class="skip"')).toBeLessThan(html.indexOf("<header"));
97 let last = 0;
98 for (const m of html.matchAll(/<h([1-6])[\s>]/g)) {
99 const level = Number(m[1]);
100 expect([path, level <= last + 1]).toEqual([path, true]);
101 last = level;
102 }
103 }
104 });
105
106 test("every form control has a name and every button says what it does", async () => {
107 for (const path of await paths()) expect([path, unnamed(await page(path))]).toEqual([path, []]);
108 expect(unnamed(await page("/notes", false))).toEqual([]);
109 });
110
111 test("the asset buttons name their asset", async () => {
112 const html = await page("/listings/1");
113 for (const verb of ["Save the MLS description", "Copy the MLS description"])
114 expect(html).toContain(`aria-label="${verb}"`);
115 expect(html).toMatch(/aria-label="Approve(?: with \d+ flags?)?: the MLS description"/);
116 expect(html).toContain('data-asset="MLS description"');
117 });
118
119 test("an accessible name begins with the words on the button (label in name)", async () => {
120 let seen = 0;
121 for (const path of await paths()) {
122 const html = await page(path);
123 for (const m of html.matchAll(/<button\b[^>]*>([^]*?)<\/button>/g)) {
124 const label = attr(m[0], "aria-label");
125 if (label === undefined) continue;
126 seen++;
127 const visible = m[1]!.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
128 expect([path, label, label.toLowerCase().includes(visible.toLowerCase())]).toEqual([path, label, true]);
129 }
130 }
131 expect(seen).toBeGreaterThan(0);
132 });
133
134 test("one live region per page announces results, and listing.js uses it", async () => {
135 for (const path of await paths()) {
136 const html = await page(path);
137 expect([path, html.match(/data-announce/g)?.length]).toEqual([path, 1]);
138 expect(html).toContain('role="status" aria-live="polite" data-announce');
139 }
140 const js = await (await fetch(base + (/src="(\/[^"]*listing\.js[^"]*)"/.exec(await page("/"))![1]!))).text();
141 expect(js).toContain("[data-announce]");
142 expect(js.match(/announce\(/g)!.length).toBeGreaterThanOrEqual(5);
143 });
144
145 test("a flag is told by an icon and words, not colour alone", async () => {
146 let marks = 0;
147 for (const path of await paths()) {
148 const html = await page(path);
149 for (const m of html.matchAll(/<mark\b[^>]*>([^]*?)<\/mark>/g)) {
150 marks++;
151 expect(m[1]).toContain("⚠");
152 expect(m[1]).toContain("flagged");
153 }
154 }
155 expect(marks).toBeGreaterThan(0);
156 });
157
158 test("every img has an alt; a headshot says whose", async () => {
159 let faces = 0;
160 for (const path of await paths()) {
161 const html = await page(path);
162 for (const m of html.matchAll(/<img\b[^>]*>/g)) {
163 expect([path, m[0], attr(m[0], "alt") !== undefined]).toEqual([path, m[0], true]);
164 if (/face/.test(attr(m[0], "class") ?? "")) {
165 faces++;
166 expect(attr(m[0], "alt")).toMatch(/^Photo of .+/);
167 }
168 }
169 }
170 expect(faces).toBeGreaterThan(0);
171 });
172
173 test("every id is unique on its page", async () => {
174 for (const path of await paths()) {
175 const html = await page(path);
176 const ids = [...html.matchAll(/\sid="([^"]*)"/g)].map((m) => m[1]!);
177 expect([path, ids.filter((id, i) => ids.indexOf(id) !== i)]).toEqual([path, []]);
178 }
179 });
src/pages/email.ts +1 −1
@@ -47,7 +47,7 @@ export function email(l: Launched): string {
4747 <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p>
4848 ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")}
4949 <div class="email-agent">
50 ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="${escape(l.agent_name)}" width="72" height="72">`}
50 ${l.headshot === null ? "" : `<img class="email-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`}
5151 <div>
5252 <p class="email-name brand">${escape(l.agent_name)}</p>
5353 <p>${escape(l.brokerage)} · License ${escape(l.license)}</p>
src/pages/layout.ts +3 −1
@@ -70,6 +70,7 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
7070 <script type="module" src="${asset("live.js")}"></script>
7171 <script type="module" src="${asset("listing.js")}"></script>
7272 <body${frame.kind === undefined ? "" : ` class="${escape(frame.kind)}"`}>
73 <a class="skip" href="#main">Skip to content</a>
7374 <div class="page">
7475 <header class="head">
7576 <div>
@@ -81,7 +82,8 @@ export function layout(body: string, who: Who, frame: Frame = {}): string {
8182 ${door}
8283 </nav>
8384 </header>
84 <main>${body}</main>
85 <main id="main" tabindex="-1">${body}</main>
86 <div class="sr" id="announce" role="status" aria-live="polite" data-announce></div>
8587 <footer class="muted">
8688 ${ssh === undefined ? "<p>The terminal is off here.</p>" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
8789 <p>Also at <a href="/api/listings">/api/listings</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules. Team <a href="/notes">notes</a>.</p>
src/pages/listing.ts +11 −11
@@ -30,7 +30,7 @@ export function annotate(text: string, check: Check): string {
3030 let at = 0;
3131 for (const f of check.findings) {
3232 out += escape(text.slice(at, f.start));
33 out += `<mark class="fh-mark fh-${f.level}" title="${escape(f.line)}">${escape(text.slice(f.start, f.end))}</mark>`;
33 out += `<mark class="fh-mark fh-${f.level}" title="${escape(f.line)}">${escape(text.slice(f.start, f.end))}<span aria-hidden="true"> ⚠</span><span class="sr"> (flagged: ${escape(f.line)})</span></mark>`;
3434 at = f.end;
3535 }
3636 return out + escape(text.slice(at));
@@ -48,7 +48,7 @@ const useButton = (f: Finding, to: string): string =>
4848 <input type="hidden" name="start" value="${f.start}">
4949 <input type="hidden" name="end" value="${f.end}">
5050 <input type="hidden" name="text" value="${escape(f.text)}">
51 <button class="button button-quiet">Use “${escape(f.dropIn)}”</button>
51 <button class="button button-quiet" aria-label="Use “${escape(f.dropIn)}” instead of “${escape(f.text)}”">Use “${escape(f.dropIn)}”</button>
5252 </form>`;
5353
5454 /**
@@ -62,13 +62,13 @@ export function fairHousing(text: string, check: Check, fix?: string): string {
6262 return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`;
6363 const n = check.findings.length;
6464 return `<p class="fh-status fh-warn" role="status">Fair Housing: ${n} ${n === 1 ? "phrase" : "phrases"} to review</p>
65 <ul class="fh-list">${check.findings
65 <ul class="fh-list" aria-label="Phrases to review">${check.findings
6666 .map(
6767 (f) =>
6868 `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`,
6969 )
7070 .join("")}</ul>
71 <div class="fh-text" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`;
71 <div class="fh-text" role="group" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`;
7272 }
7373
7474 const flags = (l: Launched): number =>
@@ -98,10 +98,10 @@ export function approval(l: Launched, a: Asset): string {
9898 const to = `/listings/${l.id}/assets/${a.kind}/approve`;
9999 if (a.approval !== null)
100100 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>
101 <button class="button button-quiet" formaction="${to}" name="approved" value="0">Withdraw</button>`;
101 <button class="button button-quiet" formaction="${to}" name="approved" value="0" aria-label="Withdraw approval of the ${escape(a.label)}">Withdraw</button>`;
102102 const n = a.check.findings.length;
103103 return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">
104 <button class="button" data-approve formaction="${to}" name="approved" value="1">${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}</button>`;
104 <button class="button" data-approve aria-label="${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`}</button>`;
outdated

Accessible name "Approve the X with N flags" does not contain the visible text "Approve with N flags" (WCAG 2.5.3). Begin the label with the visible text, e.g. Approve with 2 flags: the MLS description.

105105 }
106106
107107 function editor(l: Launched, a: Asset, editable: boolean): string {
@@ -112,8 +112,8 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
112112 <form method="post" action="/listings/${l.id}/assets/${a.kind}">
113113 <textarea class="input textarea" name="text" rows="${rows}" maxlength="${ASSET_MAX}" aria-labelledby="${a.kind}-title" data-check="${a.kind}"${editable ? "" : " readonly"}>${escape(a.text)}</textarea>
114114 <div class="row asset-actions">
115 ${editable ? `<button class="button">Save</button>` : ""}
116 <button type="button" class="button button-quiet" data-copy hidden>Copy</button>
115 ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""}
116 <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button>
117117 ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>`}
118118 <small class="muted" data-count>${a.text.length} characters</small>
119119 </div>
@@ -152,7 +152,7 @@ export function listingPage(input: {
152152 ? `<form class="row tone" method="post" action="/listings/${l.id}/tone">
153153 <label class="sr" for="tone">Tone</label>
154154 <select class="input select" id="tone" name="tone">${TONES.map((t) => `<option value="${t}"${t === l.tone ? " selected" : ""}>${t[0]!.toUpperCase()}${t.slice(1)}</option>`).join("")}</select>
155 <button class="button button-quiet" title="Writes all five again in this tone; your edits are replaced.">Rewrite all</button>
155 <button class="button button-quiet" title="Writes all five again in this tone; your edits are replaced." aria-label="Rewrite all five assets in this tone">Rewrite all</button>
156156 </form>`
157157 : `<span class="pill">${escape(l.tone)} tone</span>`;
158158 return layout(
@@ -194,7 +194,7 @@ export function listingPage(input: {
194194 ${
195195 editable
196196 ? `<form class="danger-zone" method="post" action="/listings/${l.id}/delete">
197 <button class="button button-quiet">Remove this listing</button>
197 <button class="button button-quiet" aria-label="Remove this listing, ${escape(l.address)}">Remove this listing</button>
198198 </form>`
199199 : ""
200200 }
@@ -230,7 +230,7 @@ export function flyer(l: Launched): string {
230230 ${more.length === 0 ? "" : `<div class="flyer-more">${more.slice(0, 2).map((p) => `<img src="${escape(photoUrl(l, p))}" alt="" width="400" height="267">`).join("")}</div>`}
231231 </div>
232232 <footer class="flyer-agent">
233 ${l.headshot === null ? "" : `<img class="flyer-face" src="${escape(photoUrl(l, l.headshot))}" alt="${escape(l.agent_name)}" width="96" height="96">`}
233 ${l.headshot === null ? "" : `<img class="flyer-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="96" height="96">`}
234234 <div>
235235 <p class="flyer-name brand">${escape(l.agent_name)}</p>
236236 <p>${escape(l.brokerage)}</p>
static/app.css +18
@@ -245,6 +245,24 @@ h3 {
245245 clip-path: inset(50%);
246246 white-space: nowrap;
247247 }
248 .skip {
249 position: absolute;
250 left: var(--space-3);
251 top: var(--space-3);
252 z-index: 10;
253 padding: var(--space-2) var(--space-3);
254 background: var(--color-bg);
255 color: var(--color-fg);
256 border: 2px solid currentColor;
257 border-radius: var(--radius-md);
258 transform: translateY(-300%);
259 }
260 .skip:focus {
261 transform: none;
262 }
263 main:focus {
264 outline: none;
265 }
248266 .launched {
249267 margin-top: var(--space-6);
250268 }
static/listing.js +13
@@ -5,6 +5,14 @@
55 // check; this only asks for it and swaps it in. Without this file the page
66 // is the same, a load per tab, and the check runs on Save.
77
8 // Say a result to a screen reader: the layout's polite live region.
9 const announce = (words) => {
10 const region = document.querySelector("[data-announce]");
11 if (!region) return;
12 region.textContent = "";
13 setTimeout(() => (region.textContent = words), 50);
14 };
15
816 for (const tabs of document.querySelectorAll("[data-tabs]")) {
917 const links = [...tabs.querySelectorAll('[role="tab"]')];
1018 const panels = links.map((a) =>
@@ -41,6 +49,7 @@ for (const button of document.querySelectorAll("[data-copy]")) {
4149 button.addEventListener("click", async () => {
4250 await navigator.clipboard.writeText(area.value);
4351 button.textContent = "Copied";
52 announce(`Copied the ${button.dataset.asset}`);
outdated

Brittle: the announcement depends on regex-stripping "Copy the " from the aria-label. Changing the label wording silently breaks it. Use a data attribute or the asset heading.

4453 setTimeout(() => (button.textContent = "Copy"), 1500);
4554 });
4655 }
@@ -74,6 +83,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
7483 }
7584 area.value = body.text;
7685 aside.innerHTML = body.html;
86 announce(`Used the suggested wording. ${aside.querySelector(".fh-status")?.textContent ?? ""}`);
7787 if (approval) approval.innerHTML = body.approval;
7888 if (counter) counter.textContent = `${area.value.length} characters`;
7989 });
@@ -90,6 +100,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) {
90100 });
91101 if (!response.ok || mine !== asked) return;
92102 aside.innerHTML = (await response.json()).html;
103 announce(aside.querySelector(".fh-status")?.textContent ?? "");
93104 }, 600);
94105 });
95106 }
@@ -128,6 +139,7 @@ for (const button of document.querySelectorAll("[data-copy-email]")) {
128139 }),
129140 ]);
130141 button.textContent = "Copied";
142 announce("Copied the email");
131143 setTimeout(() => (button.textContent = "Copy as email"), 1500);
132144 });
133145 }
@@ -140,6 +152,7 @@ for (const button of document.querySelectorAll("[data-copy-subject]")) {
140152 button.addEventListener("click", async () => {
141153 await navigator.clipboard.writeText(subject.textContent);
142154 button.textContent = "Copied";
155 announce("Copied the subject");
143156 setTimeout(() => (button.textContent = "Copy subject"), 1500);
144157 });
145158 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs