From ebded755ca66570793d8c84473302ccc3eddb340 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 27 Sep 2026 12:02:09 -0700 Subject: [PATCH 1/2] fix(command-palette): return focus to the opener on close The palette has no Radix trigger, so Radix restored focus to nothing and it fell to the body. --- src/ui/command-palette.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ui/command-palette.tsx b/src/ui/command-palette.tsx index 6f6566bb..1f6a3e2e 100644 --- a/src/ui/command-palette.tsx +++ b/src/ui/command-palette.tsx @@ -331,6 +331,7 @@ export function CommandPalette({ className, }: CommandPaletteProps) { const inputRef = useRef(null); + const openerRef = useRef(null); const machine = usePaletteMachine(groups, onSelect, () => onOpenChange(false), ); @@ -347,6 +348,19 @@ export function CommandPalette({ className, )} onKeyDown={machine.onKeyDown} + // The palette opens from a shortcut or a host button, never a Radix + // trigger, so Radix has nothing to hand focus back to on close. + onOpenAutoFocus={() => { + openerRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + }} + onCloseAutoFocus={(event) => { + if (openerRef.current === null) return; + event.preventDefault(); + openerRef.current.focus(); + }} > Search
From b4aaee98177da2ce7005fb2eab3d94c5e3b90732 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 27 Sep 2026 12:02:09 -0700 Subject: [PATCH 2/2] test(e2e): add approval, palette and theme browser specs --- e2e/__screenshots__/dark.png | Bin 0 -> 253 bytes e2e/__screenshots__/light.png | Bin 0 -> 251 bytes e2e/approval-card.spec.ts | 21 +++++++ e2e/command-palette.spec.ts | 52 ++++++++++++++++ e2e/fixtures.tsx | 113 +++++++++++++++++++++++++++++++++- e2e/theme.spec.ts | 28 +++++++++ playwright.config.ts | 2 + 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 e2e/__screenshots__/dark.png create mode 100644 e2e/__screenshots__/light.png create mode 100644 e2e/approval-card.spec.ts create mode 100644 e2e/command-palette.spec.ts create mode 100644 e2e/theme.spec.ts diff --git a/e2e/__screenshots__/dark.png b/e2e/__screenshots__/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..cb58b8b2488601cf7d5f4af936f1c910a10d1f27 GIT binary patch literal 253 zcmeAS@N?(olHy`uVBq!ia0y~yV3Yu|H8_}n3PZ!6KiaBpC8}>3eh&UXa zf7)`PBZIBz!ewquWfL4aS#lmQ2wqTZnx13)IPqWl#Ot38wPpOLM?S3jafa*p@9MA{ zd1jjrIyz!XJGjMUv%~nVW8XDKyGJd YD39fJoG{z*F;IlT)78&qol`;+0KJe%mjD0& literal 0 HcmV?d00001 diff --git a/e2e/approval-card.spec.ts b/e2e/approval-card.spec.ts new file mode 100644 index 00000000..e6eff737 --- /dev/null +++ b/e2e/approval-card.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("approve and reject each fire their callback once", async ({ page }) => { + await page.goto("/approval-card"); + await page.getByRole("button", { name: "Approve" }).click(); + await page.getByRole("button", { name: "Reject" }).click(); + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + "approve,reject", + ); +}); + +test("a non-idle state disables both buttons", async ({ page }) => { + await page.goto("/approval-card-busy"); + const approve = page.getByRole("button", { name: "Approving…" }); + const reject = page.getByRole("button", { name: "Reject" }); + await expect(approve).toBeDisabled(); + await expect(reject).toBeDisabled(); + await approve.click({ force: true }); + await reject.click({ force: true }); + await expect(page.getByRole("status", { name: "Events" })).toHaveText(""); +}); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 00000000..43d19ac2 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test("arrow keys move the selection and Enter fires the command", async ({ + page, +}) => { + await page.goto("/command-palette"); + await page.getByRole("button", { name: "Open palette" }).click(); + const input = page.getByRole("combobox"); + await expect(input).toBeFocused(); + await expect(page.getByRole("option", { name: "Settings" })).toHaveAttribute( + "aria-selected", + "true", + ); + + await input.press("ArrowDown"); + await expect(page.getByRole("option", { name: "Billing" })).toHaveAttribute( + "aria-selected", + "true", + ); + await input.press("ArrowDown"); + await input.press("ArrowUp"); + await input.press("Enter"); + + await expect(page.getByRole("status", { name: "Events" })).toHaveText( + "billing", + ); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect( + page.getByRole("button", { name: "Open palette" }), + ).toBeFocused(); +}); + +test("Tab stays inside the open dialog and Escape returns focus to the trigger", async ({ + page, +}) => { + await page.goto("/command-palette"); + const trigger = page.getByRole("button", { name: "Open palette" }); + await trigger.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + for (let press = 0; press < 5; press += 1) { + await page.keyboard.press("Tab"); + expect( + await dialog.evaluate((node) => node.contains(document.activeElement)), + ).toBe(true); + } + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 052d79f7..b20b7e01 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -1,7 +1,118 @@ +import { useState } from "react"; import type { ReactNode } from "react"; -import { Button } from "@corbits/react-ui"; +import { + ApprovalCard, + Button, + ThemeProvider, + ThemeToggle, + type ApprovalRequest, +} from "@corbits/react-ui"; +import { + CommandPalette, + type CommandPaletteGroup, +} from "@corbits/react-ui/ui/command-palette"; + +const REQUEST: ApprovalRequest = { + id: "req-1", + headline: "Post to #sales (Slack)", + requestedBy: "Scout", + details: [{ label: "Message", value: "Q3 numbers are in." }], +}; + +const GROUPS: CommandPaletteGroup[] = [ + { + id: "pages", + heading: "Pages", + items: [ + { id: "settings", title: "Settings" }, + { id: "billing", title: "Billing" }, + { id: "usage", title: "Usage" }, + ], + }, +]; + +function useEventLog() { + const [events, setEvents] = useState([]); + const log = {events.join(",")}; + return { + log, + record: (event: string) => setEvents((previous) => [...previous, event]), + }; +} + +function Approval({ state }: { state: "idle" | "approving" }) { + const { log, record } = useEventLog(); + return ( + <> + record("approve")} + onReject={() => record("reject")} + /> + {log} + + ); +} + +function Palette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const { log, record } = useEventLog(); + return ( + <> + + { + record(id); + setOpen(false); + }} + /> + {log} + + ); +} + +const SWATCH_TOKENS = [ + "background", + "foreground", + "card", + "primary", + "muted", + "border", + "destructive", +]; + +/** Solid token fills with no text, so screenshots match across platforms. */ +function Swatches() { + return ( +
+ {SWATCH_TOKENS.map((token) => ( +
+ ))} +
+ ); +} export const scenarios: Record ReactNode> = { smoke: () => , + "approval-card": () => , + "approval-card-busy": () => , + "command-palette": () => , + theme: () => , + "theme-toggle": () => ( + + + + + ), }; diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts new file mode 100644 index 00000000..05de5874 --- /dev/null +++ b/e2e/theme.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; + +test("light tokens with no class on a light OS", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/theme"); + await expect(page.getByTestId("swatches")).toHaveScreenshot("light.png"); +}); + +test("a .dark root applies the dark tokens", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/theme"); + await page.evaluate(() => document.documentElement.classList.add("dark")); + await expect(page.getByTestId("swatches")).toHaveScreenshot("dark.png"); +}); + +test("a classless root follows a dark OS", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/theme"); + await expect(page.getByTestId("swatches")).toHaveScreenshot("dark.png"); +}); + +test("ThemeToggle flips the tokens from light to dark", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/theme-toggle"); + await expect(page.getByTestId("swatches")).toHaveScreenshot("light.png"); + await page.getByRole("button", { name: "Light theme" }).click(); + await expect(page.getByTestId("swatches")).toHaveScreenshot("dark.png"); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 57c710fa..4e2a30cc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ testMatch: "*.spec.ts", reporter: "list", globalTeardown: "./e2e/teardown.ts", + // Screenshots are text-free token swatches, so one baseline serves every OS. + snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}", use: { baseURL: "http://localhost:4173" }, projects: [{ name: "chromium", use: devices["Desktop Chrome"] }], webServer: {