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

Merged opened this

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

Merged

hearth/quorum:hearth/49-the-live-region-keeps-your-place-focus-a → main 1 commit fork

  • Merged. hearth/49-the-live-region-keeps-your-place-focus-a was merged into main as 3464fb1.

Review

Sign in to review this change.
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
237237 hour and the last thousand. Without JavaScript it is the same page, one
238238 reload behind.
239239
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
240253 The terminal opens with the exact command in `FORGE_APP_SSH` when the app
241254 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge
242255 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";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 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";
714 import { serve } from "./server.ts";
815
916 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",
235242 expect(buffer).toContain("typed at the terminal");
236243 await reader.cancel();
237244 });
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 {
2525 readonly data: unknown;
2626 }
2727
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
2850 /** How often the server looks for rows another process wrote. */
2951 const TICK_MS = 250;
3052 const HEARTBEAT_MS = 25_000;
@@ -202,7 +224,11 @@ export function events(request: Request): Response {
202224 };
203225 const send = (event: LiveEvent): void =>
204226 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`,
206232 );
207233 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
208234 const unsubscribe = subscribe(send);
src/pages/home.ts +6 −2
@@ -15,7 +15,7 @@ import { where } from "../env.ts";
1515 import { escape } from "../http.ts";
1616 import { RULES, RULE_TEXT_MAX } from "../quorum.ts";
1717 import type { Who } from "../who.ts";
18 import { ago, layout, toast } from "./layout.ts";
18 import { ago, layout, liveStatus, toast } from "./layout.ts";
1919
2020 export function home(input: {
2121 who: Who;
@@ -60,7 +60,11 @@ export function home(input: {
6060 : `<ul class="list">${proposals.map(item).join("")}</ul>`;
6161 return layout(
6262 `<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>
6468 ${toast(notice)}`,
6569 who,
6670 );
src/pages/layout.ts +13 −1
@@ -1,7 +1,8 @@
11 /**
22 * The frame every page is drawn in, and the small helpers a page shares.
33 * 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
56 * they all come back to this one `layout`.
67 */
78
@@ -49,6 +50,17 @@ export function layout(body: string, who: Who, path = "/"): string {
4950 `;
5051 }
5152
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
5264 /** A word from the last request, shown once. */
5365 export const toast = (notice: string | undefined): string =>
5466 notice === undefined
src/pages/proposal.ts +16 −6
@@ -8,6 +8,13 @@
88 * region, it grows at the bottom, and it is the last thing on the page — so
99 * a remark that arrives while someone is typing moves nothing they can see,
1010 * 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.
1118 */
1219
1320 import {
@@ -24,7 +31,7 @@ import {
2431 import { escape } from "../http.ts";
2532 import { describeRule } from "../quorum.ts";
2633 import type { Who } from "../who.ts";
27 import { ago, layout, toast } from "./layout.ts";
34 import { ago, layout, liveStatus, toast } from "./layout.ts";
2835
2936 /** What each kind is called on the page, and what it is for in one line. */
3037 const KIND_LABEL: Record<Kind, string> = {
@@ -48,11 +55,12 @@ export function proposalPage(input: {
4855 const withdraw =
4956 proposal.state === "open" && canWithdraw(who, proposal)
5057 ? `<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>
5259 </form>`
5360 : "";
5461 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">
5664 <div class="head-row">
5765 <h2>${escape(proposal.title)}</h2>
5866 <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span>
@@ -69,7 +77,9 @@ export function proposalPage(input: {
6977 <div class="row">${withdraw}<a class="button button-quiet" href="/">All proposals</a></div>
7078 </section>
7179 <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>
7383 ${thread(who, proposal, remarks)}
7484 </section>
7585 ${toast(notice)}`,
@@ -122,13 +132,13 @@ const item = (who: Who, proposal: FullProposal, remark: Remark): string => {
122132 canAcceptCounter(who, proposal)
123133 ? `<form method="post" action="/remarks/${remark.id}/accept">
124134 <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>
126136 </form>`
127137 : "";
128138 const remove = canDeleteRemark(who, remark)
129139 ? `<form method="post" action="/remarks/${remark.id}/delete">
130140 <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>
132142 </form>`
133143 : "";
134144 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
567567 // remark arriving grows the bottom of the page and moves nothing above it,
568568 // least of all the box someone is typing in.
569569 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"'),
571581 );
572582 const writing = await (await api(`/p/${p.id}`, {}, bob)).text();
573583 expect(writing).toContain(`action="/proposals/${p.id}/remarks"`);
574584 expect(writing.indexOf('id="thread"')).toBeGreaterThan(
575585 writing.indexOf(`action="/proposals/${p.id}/remarks"`),
576586 );
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+">/);
577590
578591 // A kind that is not a kind, and a body past two thousand characters.
579592 for (const bad of [
@@ -899,6 +912,12 @@ test("a change is on the event stream, and a late client hears what it missed",
899912 await watching.cancel();
900913 const page = await (await fetch(base + "/")).text();
901914 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 );
902921 });
903922
904923 test("writes are rationed per person", async () => {
static/app.css +15
@@ -425,6 +425,21 @@ textarea.input {
425425 border-color: currentColor;
426426 }
427427
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
428443 /* A word from the last request, shown once, gone in a moment. */
429444 .toast {
430445 position: fixed;
static/live.js +148 −5
@@ -3,6 +3,14 @@
33 // any element with data-live="<types>" is refetched and swapped when one of
44 // its types arrives — server-rendered, so the page never renders twice.
55 // 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.
614
715 let since = "";
816 let tries = 0;
@@ -16,8 +24,9 @@ function connect() {
1624 };
1725 source.onmessage = (message) => {
1826 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 }));
2130 document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data }));
2231 };
2332 // The browser retries a dropped connection itself; a refused one it gives
@@ -53,7 +62,7 @@ async function refresh(types) {
5362 );
5463 for (const el of regions) {
5564 const next = fresh.getElementById(el.id);
56 if (next) el.replaceWith(next);
65 if (next) swap(el, next, document.activeElement, refresh);
5766 }
5867 } finally {
5968 refreshing = false;
@@ -64,5 +73,139 @@ async function refresh(types) {
6473 }
6574 }
6675
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs