Public: anyone can read this; the crew can change it.
The live region keeps your place: focus, an announcement, and what someone is typing #56
Check · Review · Land · Live done — Landed and live in production.
hearth/quorum:hearth/49-the-live-region-keeps-your-place-focus-a → main 1 commit fork
10 files
changed +508
−17
README.md +13
| @@ -237,6 +237,19 @@ from those rows even across a restart. The rows are trimmed to the last | ||
| 237 | 237 | hour and the last thousand. Without JavaScript it is the same page, one |
| 238 | 238 | reload behind. |
| 239 | 239 | |
| 240 | A swap does not cost you your place. Before a region is replaced, | |
| 241 | `static/live.js` notes what has focus inside it — by id, or by name — and | |
| 242 | where the caret sits; afterwards the same control in the fresh markup gets | |
| 243 | both back, and if it is gone the region's heading is focused rather than | |
| 244 | `<body>`. A box you are still typing in is not replaced at all: an | |
| 245 | `<input>`, `<textarea>` or `<select>` that holds focus and holds something | |
| 246 | the server's copy does not have keeps its region as it is, and the region | |
| 247 | catches up the moment you leave it. Each region is `aria-live="polite"` and | |
| 248 | points at a one-line `role="status"` summary beside it — "A new remark", "A | |
| 249 | vote was cast" — written on the server from the event type (`summarize` in | |
| 250 | `src/live.ts`) and carried on the stream, so a screen reader hears a | |
| 251 | sentence rather than the whole region read again. | |
| 252 | ||
| 240 | 253 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 241 | 254 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
| 242 | 255 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
src/focus.test.ts added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | /** | |
| 2 | * What a live swap costs the person using the page, proved without a | |
| 3 | * browser. `bun test` hosts no DOM and this app takes no dependency to get | |
| 4 | * one, so the elements below are a stub: the handful of properties | |
| 5 | * `static/live.js` actually touches — children, an id, a name, a value, a | |
| 6 | * caret, and a focus that can be asked who holds it — and nothing else. | |
| 7 | * The functions under test are the real ones, imported from the file the | |
| 8 | * browser loads. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | // @ts-expect-error - the browser's own file, no types, run here as it ships. | |
| 13 | import { restore, spotOf, swap, typing } from "../static/live.js"; | |
| 14 | ||
| 15 | interface Stub { | |
| 16 | tagName: string; | |
| 17 | id: string; | |
| 18 | children: Stub[]; | |
| 19 | parent?: Stub; | |
| 20 | attrs: Record<string, string>; | |
| 21 | value: string; | |
| 22 | selectionStart?: number; | |
| 23 | selectionEnd?: number; | |
| 24 | selectionDirection?: string; | |
| 25 | listeners: { type: string; run: () => void }[]; | |
| 26 | getAttribute(name: string): string | null; | |
| 27 | setAttribute(name: string, value: string): void; | |
| 28 | contains(other: Stub): boolean; | |
| 29 | focus(options?: { preventScroll?: boolean }): void; | |
| 30 | setSelectionRange(start: number, end: number, direction?: string): void; | |
| 31 | replaceWith(next: Stub): void; | |
| 32 | addEventListener( | |
| 33 | type: string, | |
| 34 | run: () => void, | |
| 35 | options?: { once?: boolean }, | |
| 36 | ): void; | |
| 37 | } | |
| 38 | ||
| 39 | /** Who has focus, and whether the page was allowed to scroll to them. */ | |
| 40 | let focused: { el?: Stub; scrolled?: boolean } = {}; | |
| 41 | ||
| 42 | function* all(node: Stub): Generator<Stub> { | |
| 43 | yield node; | |
| 44 | for (const child of node.children) yield* all(child); | |
| 45 | } | |
| 46 | ||
| 47 | function el( | |
| 48 | tagName: string, | |
| 49 | props: { | |
| 50 | id?: string; | |
| 51 | name?: string; | |
| 52 | value?: string; | |
| 53 | selectionStart?: number; | |
| 54 | } = {}, | |
| 55 | children: Stub[] = [], | |
| 56 | ): Stub { | |
| 57 | const node: Stub = { | |
| 58 | tagName, | |
| 59 | id: props.id ?? "", | |
| 60 | children, | |
| 61 | attrs: props.name === undefined ? {} : { name: props.name }, | |
| 62 | value: props.value ?? "", | |
| 63 | selectionStart: props.selectionStart, | |
| 64 | selectionEnd: props.selectionStart, | |
| 65 | listeners: [], | |
| 66 | getAttribute: (name) => node.attrs[name] ?? null, | |
| 67 | setAttribute: (name, value) => { | |
| 68 | node.attrs[name] = value; | |
| 69 | }, | |
| 70 | contains: (other) => [...all(node)].includes(other), | |
| 71 | focus: (options) => { | |
| 72 | focused = { el: node, scrolled: options?.preventScroll !== true }; | |
| 73 | }, | |
| 74 | setSelectionRange: (start, end, direction) => { | |
| 75 | node.selectionStart = start; | |
| 76 | node.selectionEnd = end; | |
| 77 | node.selectionDirection = direction; | |
| 78 | }, | |
| 79 | replaceWith: (next) => { | |
| 80 | const parent = node.parent; | |
| 81 | if (parent === undefined) return; | |
| 82 | parent.children = parent.children.map((c) => (c === node ? next : c)); | |
| 83 | next.parent = parent; | |
| 84 | node.parent = undefined; | |
| 85 | }, | |
| 86 | addEventListener: (type, run) => { | |
| 87 | node.listeners.push({ type, run }); | |
| 88 | }, | |
| 89 | }; | |
| 90 | for (const child of children) child.parent = node; | |
| 91 | return node; | |
| 92 | } | |
| 93 | ||
| 94 | /** A thread region on a page: a heading, remarks, and the box one is written in. */ | |
| 95 | const region = (body: string, remarks = 1): Stub => { | |
| 96 | const thread = el("SECTION", { id: "thread" }, [ | |
| 97 | el("H2"), | |
| 98 | ...Array.from({ length: remarks }, (_, i) => | |
| 99 | el("LI", { id: `remark-${i}` }, [el("BUTTON", { id: `delete-${i}` })]), | |
| 100 | ), | |
| 101 | el("TEXTAREA", { id: "say", name: "body", value: body, selectionStart: 0 }), | |
| 102 | ]); | |
| 103 | el("MAIN", {}, [thread]); // Something to be replaced within. | |
| 104 | return thread; | |
| 105 | }; | |
| 106 | ||
| 107 | const controlIn = (root: Stub, id: string): Stub => | |
| 108 | [...all(root)].find((node) => node.id === id)!; | |
| 109 | ||
| 110 | test("focus and the caret come back to the same control after a swap", () => { | |
| 111 | const here = region("a draft the server already has"); | |
| 112 | const next = region("a draft the server already has", 2); | |
| 113 | const box = controlIn(here, "say"); | |
| 114 | box.selectionStart = 8; | |
| 115 | box.selectionEnd = 13; | |
| 116 | focused = { el: box }; | |
| 117 | ||
| 118 | expect(swap(here, next, box)).toBe("swapped"); | |
| 119 | const fresh = controlIn(next, "say"); | |
| 120 | expect(focused.el).toBe(fresh); | |
| 121 | expect(fresh.selectionStart).toBe(8); | |
| 122 | expect(fresh.selectionEnd).toBe(13); | |
| 123 | // The region grew by a remark and the page was not scrolled for it. | |
| 124 | expect(focused.scrolled).toBe(false); | |
| 125 | expect(next.children.length).toBe(here.children.length + 1); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a control the fresh markup has dropped hands focus to the heading", () => { | |
| 129 | const here = region("", 2); | |
| 130 | const next = region("", 1); | |
| 131 | const gone = controlIn(here, "delete-1"); | |
| 132 | focused = { el: gone }; | |
| 133 | ||
| 134 | expect(swap(here, next, gone)).toBe("swapped"); | |
| 135 | expect(focused.el).toBe(next.children[0]); // the heading, not <body> | |
| 136 | expect(focused.el!.tagName).toBe("H2"); | |
| 137 | expect(focused.el!.getAttribute("tabindex")).toBe("-1"); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a region nobody is standing in is swapped and takes no focus", () => { | |
| 141 | const here = region(""); | |
| 142 | const next = region(""); | |
| 143 | const elsewhere = el("INPUT", { id: "title", name: "title" }); | |
| 144 | focused = { el: elsewhere }; | |
| 145 | ||
| 146 | expect(swap(here, next, elsewhere)).toBe("swapped"); | |
| 147 | expect(focused.el).toBe(elsewhere); | |
| 148 | }); | |
| 149 | ||
| 150 | test("a control someone is typing in is not replaced, and retries on blur", () => { | |
| 151 | const here = region("half a remar"); | |
| 152 | const page = here.parent!; | |
| 153 | const next = region(""); // The server does not know what was typed. | |
| 154 | const box = controlIn(here, "say"); | |
| 155 | focused = { el: box }; | |
| 156 | ||
| 157 | let retries = 0; | |
| 158 | expect(swap(here, next, box, () => retries++)).toBe("kept"); | |
| 159 | expect(page.children[0]).toBe(here); // still theirs, word for word | |
| 160 | expect(box.value).toBe("half a remar"); | |
| 161 | expect(box.listeners.map((l) => l.type)).toEqual(["blur"]); | |
| 162 | ||
| 163 | // A second event while they are still typing waits on the same blur. | |
| 164 | expect(swap(here, next, box, () => retries++)).toBe("kept"); | |
| 165 | expect(box.listeners.length).toBe(1); | |
| 166 | ||
| 167 | // They leave the box, and the region catches up with what it missed. | |
| 168 | box.listeners[0]!.run(); | |
| 169 | expect(retries).toBe(1); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a value the fresh markup carries is not somebody typing", () => { | |
| 173 | const here = region("said and sent"); | |
| 174 | const next = region("said and sent"); | |
| 175 | const box = controlIn(here, "say"); | |
| 176 | expect(typing(here, next, box)).toBe(false); | |
| 177 | expect(typing(here, region(""), box)).toBe(true); | |
| 178 | // A button is not a thing anyone types in, whatever it says. | |
| 179 | expect(typing(here, region(""), controlIn(here, "delete-0"))).toBe(false); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a spot is an id or a name, and nothing when focus is outside", () => { | |
| 183 | const here = region(""); | |
| 184 | const box = controlIn(here, "say"); | |
| 185 | expect(spotOf(here, box)).toMatchObject({ id: "say", start: 0 }); | |
| 186 | expect(spotOf(here, el("INPUT", { id: "elsewhere" }))).toBeUndefined(); | |
| 187 | expect(spotOf(here, undefined)).toBeUndefined(); | |
| 188 | expect(spotOf(here, here)).toBeUndefined(); | |
| 189 | ||
| 190 | // Found by name when the control has no id of its own. | |
| 191 | const unnamed = el("SECTION", { id: "thread" }, [ | |
| 192 | el("H2"), | |
| 193 | el("TEXTAREA", { name: "body", value: "" }), | |
| 194 | ]); | |
| 195 | const box2 = unnamed.children[1]!; | |
| 196 | expect(spotOf(unnamed, box2)).toMatchObject({ id: "", name: "body" }); | |
| 197 | const fresh = el("SECTION", { id: "thread" }, [ | |
| 198 | el("H2"), | |
| 199 | el("TEXTAREA", { name: "body", value: "" }), | |
| 200 | ]); | |
| 201 | focused = { el: box2 }; | |
| 202 | restore(fresh, spotOf(unnamed, box2)); | |
| 203 | expect(focused.el).toBe(fresh.children[1]); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a control with no id and no name still lands focus on the heading", () => { | |
| 207 | // Nothing in the fresh markup can name it, so it cannot come back — and | |
| 208 | // the point is that focus does not fall to <body> when it does not. | |
| 209 | const plain = el("BUTTON"); | |
| 210 | const here = el("SECTION", { id: "thread" }, [el("H2"), plain]); | |
| 211 | focused = { el: plain }; | |
| 212 | expect(swap(here, region(""), plain)).toBe("swapped"); | |
| 213 | expect(focused.el!.tagName).toBe("H2"); | |
| 214 | expect(focused.scrolled).toBe(false); | |
| 215 | }); |
src/live.test.ts +35 −1
| @@ -3,7 +3,14 @@ import { mkdtempSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { openDb } from "./db.ts"; |
| 6 | import { emit, events, following, subscribe, trim } from "./live.ts"; | |
| 6 | import { | |
| 7 | emit, | |
| 8 | events, | |
| 9 | following, | |
| 10 | subscribe, | |
| 11 | summarize, | |
| 12 | trim, | |
| 13 | } from "./live.ts"; | |
| 7 | 14 | import { serve } from "./server.ts"; |
| 8 | 15 | |
| 9 | 16 | const dir = mkdtempSync(join(tmpdir(), "app-live-")); |
| @@ -235,3 +242,30 @@ test("a write by another process reaches an open page, through the same action", | ||
| 235 | 242 | expect(buffer).toContain("typed at the terminal"); |
| 236 | 243 | await reader.cancel(); |
| 237 | 244 | }); |
| 245 | ||
| 246 | test("every event carries the one line the page announces", async () => { | |
| 247 | // The announcement is the app's own sentence, from the type alone: a | |
| 248 | // reader hears what happened instead of the whole region read again. The | |
| 249 | // words for what nobody has built yet are here too, so the vote and the | |
| 250 | // roster inherit them rather than each inventing one. | |
| 251 | expect(summarize("proposal.opened")).toBe("A new proposal"); | |
| 252 | expect(summarize("proposal.withdrawn")).toBe("A proposal was withdrawn"); | |
| 253 | expect(summarize("remark.added")).toBe("A new remark"); | |
| 254 | expect(summarize("remark.removed")).toBe("A remark was deleted"); | |
| 255 | expect(summarize("remark.accepted")).toBe("A counter became an option"); | |
| 256 | expect(summarize("vote.cast")).toBe("A vote was cast"); | |
| 257 | expect(summarize("member.added")).toBe("A member was added to the roster"); | |
| 258 | expect(summarize("member.removed")).toBe( | |
| 259 | "A member was removed from the roster", | |
| 260 | ); | |
| 261 | // No type is left without a line, whatever a later action emits. | |
| 262 | expect(summarize("something.nobody.wrote")).toBe("Something changed"); | |
| 263 | ||
| 264 | // And the stream is what carries it, beside the event itself. | |
| 265 | const stream = open(); | |
| 266 | emit("remark.added", { id: 1, body: "<script>" }); | |
| 267 | const frames = await stream.drain(); | |
| 268 | expect(frames).toContain('"type":"remark.added"'); | |
| 269 | expect(frames).toContain('"summary":"A new remark"'); | |
| 270 | stream.close(); | |
| 271 | }); |
src/live.ts +27 −1
| @@ -25,6 +25,28 @@ export interface LiveEvent { | ||
| 25 | 25 | readonly data: unknown; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | /** | |
| 29 | * What an event is, in one line, for a page to announce. The page swaps a | |
| 30 | * whole region when one of these arrives; a screen reader told to re-read | |
| 31 | * the region hears everything and learns nothing, so the announcement is | |
| 32 | * this sentence instead — written here, by the app, from the type alone, | |
| 33 | * and never assembled from what anybody typed. | |
| 34 | */ | |
| 35 | const SUMMARIES: Record<string, string> = { | |
| 36 | "proposal.opened": "A new proposal", | |
| 37 | "proposal.withdrawn": "A proposal was withdrawn", | |
| 38 | "remark.added": "A new remark", | |
| 39 | "remark.removed": "A remark was deleted", | |
| 40 | "remark.accepted": "A counter became an option", | |
| 41 | "vote.cast": "A vote was cast", | |
| 42 | "member.added": "A member was added to the roster", | |
| 43 | "member.removed": "A member was removed from the roster", | |
| 44 | }; | |
| 45 | ||
| 46 | /** The line for an event type, and a true but empty-handed one for the rest. */ | |
| 47 | export const summarize = (type: string): string => | |
| 48 | SUMMARIES[type] ?? "Something changed"; | |
| 49 | ||
| 28 | 50 | /** How often the server looks for rows another process wrote. */ |
| 29 | 51 | const TICK_MS = 250; |
| 30 | 52 | const HEARTBEAT_MS = 25_000; |
| @@ -202,7 +224,11 @@ export function events(request: Request): Response { | ||
| 202 | 224 | }; |
| 203 | 225 | const send = (event: LiveEvent): void => |
| 204 | 226 | write( |
| 205 | `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`, | |
| 227 | `id: ${event.id}\ndata: ${JSON.stringify({ | |
| 228 | type: event.type, | |
| 229 | data: event.data, | |
| 230 | summary: summarize(event.type), | |
| 231 | })}\n\n`, | |
| 206 | 232 | ); |
| 207 | 233 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); |
| 208 | 234 | const unsubscribe = subscribe(send); |
src/pages/home.ts +6 −2
| @@ -15,7 +15,7 @@ import { where } from "../env.ts"; | ||
| 15 | 15 | import { escape } from "../http.ts"; |
| 16 | 16 | import { RULES, RULE_TEXT_MAX } from "../quorum.ts"; |
| 17 | 17 | import type { Who } from "../who.ts"; |
| 18 | import { ago, layout, toast } from "./layout.ts"; | |
| 18 | import { ago, layout, liveStatus, toast } from "./layout.ts"; | |
| 19 | 19 | |
| 20 | 20 | export function home(input: { |
| 21 | 21 | who: Who; |
| @@ -60,7 +60,11 @@ export function home(input: { | ||
| 60 | 60 | : `<ul class="list">${proposals.map(item).join("")}</ul>`; |
| 61 | 61 | return layout( |
| 62 | 62 | `<section class="card">${composer}</section> |
| 63 | <section id="proposals" data-live="proposal.opened proposal.withdrawn">${list}</section> | |
| 63 | ${liveStatus("proposals")} | |
| 64 | <section id="proposals" data-live="proposal.opened proposal.withdrawn" data-status="proposals-status" aria-live="polite"> | |
| 65 | <h2 class="offscreen">Proposals</h2> | |
| 66 | ${list} | |
| 67 | </section> | |
| 64 | 68 | ${toast(notice)}`, |
| 65 | 69 | who, |
| 66 | 70 | ); |
src/pages/layout.ts +13 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The frame every page is drawn in, and the small helpers a page shares. |
| 3 | 3 | * The first paint is complete HTML; `static/live.js` keeps the regions |
| 4 | * marked `data-live` fresh afterwards. Each page has its own file here and | |
| 4 | * marked `data-live` fresh afterwards, without moving anybody's focus or | |
| 5 | * throwing away what they are typing. Each page has its own file here and | |
| 5 | 6 | * they all come back to this one `layout`. |
| 6 | 7 | */ |
| 7 | 8 | |
| @@ -49,6 +50,17 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 49 | 50 | `; |
| 50 | 51 | } |
| 51 | 52 | |
| 53 | /** | |
| 54 | * The line a screen reader hears when a live region changes: one sentence | |
| 55 | * the server wrote for the event (`summarize` in `src/live.ts`), put here | |
| 56 | * by `static/live.js`. It sits outside the region it speaks for — a region | |
| 57 | * is replaced wholesale, and an announcement that is replaced along with it | |
| 58 | * is a new node some readers never speak — and the region points at it with | |
| 59 | * `data-status`. Empty on the first paint: nothing has changed yet. | |
| 60 | */ | |
| 61 | export const liveStatus = (region: string): string => | |
| 62 | `<p class="offscreen" id="${region}-status" role="status"></p>`; | |
| 63 | ||
| 52 | 64 | /** A word from the last request, shown once. */ |
| 53 | 65 | export const toast = (notice: string | undefined): string => |
| 54 | 66 | notice === undefined |
src/pages/proposal.ts +16 −6
| @@ -8,6 +8,13 @@ | ||
| 8 | 8 | * region, it grows at the bottom, and it is the last thing on the page — so |
| 9 | 9 | * a remark that arrives while someone is typing moves nothing they can see, |
| 10 | 10 | * and the box they are typing in is never the thing that gets replaced. |
| 11 | * | |
| 12 | * Both live regions here carry a heading and a status line for the same | |
| 13 | * reason: when a swap cannot find the control that had focus, the heading | |
| 14 | * is where focus goes, and the status line is the one sentence a screen | |
| 15 | * reader is told instead of the whole region over again. The thread's | |
| 16 | * heading is `offscreen` — there for a reader, not for the eye — and every | |
| 17 | * control in it is named by an id, so a swap can hand focus back to it. | |
| 11 | 18 | */ |
| 12 | 19 | |
| 13 | 20 | import { |
| @@ -24,7 +31,7 @@ import { | ||
| 24 | 31 | import { escape } from "../http.ts"; |
| 25 | 32 | import { describeRule } from "../quorum.ts"; |
| 26 | 33 | import type { Who } from "../who.ts"; |
| 27 | import { ago, layout, toast } from "./layout.ts"; | |
| 34 | import { ago, layout, liveStatus, toast } from "./layout.ts"; | |
| 28 | 35 | |
| 29 | 36 | /** What each kind is called on the page, and what it is for in one line. */ |
| 30 | 37 | const KIND_LABEL: Record<Kind, string> = { |
| @@ -48,11 +55,12 @@ export function proposalPage(input: { | ||
| 48 | 55 | const withdraw = |
| 49 | 56 | proposal.state === "open" && canWithdraw(who, proposal) |
| 50 | 57 | ? `<form method="post" action="/proposals/${proposal.id}/withdraw"> |
| 51 | <button class="button button-quiet">Withdraw</button> | |
| 58 | <button class="button button-quiet" id="withdraw">Withdraw</button> | |
| 52 | 59 | </form>` |
| 53 | 60 | : ""; |
| 54 | 61 | return layout( |
| 55 | `<section id="proposal" data-live="proposal.opened proposal.withdrawn remark.accepted" class="card"> | |
| 62 | `${liveStatus("proposal")} | |
| 63 | <section id="proposal" data-live="proposal.opened proposal.withdrawn remark.accepted" data-status="proposal-status" aria-live="polite" class="card"> | |
| 56 | 64 | <div class="head-row"> |
| 57 | 65 | <h2>${escape(proposal.title)}</h2> |
| 58 | 66 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> |
| @@ -69,7 +77,9 @@ export function proposalPage(input: { | ||
| 69 | 77 | <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div> |
| 70 | 78 | </section> |
| 71 | 79 | <section class="card">${composer(who, proposal)}</section> |
| 72 | <section id="thread" data-live="remark.added remark.removed remark.accepted"> | |
| 80 | ${liveStatus("thread")} | |
| 81 | <section id="thread" data-live="remark.added remark.removed remark.accepted" data-status="thread-status" aria-live="polite"> | |
| 82 | <h2 class="offscreen">The argument</h2> | |
| 73 | 83 | ${thread(who, proposal, remarks)} |
| 74 | 84 | </section> |
| 75 | 85 | ${toast(notice)}`, |
| @@ -122,13 +132,13 @@ const item = (who: Who, proposal: FullProposal, remark: Remark): string => { | ||
| 122 | 132 | canAcceptCounter(who, proposal) |
| 123 | 133 | ? `<form method="post" action="/remarks/${remark.id}/accept"> |
| 124 | 134 | <input type="hidden" name="proposal" value="${proposal.id}"> |
| 125 | <button class="button button-quiet">Accept as an option</button> | |
| 135 | <button class="button button-quiet" id="accept-${remark.id}">Accept as an option</button> | |
| 126 | 136 | </form>` |
| 127 | 137 | : ""; |
| 128 | 138 | const remove = canDeleteRemark(who, remark) |
| 129 | 139 | ? `<form method="post" action="/remarks/${remark.id}/delete"> |
| 130 | 140 | <input type="hidden" name="proposal" value="${proposal.id}"> |
| 131 | <button class="button button-quiet">Delete</button> | |
| 141 | <button class="button button-quiet" id="delete-${remark.id}">Delete</button> | |
| 132 | 142 | </form>` |
| 133 | 143 | : ""; |
| 134 | 144 | return `<li class="remark" data-kind="${escape(remark.kind)}"> |
src/server.test.ts +20 −1
| @@ -567,13 +567,26 @@ test("the thread reads for a guest, writes for a person, and escapes what it sho | ||
| 567 | 567 | // remark arriving grows the bottom of the page and moves nothing above it, |
| 568 | 568 | // least of all the box someone is typing in. |
| 569 | 569 | expect(page).toContain( |
| 570 | '<section id="thread" data-live="remark.added remark.removed remark.accepted">', | |
| 570 | '<section id="thread" data-live="remark.added remark.removed remark.accepted" data-status="thread-status" aria-live="polite">', | |
| 571 | ); | |
| 572 | // It announces itself in one line the app wrote, in a status element that | |
| 573 | // lives outside the region — the region is replaced, this is only filled | |
| 574 | // in — and it has a heading for focus to land on when what had it is gone. | |
| 575 | expect(page).toContain( | |
| 576 | '<p class="offscreen" id="thread-status" role="status"></p>', | |
| 577 | ); | |
| 578 | expect(page).toContain('<h2 class="offscreen">The argument</h2>'); | |
| 579 | expect(page.indexOf('id="thread-status"')).toBeLessThan( | |
| 580 | page.indexOf('id="thread"'), | |
| 571 | 581 | ); |
| 572 | 582 | const writing = await (await api(`/p/${p.id}`, {}, bob)).text(); |
| 573 | 583 | expect(writing).toContain(`action="/proposals/${p.id}/remarks"`); |
| 574 | 584 | expect(writing.indexOf('id="thread"')).toBeGreaterThan( |
| 575 | 585 | writing.indexOf(`action="/proposals/${p.id}/remarks"`), |
| 576 | 586 | ); |
| 587 | // Every control in the thread is named by an id, which is what a live | |
| 588 | // swap looks the focused one up by in the fresh markup. | |
| 589 | expect(writing).toMatch(/<button class="button button-quiet" id="delete-\d+">/); | |
| 577 | 590 | |
| 578 | 591 | // A kind that is not a kind, and a body past two thousand characters. |
| 579 | 592 | for (const bad of [ |
| @@ -899,6 +912,12 @@ test("a change is on the event stream, and a late client hears what it missed", | ||
| 899 | 912 | await watching.cancel(); |
| 900 | 913 | const page = await (await fetch(base + "/")).text(); |
| 901 | 914 | expect(page).toContain('data-live="proposal.opened proposal.withdrawn"'); |
| 915 | // The list announces itself the same way the thread does, and the | |
| 916 | // announcement is empty until something has actually changed. | |
| 917 | expect(page).toContain('data-status="proposals-status" aria-live="polite"'); | |
| 918 | expect(page).toContain( | |
| 919 | '<p class="offscreen" id="proposals-status" role="status"></p>', | |
| 920 | ); | |
| 902 | 921 | }); |
| 903 | 922 | |
| 904 | 923 | test("writes are rationed per person", async () => { |
static/app.css +15
| @@ -425,6 +425,21 @@ textarea.input { | ||
| 425 | 425 | border-color: currentColor; |
| 426 | 426 | } |
| 427 | 427 | |
| 428 | /* There for a screen reader and for nobody else: the live regions' status | |
| 429 | lines and their headings. Not `display: none` — that is not reached at | |
| 430 | all — and it takes no space, so nothing on the page moves for it. */ | |
| 431 | .offscreen { | |
| 432 | position: absolute; | |
| 433 | width: 1px; | |
| 434 | height: 1px; | |
| 435 | margin: -1px; | |
| 436 | padding: 0; | |
| 437 | border: 0; | |
| 438 | overflow: hidden; | |
| 439 | clip-path: inset(50%); | |
| 440 | white-space: nowrap; | |
| 441 | } | |
| 442 | ||
| 428 | 443 | /* A word from the last request, shown once, gone in a moment. */ |
| 429 | 444 | .toast { |
| 430 | 445 | position: fixed; |
static/live.js +148 −5
| @@ -3,6 +3,14 @@ | ||
| 3 | 3 | // any element with data-live="<types>" is refetched and swapped when one of |
| 4 | 4 | // its types arrives — server-rendered, so the page never renders twice. |
| 5 | 5 | // Without this file the page is the same page, one reload behind. |
| 6 | // | |
| 7 | // A swap must not cost someone their place: what had focus inside a region | |
| 8 | // is found again in the fresh markup and given the caret back, a control | |
| 9 | // somebody is still typing in is not replaced at all, and the one-line | |
| 10 | // summary the server wrote for the event goes in the region's status line, | |
| 11 | // which lives outside the region so that it is updated rather than | |
| 12 | // replaced. Imported anywhere but a browser this file is only functions, | |
| 13 | // which is how the focus code is tested. | |
| 6 | 14 | |
| 7 | 15 | let since = ""; |
| 8 | 16 | let tries = 0; |
| @@ -16,8 +24,9 @@ function connect() { | ||
| 16 | 24 | }; |
| 17 | 25 | source.onmessage = (message) => { |
| 18 | 26 | since = message.lastEventId; |
| 19 | const { type, data } = JSON.parse(message.data); | |
| 20 | document.dispatchEvent(new CustomEvent("live", { detail: { type, data } })); | |
| 27 | const { type, data, summary } = JSON.parse(message.data); | |
| 28 | const detail = { type, data, summary }; | |
| 29 | document.dispatchEvent(new CustomEvent("live", { detail })); | |
| 21 | 30 | document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data })); |
| 22 | 31 | }; |
| 23 | 32 | // The browser retries a dropped connection itself; a refused one it gives |
| @@ -53,7 +62,7 @@ async function refresh(types) { | ||
| 53 | 62 | ); |
| 54 | 63 | for (const el of regions) { |
| 55 | 64 | const next = fresh.getElementById(el.id); |
| 56 | if (next) el.replaceWith(next); | |
| 65 | if (next) swap(el, next, document.activeElement, refresh); | |
| 57 | 66 | } |
| 58 | 67 | } finally { |
| 59 | 68 | refreshing = false; |
| @@ -64,5 +73,139 @@ async function refresh(types) { | ||
| 64 | 73 | } |
| 65 | 74 | } |
| 66 | 75 | |
| 67 | document.addEventListener("live", (event) => refresh([event.detail.type])); | |
| 68 | connect(); | |
| 76 | /** Every element under `root`, `root` itself included. */ | |
| 77 | function* walk(root) { | |
| 78 | yield root; | |
| 79 | for (const child of root.children ?? []) yield* walk(child); | |
| 80 | } | |
| 81 | ||
| 82 | /** The element a spot names, in whichever copy of the region it is asked for. */ | |
| 83 | function find(root, spot) { | |
| 84 | for (const el of walk(root)) | |
| 85 | if (spot.id ? el.id === spot.id : el.getAttribute("name") === spot.name) | |
| 86 | return el; | |
| 87 | return undefined; | |
| 88 | } | |
| 89 | ||
| 90 | const TYPED = /^(INPUT|TEXTAREA|SELECT)$/; | |
| 91 | const value = (el) => (typeof el.value === "string" ? el.value : ""); | |
| 92 | ||
| 93 | /** | |
| 94 | * Where someone's hands are inside this region, in terms the fresh markup | |
| 95 | * can be searched by: an id if the control has one, else its name. Nothing | |
| 96 | * at all when focus is elsewhere — a swap must never take focus from a part | |
| 97 | * of the page it did not touch. A control with neither id nor name is a | |
| 98 | * spot that will not be found again, which is how focus reaches the | |
| 99 | * heading instead of falling to `<body>`. | |
| 100 | */ | |
| 101 | export function spotOf(region, active) { | |
| 102 | if (!active || active === region || !region.contains(active)) return undefined; | |
| 103 | const spot = { | |
| 104 | id: active.id ?? "", | |
| 105 | name: active.getAttribute?.("name") ?? "", | |
| 106 | }; | |
| 107 | try { | |
| 108 | if (typeof active.selectionStart === "number") { | |
| 109 | spot.start = active.selectionStart; | |
| 110 | spot.end = active.selectionEnd; | |
| 111 | spot.direction = active.selectionDirection ?? undefined; | |
| 112 | } | |
| 113 | } catch { | |
| 114 | // Some input types refuse a selection. Focus is the part that matters. | |
| 115 | } | |
| 116 | return spot; | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Whether the focused control holds something the fresh markup does not | |
| 121 | * carry — half a written remark, in other words. Swapping the region would | |
| 122 | * throw it away, so the region waits instead. | |
| 123 | */ | |
| 124 | export function typing(region, next, active) { | |
| 125 | if (!active || !region.contains(active) || !TYPED.test(active.tagName ?? "")) | |
| 126 | return false; | |
| 127 | const mine = value(active); | |
| 128 | if (mine === "") return false; | |
| 129 | const spot = spotOf(region, active); | |
| 130 | const theirs = spot === undefined ? undefined : find(next, spot); | |
| 131 | return theirs === undefined || value(theirs) !== mine; | |
| 132 | } | |
| 133 | ||
| 134 | const HEADING = /^H[1-6]$/; | |
| 135 | ||
| 136 | /** The region's heading: where focus goes when the control it was on is gone. */ | |
| 137 | function heading(region) { | |
| 138 | for (const el of walk(region)) if (HEADING.test(el.tagName ?? "")) return el; | |
| 139 | return region; | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Focus and caret, back where they were. A control that the fresh markup no | |
| 144 | * longer has hands focus to the heading rather than to `<body>`, so a | |
| 145 | * keyboard is still somewhere in the region it was reading. | |
| 146 | */ | |
| 147 | export function restore(region, spot) { | |
| 148 | const target = find(region, spot); | |
| 149 | if (target === undefined) { | |
| 150 | const place = heading(region); | |
| 151 | // A heading is not focusable until it is told to be, and only for this. | |
| 152 | place.setAttribute?.("tabindex", "-1"); | |
| 153 | place.focus?.({ preventScroll: true }); | |
| 154 | return; | |
| 155 | } | |
| 156 | target.focus?.({ preventScroll: true }); // No scroll jump when a region grows. | |
| 157 | if (spot.start === undefined || !target.setSelectionRange) return; | |
| 158 | try { | |
| 159 | target.setSelectionRange(spot.start, spot.end, spot.direction); | |
| 160 | } catch { | |
| 161 | // A control that has no selection is focused and left alone. | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | /** Controls already promised a retry, so a blur is only ever waited on once. */ | |
| 166 | const waiting = new WeakSet(); | |
| 167 | ||
| 168 | /** | |
| 169 | * One region, replaced as carefully as it can be: left alone while someone | |
| 170 | * is typing in it (and refreshed again the moment they leave), otherwise | |
| 171 | * swapped with focus and caret carried across. | |
| 172 | */ | |
| 173 | export function swap(region, next, active, retry) { | |
| 174 | if (typing(region, next, active)) { | |
| 175 | if (!waiting.has(active)) { | |
| 176 | waiting.add(active); | |
| 177 | active.addEventListener?.( | |
| 178 | "blur", | |
| 179 | () => { | |
| 180 | waiting.delete(active); | |
| 181 | retry?.(); | |
| 182 | }, | |
| 183 | { once: true }, | |
| 184 | ); | |
| 185 | } | |
| 186 | return "kept"; | |
| 187 | } | |
| 188 | const spot = spotOf(region, active); | |
| 189 | region.replaceWith(next); | |
| 190 | if (spot) restore(next, spot); | |
| 191 | return "swapped"; | |
| 192 | } | |
| 193 | ||
| 194 | /** The sentence the server wrote for this event, in the regions it touches. */ | |
| 195 | function announce(types, summary) { | |
| 196 | if (!summary) return; | |
| 197 | for (const el of document.querySelectorAll("[data-live][data-status]")) { | |
| 198 | if (!el.dataset.live.split(" ").some((t) => types.includes(t))) continue; | |
| 199 | const status = document.getElementById(el.dataset.status); | |
| 200 | if (status) status.textContent = summary; | |
| 201 | } | |
| 202 | } | |
| 203 | ||
| 204 | if (typeof document !== "undefined" && typeof EventSource !== "undefined") { | |
| 205 | document.addEventListener("live", (event) => { | |
| 206 | const { type, summary } = event.detail; | |
| 207 | announce([type], summary); | |
| 208 | refresh([type]); | |
| 209 | }); | |
| 210 | connect(); | |
| 211 | } |