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

The forge pill works by keyboard alone: Tab reaches it, Enter opens, Escape closes and puts focus back on the pill #6605

Merged opened this

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

Merged

scout/forge:scout/5877-the-forge-pill-works-by-keyboard-alone-t → main 6 commits fork 1 thread · 1 unresolved

  • Merged. scout/5877-the-forge-pill-works-by-keyboard-alone-t was merged into main as 9bbcd1c.

Review

Sign in to review this change.
4 files changed +228 −4
src/apps/toolbar.ts +25 −1
@@ -436,8 +436,32 @@ export const toolbarJs = `(function () {
436436 pill.setAttribute("aria-expanded", open ? "true" : "false");
437437 pill.style.opacity = open ? "1" : ".85";
438438 watch(open);
439 if (open) panel.focus();
439 if (open) {
440 // Focus goes to the panel's first link; a bare panel only when it has none.
441 var first = stops().filter(function (el) { return el.tagName === "A"; })[0];
442 (first || panel).focus();
443 }
440444 }
445 // The panel's own tab stops, in order (read from the panel, never the page).
446 function stops() {
447 var all = panel.getElementsByTagName ? Array.prototype.slice.call(panel.getElementsByTagName("*")) : [];
448 return all.filter(function (el) {
449 var t = el.tagName;
450 return (t === "A" && el.hasAttribute("href")) || ((t === "BUTTON" || t === "INPUT" || t === "TEXTAREA") && !el.disabled);
451 });
452 }
453 // Tab cycles inside the open panel: the last stop wraps to the first, and back.
454 panel.addEventListener("keydown", function (e) {
455 if (e.key !== "Tab") return;
456 var s = stops();
457 if (!s.length) return;
458 var at = root.activeElement;
459 var edge = e.shiftKey ? s[0] : s[s.length - 1];
460 if (at === edge || at === panel) {
461 e.preventDefault();
462 (e.shiftKey ? s[s.length - 1] : s[0]).focus();
463 }
464 });
441465 pill.addEventListener("click", function () {
442466 if (Date.now() < until) return;
443467 set(panel.hidden);
test/apps-crew-pill.test.ts +1 −1
@@ -214,6 +214,6 @@ describe("the crew line", () => {
214214 );
215215 expect(toolbarJs).not.toContain("document.cookie");
216216 expect(toolbarJs).toContain('aria-live", "polite"');
217 expect(toolbarJs.length).toBeLessThan(20 * 1024);
217 expect(toolbarJs.length).toBeLessThan(22 * 1024);
218218 });
219219 });
test/apps-toolbar.test.ts +30 −2
@@ -366,7 +366,7 @@ describe("the forge pill", () => {
366366 expect(res.headers.get("content-type")).toContain("javascript");
367367 const code = await res.text();
368368 expect(code).toBe(toolbarJs);
369 expect(code.length).toBeLessThan(20 * 1024);
369 expect(code.length).toBeLessThan(22 * 1024);
370370 expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/);
371371 expect(code).toContain("attachShadow");
372372 });
@@ -445,7 +445,7 @@ describe("the pill's keyboard and touch behaviour", () => {
445445 const els = all(root);
446446 const pill = els.find((e) => e.tag === "button")!;
447447 const panel = els.find((e) => e.attrs.role === "region")!;
448 return { doc, pill, panel, lines: panel.children };
448 return { doc, pill, panel, root, lines: panel.children };
449449 }
450450
451451
@@ -463,6 +463,34 @@ describe("the pill's keyboard and touch behaviour", () => {
463463 expect(pill.focused).toBe(true);
464464 });
465465
466 test("opening focuses the panel's first link, and Tab wraps inside the panel", () => {
467 const { pill, panel, root } = tree({ app: "notes", env: "production" });
468 const stop = (n: string, tagName: string) => ({ n, tagName, focused: false, disabled: false, hasAttribute: () => true, focus() { this.focused = true; } });
469 const stops = [stop("a", "A"), stop("b", "A"), stop("c", "BUTTON")];
470 const p = panel as unknown as Record<string, unknown>;
471 p.getElementsByTagName = () => stops;
472 pill.listeners.click![0]!();
473 expect(stops[0]!.focused).toBe(true);
474 let stopped = 0;
475 const tabKey = (shift: boolean) => panel.listeners.keydown![0]!({ key: "Tab", shiftKey: shift, preventDefault() { stopped++; } });
476 const at = (el: unknown) => { (root as unknown as Record<string, unknown>).activeElement = el; stops.forEach((s) => (s.focused = false)); stopped = 0; };
477 // Mid-panel, the browser moves focus itself.
outdated

This only checks that Tab from the first stop does not wrap. Add the cases that matter: activeElement is the last stop, so Tab calls preventDefault and focuses stops[0]. activeElement is the first stop with shiftKey, so it focuses the last. Without those, the title is untrue.

478 at(stops[1]);
479 tabKey(false);
480 tabKey(true);
481 expect(stopped).toBe(0);
482 // Tab from the last stop wraps to the first.
483 at(stops[2]);
484 tabKey(false);
485 expect(stopped).toBe(1);
486 expect(stops[0]!.focused).toBe(true);
487 // Shift+Tab from the first stop wraps to the last.
488 at(stops[0]);
489 tabKey(true);
490 expect(stopped).toBe(1);
491 expect(stops[2]!.focused).toBe(true);
492 });
493
466494 test("display carries the state, because an inline display beats [hidden]", () => {
467495 const { doc, pill, panel } = tree({ app: "notes" });
468496 expect(panel.style.display).toBe("none");
test/toolbar-keyboard.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The forge pill works by keyboard alone (#5877): in a real headless
3 * Chromium, on a seeded app page, only key events: Tab reaches the pill,
4 * Enter opens the panel and focuses its first link, Tab cycles inside it,
5 * Escape closes it and puts focus back on the pill. The shadow root is
6 * closed, so focus inside it is read through the devtools protocol.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtempSync, rmSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { childEnv } from "../src/apps/process.ts";
14 import { TOOLBAR_PATH, toolbarJs, toolbarTag } from "../src/apps/toolbar.ts";
15 import { findChromium } from "../src/looks/capture.ts";
16 import { chromiumRuns } from "./helpers/chrome.ts";
17
18 const found = findChromium();
19 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
20 if (chromium === undefined)
21 console.log("SKIPPING: no chromium that starts on this machine, so the pill keyboard test cannot run");
22
23 describe.skipIf(chromium === undefined)("the pill by keyboard", () => {
24 let server: ReturnType<typeof Bun.serve>;
25 let proc: ReturnType<typeof Bun.spawn>;
26 let ws: WebSocket;
27 let profile: string;
28 let nextId = 1;
29 const pending = new Map<number, (m: any) => void>();
30
31 const send = (method: string, params: object = {}): Promise<any> => {
32 const id = nextId++;
33 return new Promise((resolve, reject) => {
34 pending.set(id, (m) => (m.error ? reject(new Error(m.error.message)) : resolve(m.result)));
35 ws.send(JSON.stringify({ id, method, params }));
36 });
37 };
38 const evaluate = async (expression: string): Promise<any> =>
39 (await send("Runtime.evaluate", { expression, returnByValue: true })).result.value;
40
41 const key = async (name: string, code: number, text?: string, modifiers = 0) => {
42 const base = { key: name, code: name, windowsVirtualKeyCode: code, modifiers };
43 await send("Input.dispatchKeyEvent", { type: text ? "keyDown" : "rawKeyDown", ...base, ...(text ? { text } : {}) });
44 await send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
45 };
46 const tab = (shift = false) => key("Tab", 9, undefined, shift ? 8 : 0);
47
48 /** Run `body` (a function source) with `this` the pill's closed shadow root. */
49 const inRoot = async (body: string): Promise<string> => {
50 const { root } = await send("DOM.getDocument", { depth: -1, pierce: true });
51 const find = (n: any): any =>
52 n.attributes?.includes("forge-toolbar") ? n : (n.children ?? []).map(find).find(Boolean);
53 const host = find(root);
54 const { object } = await send("DOM.resolveNode", { backendNodeId: host.shadowRoots[0].backendNodeId });
55 const r = await send("Runtime.callFunctionOn", {
56 objectId: object.objectId,
57 returnByValue: true,
58 functionDeclaration: `function () { ${body} }`,
59 });
60 return r.result.value;
61 };
62 const inside = () =>
63 inRoot(`var a = this.activeElement; return a ? (a.getAttribute("aria-label") || a.textContent) : "";`);
64 const expanded = () => inRoot(`return this.querySelector("button").getAttribute("aria-expanded");`);
65 const outside = () => evaluate(`document.activeElement.id || document.activeElement.tagName`);
66
67 beforeAll(async () => {
68 server = Bun.serve({
69 port: 0,
70 hostname: "127.0.0.1",
71 fetch(req) {
72 const url = new URL(req.url);
73 if (url.pathname === TOOLBAR_PATH)
74 return new Response(toolbarJs, { headers: { "content-type": "text/javascript" } });
75 if (url.pathname === "/__forge/notes") return Response.json({ notes: [] });
76 if (url.pathname === "/__forge/crew") return Response.json({});
77 const tag = toolbarTag(`http://127.0.0.1:${server.port}`, { app: "notes", repo: "me/notes", env: "production" });
78 return new Response(
79 `<!doctype html><title>notes</title><body><a id="first" href="/a">first</a><a id="last" href="/b">last</a>${tag}</body>`,
80 { headers: { "content-type": "text/html; charset=utf-8" } },
81 );
82 },
83 });
84 profile = mkdtempSync(join(tmpdir(), "forge-pill-kbd-"));
85 proc = Bun.spawn(
86 [
87 chromium!, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
88 "--disable-background-networking", "--no-first-run", `--user-data-dir=${profile}`,
89 "--remote-debugging-port=0",
90 ],
91 { env: childEnv({}), stdin: "ignore", stdout: "ignore", stderr: "pipe" },
92 );
93 const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
94 let buf = "";
95 let wsUrl = "";
96 while (!wsUrl) {
97 const { done, value } = await reader.read();
98 if (done) throw new Error("chromium exited early");
99 buf += new TextDecoder().decode(value, { stream: true });
100 wsUrl = buf.match(/DevTools listening on (ws:\/\/\S+)/)?.[1] ?? "";
101 }
102 reader.cancel().catch(() => {});
103 const base = wsUrl.replace(/^ws:/, "http:").replace(/\/devtools\/browser\/.*$/, "");
104 const list = (await (await fetch(`${base}/json/list`)).json()) as any[];
105 ws = new WebSocket(list.find((t) => t.type === "page").webSocketDebuggerUrl);
106 await new Promise<void>((ok, bad) => {
107 ws.addEventListener("open", () => ok());
108 ws.addEventListener("error", () => bad(new Error("devtools refused")));
109 });
110 ws.addEventListener("message", (e) => {
111 const m = JSON.parse(e.data as string);
112 if (m.id !== undefined) pending.get(m.id)?.(m);
113 });
114 await send("Page.enable");
115 await send("DOM.enable");
116 await send("Page.navigate", { url: `http://127.0.0.1:${server.port}/` });
117 for (let i = 0; i < 200; i++) {
118 if ((await evaluate(`document.readyState === "complete" && !!document.getElementById("forge-toolbar")`)) === true) break;
119 await Bun.sleep(25);
120 }
121 }, 30_000);
122
123 afterAll(async () => {
124 ws?.close();
125 proc?.kill("SIGKILL");
126 await proc?.exited.catch(() => {});
127 server?.stop(true);
128 if (profile) rmSync(profile, { recursive: true, force: true });
129 });
130
131 test("Tab, Enter, Tab, Escape: keys only, focus checked at each step", async () => {
132 await evaluate(`document.getElementById("last").focus()`);
133 expect(await outside()).toBe("last");
134
135 await tab();
136 expect(await outside()).toBe("forge-toolbar");
137 expect(await inside()).toBe("notes, production");
138 expect(await expanded()).toBe("false");
139
140 await key("Enter", 13, "\r");
141 expect(await expanded()).toBe("true");
142 expect(await inside()).toBe("Visit it");
143
144 // Tab walks the panel and wraps from the last stop to the first, never out to the page.
145 const seen: string[] = [];
146 for (let i = 0; i < 40 && seen.filter((s) => s === "Visit it").length < 2; i++) {
147 await tab();
148 expect(await outside()).toBe("forge-toolbar");
149 seen.push(await inside());
150 }
151 expect(seen).toContain("Close");
152 expect(seen.filter((s) => s === "Visit it").length).toBeGreaterThan(1);
153
154 await tab(true);
155 expect(await outside()).toBe("forge-toolbar");
156
157 await key("Escape", 27);
158 expect(await expanded()).toBe("false");
159 expect(await outside()).toBe("forge-toolbar");
160 expect(await inside()).toBe("notes, production");
161 }, 30_000);
162
163 test("Space opens it too", async () => {
164 await evaluate(`document.getElementById("last").focus()`);
165 await tab();
166 await key(" ", 32, " ");
167 expect(await expanded()).toBe("true");
168 expect(await inside()).toBe("Visit it");
169 await key("Escape", 27);
170 expect(await expanded()).toBe("false");
171 }, 30_000);
172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs