Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added e2e/__screenshots__/dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/__screenshots__/light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions e2e/approval-card.spec.ts
Original file line number Diff line number Diff line change
@@ -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("");
});
52 changes: 52 additions & 0 deletions e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
113 changes: 112 additions & 1 deletion e2e/fixtures.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
const log = <output aria-label="Events">{events.join(",")}</output>;
return {
log,
record: (event: string) => setEvents((previous) => [...previous, event]),
};
}

function Approval({ state }: { state: "idle" | "approving" }) {
const { log, record } = useEventLog();
return (
<>
<ApprovalCard
request={REQUEST}
state={state}
onApprove={() => record("approve")}
onReject={() => record("reject")}
/>
{log}
</>
);
}

function Palette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const { log, record } = useEventLog();
return (
<>
<Button onClick={() => setOpen(true)}>Open palette</Button>
<CommandPalette
open={open}
onOpenChange={setOpen}
query={query}
onQueryChange={setQuery}
groups={GROUPS}
onSelect={(id) => {
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 (
<div data-testid="swatches" style={{ display: "flex", width: 280 }}>
{SWATCH_TOKENS.map((token) => (
<div
key={token}
style={{ width: 40, height: 40, background: `var(--${token})` }}
/>
))}
</div>
);
}

export const scenarios: Record<string, () => ReactNode> = {
smoke: () => <Button>Ready</Button>,
"approval-card": () => <Approval state="idle" />,
"approval-card-busy": () => <Approval state="approving" />,
"command-palette": () => <Palette />,
theme: () => <Swatches />,
"theme-toggle": () => (
<ThemeProvider storageKey="e2e-theme" defaultMode="light">
<ThemeToggle />
<Swatches />
</ThemeProvider>
),
};
28 changes: 28 additions & 0 deletions e2e/theme.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
2 changes: 2 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 14 additions & 0 deletions src/ui/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export function CommandPalette({
className,
}: CommandPaletteProps) {
const inputRef = useRef<HTMLInputElement>(null);
const openerRef = useRef<HTMLElement | null>(null);
const machine = usePaletteMachine(groups, onSelect, () =>
onOpenChange(false),
);
Expand All @@ -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();
}}
>
<DialogTitle className="sr-only">Search</DialogTitle>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
Expand Down
Loading