diff --git a/apps/web/src/components/ui/Badge.test.tsx b/apps/web/src/components/ui/Badge.test.tsx
new file mode 100644
index 0000000..743ffd5
--- /dev/null
+++ b/apps/web/src/components/ui/Badge.test.tsx
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import { Badge } from "./Badge.js";
+
+describe("Badge", () => {
+ it("renders children text", () => {
+ render(Active);
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ });
+
+ it("applies default variant classes when no variant is specified", () => {
+ render(Default);
+ const badge = screen.getByText("Default");
+ expect(badge.className).toContain("bg-gray-100");
+ expect(badge.className).toContain("text-gray-700");
+ });
+
+ it("applies success variant classes", () => {
+ render(OK);
+ const badge = screen.getByText("OK");
+ expect(badge.className).toContain("bg-green-100");
+ expect(badge.className).toContain("text-green-700");
+ });
+
+ it("applies danger variant classes", () => {
+ render(Error);
+ expect(screen.getByText("Error").className).toContain("bg-red-100");
+ });
+
+ it("applies warning variant classes", () => {
+ render(Warn);
+ expect(screen.getByText("Warn").className).toContain("bg-yellow-100");
+ });
+
+ it("applies info variant classes", () => {
+ render(Info);
+ expect(screen.getByText("Info").className).toContain("bg-blue-100");
+ });
+
+ it("merges custom className", () => {
+ render(Test);
+ expect(screen.getByText("Test").className).toContain("custom-class");
+ });
+
+ it("renders as a span element", () => {
+ render(Span);
+ expect(screen.getByText("Span").tagName).toBe("SPAN");
+ });
+});
diff --git a/apps/web/src/components/ui/Button.test.tsx b/apps/web/src/components/ui/Button.test.tsx
new file mode 100644
index 0000000..acf0c3f
--- /dev/null
+++ b/apps/web/src/components/ui/Button.test.tsx
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { Button } from "./Button.js";
+
+describe("Button", () => {
+ it("renders children text", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
+ });
+
+ it("applies primary variant by default", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("bg-brand-600");
+ });
+
+ it("applies secondary variant", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("border-gray-300");
+ });
+
+ it("applies ghost variant", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("hover:bg-gray-100");
+ });
+
+ it("applies danger variant", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("bg-red-600");
+ });
+
+ it("applies small size", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("px-3");
+ expect(screen.getByRole("button").className).toContain("text-xs");
+ });
+
+ it("applies medium size by default", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("px-4");
+ expect(screen.getByRole("button").className).toContain("text-sm");
+ });
+
+ it("applies large size", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("px-6");
+ expect(screen.getByRole("button").className).toContain("text-base");
+ });
+
+ it("handles click events", async () => {
+ const onClick = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button"));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("disables the button when disabled prop is true", () => {
+ render();
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+
+ it("does not fire click when disabled", async () => {
+ const onClick = vi.fn();
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button"));
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("merges custom className", () => {
+ render();
+ expect(screen.getByRole("button").className).toContain("extra");
+ });
+
+ it("passes through additional HTML attributes", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("submit-btn")).toHaveAttribute("type", "submit");
+ });
+});
diff --git a/apps/web/src/components/ui/ConfirmDialog.test.tsx b/apps/web/src/components/ui/ConfirmDialog.test.tsx
new file mode 100644
index 0000000..66d03aa
--- /dev/null
+++ b/apps/web/src/components/ui/ConfirmDialog.test.tsx
@@ -0,0 +1,65 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { ConfirmDialog } from "./ConfirmDialog.js";
+
+describe("ConfirmDialog", () => {
+ const defaultProps = {
+ title: "Delete item?",
+ message: "This action cannot be undone.",
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ };
+
+ it("renders title and message", () => {
+ render();
+ expect(screen.getByText("Delete item?")).toBeInTheDocument();
+ expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
+ });
+
+ it("uses default button labels", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
+ });
+
+ it("uses custom button labels", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Yes, delete" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "No, keep" })).toBeInTheDocument();
+ });
+
+ it("calls onConfirm when confirm button is clicked", async () => {
+ const onConfirm = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
+ expect(onConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("calls onCancel when cancel button is clicked", async () => {
+ const onCancel = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it("applies danger styling to confirm button when variant is danger", () => {
+ render();
+ const confirmBtn = screen.getByRole("button", { name: "Confirm" });
+ expect(confirmBtn.className).toContain("bg-red-600");
+ });
+
+ it("applies default styling to confirm button when variant is default", () => {
+ render();
+ const confirmBtn = screen.getByRole("button", { name: "Confirm" });
+ expect(confirmBtn.className).toContain("bg-brand-600");
+ });
+
+ it("calls onCancel when clicking the backdrop", async () => {
+ const onCancel = vi.fn();
+ const { container } = render();
+ const backdrop = container.firstElementChild!;
+ await userEvent.click(backdrop);
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/components/ui/EmptyState.test.tsx b/apps/web/src/components/ui/EmptyState.test.tsx
new file mode 100644
index 0000000..ffe7775
--- /dev/null
+++ b/apps/web/src/components/ui/EmptyState.test.tsx
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { EmptyState } from "./EmptyState.js";
+
+describe("EmptyState", () => {
+ it("renders the title", () => {
+ render();
+ expect(screen.getByText("No results")).toBeInTheDocument();
+ });
+
+ it("renders detail text when provided", () => {
+ render();
+ expect(screen.getByText("Try adjusting your filters")).toBeInTheDocument();
+ });
+
+ it("does not render detail when not provided", () => {
+ const { container } = render();
+ const paragraphs = container.querySelectorAll("p");
+ expect(paragraphs).toHaveLength(1);
+ });
+
+ it("renders an action button when action is provided", () => {
+ const action = { label: "Create new", onClick: vi.fn() };
+ render();
+ expect(screen.getByRole("button", { name: "Create new" })).toBeInTheDocument();
+ });
+
+ it("calls action onClick when button is clicked", async () => {
+ const onClick = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "Add" }));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
+ it("does not render action button when not provided", () => {
+ render();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("renders icon when provided", () => {
+ render(Icon} />);
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
+ });
+
+ it("applies testId to the container", () => {
+ render();
+ expect(screen.getByTestId("empty-state")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/ui/FilterBar.test.tsx b/apps/web/src/components/ui/FilterBar.test.tsx
new file mode 100644
index 0000000..688d03c
--- /dev/null
+++ b/apps/web/src/components/ui/FilterBar.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "~/test-utils.js";
+import userEvent from "@testing-library/user-event";
+import { FilterBar } from "./FilterBar.js";
+
+describe("FilterBar", () => {
+ it("renders children", () => {
+ render(
+
+ Filter content
+ ,
+ );
+ expect(screen.getByText("Filter content")).toBeInTheDocument();
+ });
+
+ it("does not show clear button when no active filters", () => {
+ render(
+
+ Filters
+ ,
+ );
+ expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
+ });
+
+ it("does not show clear button when onClearFilters is not provided", () => {
+ render(
+
+ Filters
+ ,
+ );
+ expect(screen.queryByText("Clear filters")).not.toBeInTheDocument();
+ });
+
+ it("shows clear button when hasActiveFilters and onClearFilters are both provided", () => {
+ render(
+
+ Filters
+ ,
+ );
+ expect(screen.getByText("Clear filters")).toBeInTheDocument();
+ });
+
+ it("calls onClearFilters when clear button is clicked", async () => {
+ const onClear = vi.fn();
+ render(
+
+ Filters
+ ,
+ );
+ await userEvent.click(screen.getByText("Clear filters"));
+ expect(onClear).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/web/src/hooks/useSelection.test.ts b/apps/web/src/hooks/useSelection.test.ts
new file mode 100644
index 0000000..6d5163a
--- /dev/null
+++ b/apps/web/src/hooks/useSelection.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useSelection } from "./useSelection.js";
+
+describe("useSelection", () => {
+ it("starts with empty selection", () => {
+ const { result } = renderHook(() => useSelection());
+ expect(result.current.count).toBe(0);
+ expect(result.current.selectedArray).toEqual([]);
+ });
+
+ it("toggles an item on", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ expect(result.current.selectedIds.has("a")).toBe(true);
+ expect(result.current.count).toBe(1);
+ });
+
+ it("toggles an item off", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ act(() => result.current.toggle("a"));
+ expect(result.current.selectedIds.has("a")).toBe(false);
+ expect(result.current.count).toBe(0);
+ });
+
+ it("toggleAll selects all items when none are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggleAll(["a", "b", "c"]));
+ expect(result.current.count).toBe(3);
+ expect(result.current.selectedIds.has("a")).toBe(true);
+ expect(result.current.selectedIds.has("b")).toBe(true);
+ expect(result.current.selectedIds.has("c")).toBe(true);
+ });
+
+ it("toggleAll deselects all items when all are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggleAll(["a", "b"]));
+ act(() => result.current.toggleAll(["a", "b"]));
+ expect(result.current.count).toBe(0);
+ });
+
+ it("toggleAll selects remaining items when some are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ act(() => result.current.toggleAll(["a", "b", "c"]));
+ expect(result.current.count).toBe(3);
+ });
+
+ it("clear removes all selections", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => {
+ result.current.toggle("a");
+ result.current.toggle("b");
+ });
+ act(() => result.current.clear());
+ expect(result.current.count).toBe(0);
+ });
+
+ it("isAllSelected returns true when all given ids are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggleAll(["a", "b"]));
+ expect(result.current.isAllSelected(["a", "b"])).toBe(true);
+ });
+
+ it("isAllSelected returns false when some ids are not selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ expect(result.current.isAllSelected(["a", "b"])).toBe(false);
+ });
+
+ it("isAllSelected returns false for empty ids array", () => {
+ const { result } = renderHook(() => useSelection());
+ expect(result.current.isAllSelected([])).toBe(false);
+ });
+
+ it("isIndeterminate returns true when some but not all are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggle("a"));
+ expect(result.current.isIndeterminate(["a", "b"])).toBe(true);
+ });
+
+ it("isIndeterminate returns false when all are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => result.current.toggleAll(["a", "b"]));
+ expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
+ });
+
+ it("isIndeterminate returns false when none are selected", () => {
+ const { result } = renderHook(() => useSelection());
+ expect(result.current.isIndeterminate(["a", "b"])).toBe(false);
+ });
+
+ it("selectedArray reflects the current selection", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => {
+ result.current.toggle("x");
+ result.current.toggle("y");
+ });
+ expect(result.current.selectedArray.sort()).toEqual(["x", "y"]);
+ });
+
+ it("preserves other selections when toggling individual items", () => {
+ const { result } = renderHook(() => useSelection());
+ act(() => {
+ result.current.toggle("a");
+ result.current.toggle("b");
+ });
+ act(() => result.current.toggle("a"));
+ expect(result.current.selectedIds.has("b")).toBe(true);
+ expect(result.current.count).toBe(1);
+ });
+});
diff --git a/apps/web/src/vitest-setup.ts b/apps/web/src/vitest-setup.ts
index f149f27..e262193 100644
--- a/apps/web/src/vitest-setup.ts
+++ b/apps/web/src/vitest-setup.ts
@@ -1 +1,7 @@
import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/apps/web/vitest.config.mts b/apps/web/vitest.config.mts
index 83f4f73..8189c6a 100644
--- a/apps/web/vitest.config.mts
+++ b/apps/web/vitest.config.mts
@@ -2,6 +2,9 @@ import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
+ esbuild: {
+ jsx: "automatic",
+ },
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),