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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/19-every-page-works-by-keyboard-and-screen → main 2 commits fork 2 threads · 2 unresolved
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 { | ||
| 47 | 47 | <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p> |
| 48 | 48 | ${paragraphs.map((p) => `<p class="email-body">${escape(p).replace(/\n/g, "<br>")}</p>`).join("")} |
| 49 | 49 | <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">`} | |
| 51 | 51 | <div> |
| 52 | 52 | <p class="email-name brand">${escape(l.agent_name)}</p> |
| 53 | 53 | <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 { | ||
| 70 | 70 | <script type="module" src="${asset("live.js")}"></script> |
| 71 | 71 | <script type="module" src="${asset("listing.js")}"></script> |
| 72 | 72 | <body${frame.kind === undefined ? "" : ` class="${escape(frame.kind)}"`}> |
| 73 | <a class="skip" href="#main">Skip to content</a> | |
| 73 | 74 | <div class="page"> |
| 74 | 75 | <header class="head"> |
| 75 | 76 | <div> |
| @@ -81,7 +82,8 @@ export function layout(body: string, who: Who, frame: Frame = {}): string { | ||
| 81 | 82 | ${door} |
| 82 | 83 | </nav> |
| 83 | 84 | </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> | |
| 85 | 87 | <footer class="muted"> |
| 86 | 88 | ${ssh === undefined ? "<p>The terminal is off here.</p>" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 87 | 89 | <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 { | ||
| 30 | 30 | let at = 0; |
| 31 | 31 | for (const f of check.findings) { |
| 32 | 32 | 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>`; | |
| 34 | 34 | at = f.end; |
| 35 | 35 | } |
| 36 | 36 | return out + escape(text.slice(at)); |
| @@ -48,7 +48,7 @@ const useButton = (f: Finding, to: string): string => | ||
| 48 | 48 | <input type="hidden" name="start" value="${f.start}"> |
| 49 | 49 | <input type="hidden" name="end" value="${f.end}"> |
| 50 | 50 | <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> | |
| 52 | 52 | </form>`; |
| 53 | 53 | |
| 54 | 54 | /** |
| @@ -62,13 +62,13 @@ export function fairHousing(text: string, check: Check, fix?: string): string { | ||
| 62 | 62 | return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`; |
| 63 | 63 | const n = check.findings.length; |
| 64 | 64 | 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 | |
| 66 | 66 | .map( |
| 67 | 67 | (f) => |
| 68 | 68 | `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`, |
| 69 | 69 | ) |
| 70 | 70 | .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>`; | |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | const flags = (l: Launched): number => |
| @@ -98,10 +98,10 @@ export function approval(l: Launched, a: Asset): string { | ||
| 98 | 98 | const to = `/listings/${l.id}/assets/${a.kind}/approve`; |
| 99 | 99 | if (a.approval !== null) |
| 100 | 100 | 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>`; | |
| 102 | 102 | const n = a.check.findings.length; |
| 103 | 103 | 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>`; | |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | 107 | function editor(l: Launched, a: Asset, editable: boolean): string { |
| @@ -112,8 +112,8 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 112 | 112 | <form method="post" action="/listings/${l.id}/assets/${a.kind}"> |
| 113 | 113 | <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> |
| 114 | 114 | <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> | |
| 117 | 117 | ${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>`} |
| 118 | 118 | <small class="muted" data-count>${a.text.length} characters</small> |
| 119 | 119 | </div> |
| @@ -152,7 +152,7 @@ export function listingPage(input: { | ||
| 152 | 152 | ? `<form class="row tone" method="post" action="/listings/${l.id}/tone"> |
| 153 | 153 | <label class="sr" for="tone">Tone</label> |
| 154 | 154 | <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> | |
| 156 | 156 | </form>` |
| 157 | 157 | : `<span class="pill">${escape(l.tone)} tone</span>`; |
| 158 | 158 | return layout( |
| @@ -194,7 +194,7 @@ export function listingPage(input: { | ||
| 194 | 194 | ${ |
| 195 | 195 | editable |
| 196 | 196 | ? `<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> | |
| 198 | 198 | </form>` |
| 199 | 199 | : "" |
| 200 | 200 | } |
| @@ -230,7 +230,7 @@ export function flyer(l: Launched): string { | ||
| 230 | 230 | ${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>`} |
| 231 | 231 | </div> |
| 232 | 232 | <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">`} | |
| 234 | 234 | <div> |
| 235 | 235 | <p class="flyer-name brand">${escape(l.agent_name)}</p> |
| 236 | 236 | <p>${escape(l.brokerage)}</p> |
static/app.css +18
| @@ -245,6 +245,24 @@ h3 { | ||
| 245 | 245 | clip-path: inset(50%); |
| 246 | 246 | white-space: nowrap; |
| 247 | 247 | } |
| 248 | .skip { | |
| 249 | position: absolute; | |
| 250 | left: var(); | |
| 251 | top: var(); | |
| 252 | z-index: 10; | |
| 253 | padding: var() var(); | |
| 254 | background: var(); | |
| 255 | color: var(); | |
| 256 | border: 2px solid currentColor; | |
| 257 | border-radius: var(); | |
| 258 | transform: translateY(-300%); | |
| 259 | } | |
| 260 | .skip:focus { | |
| 261 | transform: none; | |
| 262 | } | |
| 263 | main:focus { | |
| 264 | outline: none; | |
| 265 | } | |
| 248 | 266 | .launched { |
| 249 | 267 | margin-top: var(); |
| 250 | 268 | } |
static/listing.js +13
| @@ -5,6 +5,14 @@ | ||
| 5 | 5 | // check; this only asks for it and swaps it in. Without this file the page |
| 6 | 6 | // is the same, a load per tab, and the check runs on Save. |
| 7 | 7 | |
| 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 | ||
| 8 | 16 | for (const tabs of document.querySelectorAll("[data-tabs]")) { |
| 9 | 17 | const links = [...tabs.querySelectorAll('[role="tab"]')]; |
| 10 | 18 | const panels = links.map((a) => |
| @@ -41,6 +49,7 @@ for (const button of document.querySelectorAll("[data-copy]")) { | ||
| 41 | 49 | button.addEventListener("click", async () => { |
| 42 | 50 | await navigator.clipboard.writeText(area.value); |
| 43 | 51 | 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. |
||
| 44 | 53 | setTimeout(() => (button.textContent = "Copy"), 1500); |
| 45 | 54 | }); |
| 46 | 55 | } |
| @@ -74,6 +83,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 74 | 83 | } |
| 75 | 84 | area.value = body.text; |
| 76 | 85 | aside.innerHTML = body.html; |
| 86 | announce(`Used the suggested wording. ${aside.querySelector(".fh-status")?.textContent ?? ""}`); | |
| 77 | 87 | if (approval) approval.innerHTML = body.approval; |
| 78 | 88 | if (counter) counter.textContent = `${area.value.length} characters`; |
| 79 | 89 | }); |
| @@ -90,6 +100,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 90 | 100 | }); |
| 91 | 101 | if (!response.ok || mine !== asked) return; |
| 92 | 102 | aside.innerHTML = (await response.json()).html; |
| 103 | announce(aside.querySelector(".fh-status")?.textContent ?? ""); | |
| 93 | 104 | }, 600); |
| 94 | 105 | }); |
| 95 | 106 | } |
| @@ -128,6 +139,7 @@ for (const button of document.querySelectorAll("[data-copy-email]")) { | ||
| 128 | 139 | }), |
| 129 | 140 | ]); |
| 130 | 141 | button.textContent = "Copied"; |
| 142 | announce("Copied the email"); | |
| 131 | 143 | setTimeout(() => (button.textContent = "Copy as email"), 1500); |
| 132 | 144 | }); |
| 133 | 145 | } |
| @@ -140,6 +152,7 @@ for (const button of document.querySelectorAll("[data-copy-subject]")) { | ||
| 140 | 152 | button.addEventListener("click", async () => { |
| 141 | 153 | await navigator.clipboard.writeText(subject.textContent); |
| 142 | 154 | button.textContent = "Copied"; |
| 155 | announce("Copied the subject"); | |
| 143 | 156 | setTimeout(() => (button.textContent = "Copy subject"), 1500); |
| 144 | 157 | }); |
| 145 | 158 | } |
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.