Public: anyone can read this; the crew can change it.
The toolbar pill's note form works by keyboard and screen reader #12430
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12417-the-toolbar-pill-s-note-form-works-by-ke → main 1 commit fork
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. | ||
| 46 | 46 | - **Off:** `"toolbar": false` in `forge.json`. |
| 47 | 47 | |
| 48 | 48 | 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 () { | ||
| 165 | 165 | tellText.maxLength = 2000; |
| 166 | 166 | tellText.rows = 3; |
| 167 | 167 | tellText.required = true; |
| 168 | tellText.id = "forge-note"; | |
| 169 | tellHint.htmlFor = "forge-note"; | |
| 168 | 170 | tellText.setAttribute("aria-label", "Your note to the builders"); |
| 169 | 171 | 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"; |
| 170 | 172 | var tellPicRow = document.createElement("label"); |
| @@ -182,7 +184,7 @@ const toolbarSource = `(function () { | ||
| 182 | 184 | tellGo.textContent = "Send"; |
| 183 | 185 | 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"; |
| 184 | 186 | var tellSay = document.createElement("div"); |
| 185 | tellSay.setAttribute("aria-live", "polite"); | |
| 187 | tellSay.setAttribute("role", "status"); | |
| 186 | 188 | tellSay.style.cssText = "overflow-wrap:anywhere"; |
| 187 | 189 | var mine = document.createElement("div"); |
| 188 | 190 | mine.style.cssText = "display:flex;flex-direction:column;gap:6px"; |
| @@ -557,9 +559,8 @@ const toolbarSource = `(function () { | ||
| 557 | 559 | pill.style.opacity = open ? "1" : ".85"; |
| 558 | 560 | watch(open); |
| 559 | 561 | 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(); | |
| 563 | 564 | } |
| 564 | 565 | } |
| 565 | 566 | // 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", () = | ||
| 269 | 269 | const els = all(b.panel); |
| 270 | 270 | const form = els.find((e) => e.tag === "form" && e.attrs["aria-label"] === "Tell the builders")!; |
| 271 | 271 | 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")!; | |
| 273 | 273 | area.value = "make it purple"; |
| 274 | 274 | b.fire(form, "submit"); |
| 275 | 275 | 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", () => { | ||
| 499 | 499 | expect(panel.hidden).toBe(true); |
| 500 | 500 | pill.listeners.click![0]!(); |
| 501 | 501 | 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); | |
| 503 | 504 | expect(pill.attrs["aria-expanded"]).toBe("true"); |
| 504 | 505 | pill.focused = false; |
| 505 | 506 | doc.listeners.keydown![0]!({ key: "Escape" }); |
| @@ -507,14 +508,14 @@ describe("the pill's keyboard and touch behaviour", () => { | ||
| 507 | 508 | expect(pill.focused).toBe(true); |
| 508 | 509 | }); |
| 509 | 510 | |
| 510 | test("opening focuses the panel's first link, and Tab wraps inside the panel", () => { | |
| 511 | test("Tab wraps inside the panel", () => { | |
| 511 | 512 | const { pill, panel, root } = tree({ app: "notes", env: "production" }); |
| 512 | 513 | const stop = (n: string, tagName: string) => ({ n, tagName, focused: false, disabled: false, hasAttribute: () => true, focus() { this.focused = true; } }); |
| 513 | 514 | const stops = [stop("a", "A"), stop("b", "A"), stop("c", "BUTTON")]; |
| 514 | 515 | const p = panel as unknown as Record<string, unknown>; |
| 515 | 516 | p.getElementsByTagName = () => stops; |
| 516 | 517 | 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 | |
| 518 | 519 | let stopped = 0; |
| 519 | 520 | const tabKey = (shift: boolean) => panel.listeners.keydown![0]!({ key: "Tab", shiftKey: shift, preventDefault() { stopped++; } }); |
| 520 | 521 | 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", () => { | ||
| 139 | 139 | |
| 140 | 140 | await key("Enter", 13, "\r"); |
| 141 | 141 | expect(await expanded()).toBe("true"); |
| 142 | expect(await inside()).toBe("Visit it"); | |
| 142 | expect(await inside()).toBe("Your note to the builders"); | |
| 143 | 143 | |
| 144 | 144 | // Tab walks the panel and wraps from the last stop to the first, never out to the page. |
| 145 | 145 | 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++) { | |
| 147 | 147 | await tab(); |
| 148 | 148 | expect(await outside()).toBe("forge-toolbar"); |
| 149 | 149 | seen.push(await inside()); |
| 150 | 150 | } |
| 151 | 151 | 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); | |
| 153 | 153 | |
| 154 | 154 | await tab(true); |
| 155 | 155 | expect(await outside()).toBe("forge-toolbar"); |
| @@ -165,8 +165,34 @@ describe.skipIf(chromium === undefined)("the pill by keyboard", () => { | ||
| 165 | 165 | await tab(); |
| 166 | 166 | await key(" ", 32, " "); |
| 167 | 167 | expect(await expanded()).toBe("true"); |
| 168 | expect(await inside()).toBe("Visit it"); | |
| 168 | expect(await inside()).toBe("Your note to the builders"); | |
| 169 | 169 | await key("Escape", 27); |
| 170 | 170 | expect(await expanded()).toBe("false"); |
| 171 | 171 | }, 30_000); |
| 172 | 172 | }); |
| 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 | }); |
new above
offline