diff --git a/e2e/__screenshots__/dark.png b/e2e/__screenshots__/dark.png new file mode 100644 index 00000000..cb58b8b2 Binary files /dev/null and b/e2e/__screenshots__/dark.png differ diff --git a/e2e/__screenshots__/light.png b/e2e/__screenshots__/light.png new file mode 100644 index 00000000..a897a3e4 Binary files /dev/null and b/e2e/__screenshots__/light.png differ 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: { 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