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

The toolbar pill's note form works by keyboard and screen reader #12430

Merged opened this

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

Merged

scout/forge:scout/12417-the-toolbar-pill-s-note-form-works-by-ke → main 1 commit fork

  • Merged. scout/12417-the-toolbar-pill-s-note-form-works-by-ke was merged into main as cc21c84.

Review

Sign in to review this change.
5 files changed +44 −12
docs/features/forge-toolbar.md +4
@@ -46,3 +46,7 @@ preview), so you always know where you are and can get back to the forge.
4646 - **Off:** `"toolbar": false` in `forge.json`.
4747
4848 Tests: `test/toolbar-injection.test.ts`, `test/apps-toolbar.test.ts`, `test/note-context.test.ts`, `test/apps-feedback.test.ts`, `test/apps-toolbar-yours.test.ts`, `test/toolbar-serving.test.ts`.
49
50 ## By keyboard and screen reader
51
52 The pill is a button named for the app and where it runs, with `aria-expanded`. Enter or Space opens the panel and moves focus to the note field; Escape closes it and returns focus to the pill. The note form and its field have labels, and the line under Send is `role="status"`, set as text, so "Sent" or the refusal is announced and never parsed as markup. `test/toolbar-keyboard.test.ts` asserts this from the shipped script (and in a real Chromium where one runs).
src/apps/toolbar.ts +5 −4
@@ -165,6 +165,8 @@ const toolbarSource = `(function () {
165165 tellText.maxLength = 2000;
166166 tellText.rows = 3;
167167 tellText.required = true;
168 tellText.id = "forge-note";
169 tellHint.htmlFor = "forge-note";
168170 tellText.setAttribute("aria-label", "Your note to the builders");
169171 tellText.style.cssText = "box-sizing:border-box;width:100%;font:16px/1.3 system-ui,sans-serif;padding:8px;border-radius:8px;border:1px solid #57606a;background:#0d1117;color:#fff;resize:vertical";
170172 var tellPicRow = document.createElement("label");
@@ -182,7 +184,7 @@ const toolbarSource = `(function () {
182184 tellGo.textContent = "Send";
183185 tellGo.style.cssText = "all:unset;box-sizing:border-box;cursor:pointer;min-height:44px;text-align:center;line-height:44px;border-radius:8px;background:#238636;color:#fff;font-weight:600";
184186 var tellSay = document.createElement("div");
185 tellSay.setAttribute("aria-live", "polite");
187 tellSay.setAttribute("role", "status");
186188 tellSay.style.cssText = "overflow-wrap:anywhere";
187189 var mine = document.createElement("div");
188190 mine.style.cssText = "display:flex;flex-direction:column;gap:6px";
@@ -557,9 +559,8 @@ const toolbarSource = `(function () {
557559 pill.style.opacity = open ? "1" : ".85";
558560 watch(open);
559561 if (open) {
560 // Focus goes to the panel's first link; a bare panel only when it has none.
561 var first = stops().filter(function (el) { return el.tagName === "A"; })[0];
562 (first || panel).focus();
562 // Focus goes to the note field, the panel's main job.
563 tellText.focus();
563564 }
564565 }
565566 // The panel's own tab stops, in order (read from the panel, never the page).
test/apps-toolbar-tell.test.ts +1 −1
@@ -269,7 +269,7 @@ describe("a tell from the toolbar that fails keeps the words and says why", () =
269269 const els = all(b.panel);
270270 const form = els.find((e) => e.tag === "form" && e.attrs["aria-label"] === "Tell the builders")!;
271271 const area = els.find((e) => e.tag === "textarea")!;
272 const say = els.find((e) => e.attrs["aria-live"] === "polite")!;
272 const say = els.find((e) => e.attrs.role === "status")!;
273273 area.value = "make it purple";
274274 b.fire(form, "submit");
275275 for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
test/apps-toolbar.test.ts +4 −3
@@ -499,7 +499,8 @@ describe("the pill's keyboard and touch behaviour", () => {
499499 expect(panel.hidden).toBe(true);
500500 pill.listeners.click![0]!();
501501 expect(panel.hidden).toBe(false);
502 expect(panel.focused).toBe(true);
502 const note = (function walk(e: any): any { return e.tag === "textarea" ? e : e.children.map(walk).find(Boolean); })(panel);
503 expect(note.focused).toBe(true);
503504 expect(pill.attrs["aria-expanded"]).toBe("true");
504505 pill.focused = false;
505506 doc.listeners.keydown![0]!({ key: "Escape" });
@@ -507,14 +508,14 @@ describe("the pill's keyboard and touch behaviour", () => {
507508 expect(pill.focused).toBe(true);
508509 });
509510
510 test("opening focuses the panel's first link, and Tab wraps inside the panel", () => {
511 test("Tab wraps inside the panel", () => {
511512 const { pill, panel, root } = tree({ app: "notes", env: "production" });
512513 const stop = (n: string, tagName: string) => ({ n, tagName, focused: false, disabled: false, hasAttribute: () => true, focus() { this.focused = true; } });
513514 const stops = [stop("a", "A"), stop("b", "A"), stop("c", "BUTTON")];
514515 const p = panel as unknown as Record<string, unknown>;
515516 p.getElementsByTagName = () => stops;
516517 pill.listeners.click![0]!();
517 expect(stops[0]!.focused).toBe(true);
518 expect(stops[0]!.focused).toBe(false); // focus goes to the note field, not a link
518519 let stopped = 0;
519520 const tabKey = (shift: boolean) => panel.listeners.keydown![0]!({ key: "Tab", shiftKey: shift, preventDefault() { stopped++; } });
520521 const at = (el: unknown) => { (root as unknown as Record<string, unknown>).activeElement = el; stops.forEach((s) => (s.focused = false)); stopped = 0; };
test/toolbar-keyboard.test.ts +30 −4
@@ -139,17 +139,17 @@ describe.skipIf(chromium === undefined)("the pill by keyboard", () => {
139139
140140 await key("Enter", 13, "\r");
141141 expect(await expanded()).toBe("true");
142 expect(await inside()).toBe("Visit it");
142 expect(await inside()).toBe("Your note to the builders");
143143
144144 // Tab walks the panel and wraps from the last stop to the first, never out to the page.
145145 const seen: string[] = [];
146 for (let i = 0; i < 40 && seen.filter((s) => s === "Visit it").length < 2; i++) {
146 for (let i = 0; i < 40 && seen.filter((s) => s === "Your note to the builders").length < 2; i++) {
147147 await tab();
148148 expect(await outside()).toBe("forge-toolbar");
149149 seen.push(await inside());
150150 }
151151 expect(seen).toContain("Close");
152 expect(seen.filter((s) => s === "Visit it").length).toBeGreaterThan(1);
152 expect(seen.filter((s) => s === "Your note to the builders").length).toBeGreaterThan(1);
153153
154154 await tab(true);
155155 expect(await outside()).toBe("forge-toolbar");
@@ -165,8 +165,34 @@ describe.skipIf(chromium === undefined)("the pill by keyboard", () => {
165165 await tab();
166166 await key(" ", 32, " ");
167167 expect(await expanded()).toBe("true");
168 expect(await inside()).toBe("Visit it");
168 expect(await inside()).toBe("Your note to the builders");
169169 await key("Escape", 27);
170170 expect(await expanded()).toBe("false");
171171 }, 30_000);
172172 });
173
174 describe("the note form, from the shipped script", () => {
175 test("the pill is a named button that says whether it is open", () => {
176 expect(toolbarJs).toContain('document.createElement("button")');
177 expect(toolbarJs).toContain('pill.setAttribute("aria-label"');
178 expect(toolbarJs).toContain('pill.setAttribute("aria-expanded"');
179 });
180 test("opening focuses the note field; Escape closes and returns to the pill", () => {
181 expect(toolbarJs).toContain("tellText.focus()");
182 expect(toolbarJs).toMatch(/e\.key !== "Escape"[^]*?set\(false\);\s*pill\.focus\(\)/);
183 });
184 test("the form's controls have labels", () => {
185 expect(toolbarJs).toContain('tellBox.setAttribute("aria-label"');
186 expect(toolbarJs).toContain('tellText.setAttribute("aria-label"');
187 expect(toolbarJs).toContain('tellHint.htmlFor = "forge-note"');
188 expect(toolbarJs).toContain("tellPicRow = document.createElement(\"label\")");
189 });
190 test("the status line is role=status and is set as text, never markup", () => {
191 expect(toolbarJs).toContain('tellSay.setAttribute("role", "status")');
192 expect(toolbarJs).toMatch(/tellSay\.textContent =/);
193 expect(toolbarJs).not.toMatch(/tellSay\.(innerHTML|outerHTML)|insertAdjacentHTML/);
194 });
195 test("the pill adds nothing to the page's layout", () => {
196 expect(toolbarJs).toMatch(/host\.style\.cssText = "[^"]*position:fixed/);
197 });
198 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs