From cd7e7a54b630ff0ce7e0bbd34f3b3637c8f64cb8 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Tue, 14 Jul 2026 18:32:46 -0700 Subject: [PATCH 1/2] chore(test): include src/i18n and src/lib in unit coverage The coverage include list only counted src/components, so the i18n merge logic and lib/utils helpers were invisible in reports. Co-Authored-By: Claude Fable 5 --- packages/ui/vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index 89f7e0a..a3d0b85 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "json", "lcov"], reportsDirectory: "./coverage", - include: ["src/components/**"], + include: ["src/components/**", "src/i18n/**", "src/lib/**"], exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.stories.tsx"], }, }, From f9b3c70ffb1f6a6bac8b7259382c0964b95ba3ce Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Tue, 14 Jul 2026 18:32:47 -0700 Subject: [PATCH 2/2] test(ui): cover every untested component (48% -> 97% statements) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 18 test files and extends 2, +152 tests (74 -> 226), written against rendered behavior: roles, aria wiring, keyboard/pointer interaction, and callback props — no snapshots, no CSS assertions. - Base UI overlays: dialog, popover, tooltip, sheet, dropdown-menu - Base UI primitives: accordion, select, switch, tabs, separator, scroll-area, navigation-menu (covers the extracted *-variants.ts too) - Docs components: breadcrumbs, callout, page-footer-nav, command-palette, elide-logo markOnly branch, theme-provider (storage/OS-preference/toggle) - Logic: lib/utils cn + keyed, i18n default + deep-merge, code-block editor/terminal/Prism paths, api-method signature branches, table-of-contents scroll-spy via a captured IntersectionObserver stub jsdom gaps are stubbed per-file (PointerEvent, ResizeObserver, matchMedia, an in-memory localStorage — Node's built-in shadows jsdom's); ScrollArea's scrollbar/thumb need real layout to mount, so only its render paths are asserted. Co-Authored-By: Claude Fable 5 --- packages/ui/src/components/accordion.test.tsx | 94 +++++ .../ui/src/components/api-method.test.tsx | 23 ++ .../ui/src/components/breadcrumbs.test.tsx | 54 +++ packages/ui/src/components/callout.test.tsx | 37 ++ .../ui/src/components/code-block.test.tsx | 71 ++++ .../src/components/command-palette.test.tsx | 97 ++++++ packages/ui/src/components/dialog.test.tsx | 153 +++++++++ .../ui/src/components/dropdown-menu.test.tsx | 320 ++++++++++++++++++ .../ui/src/components/elide-logo.test.tsx | 43 +++ .../src/components/navigation-menu.test.tsx | 101 ++++++ .../src/components/page-footer-nav.test.tsx | 44 +++ packages/ui/src/components/popover.test.tsx | 101 ++++++ .../ui/src/components/scroll-area.test.tsx | 65 ++++ packages/ui/src/components/select.test.tsx | 106 ++++++ packages/ui/src/components/separator.test.tsx | 30 ++ packages/ui/src/components/sheet.test.tsx | 164 +++++++++ packages/ui/src/components/switch.test.tsx | 66 ++++ .../src/components/table-of-contents.test.tsx | 100 +++++- packages/ui/src/components/tabs.test.tsx | 87 +++++ .../ui/src/components/theme-provider.test.tsx | 186 ++++++++++ packages/ui/src/components/tooltip.test.tsx | 80 +++++ packages/ui/src/i18n/i18n.test.tsx | 38 +++ packages/ui/src/lib/utils.test.ts | 47 +++ 23 files changed, 2105 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/accordion.test.tsx create mode 100644 packages/ui/src/components/breadcrumbs.test.tsx create mode 100644 packages/ui/src/components/callout.test.tsx create mode 100644 packages/ui/src/components/code-block.test.tsx create mode 100644 packages/ui/src/components/command-palette.test.tsx create mode 100644 packages/ui/src/components/dialog.test.tsx create mode 100644 packages/ui/src/components/dropdown-menu.test.tsx create mode 100644 packages/ui/src/components/elide-logo.test.tsx create mode 100644 packages/ui/src/components/navigation-menu.test.tsx create mode 100644 packages/ui/src/components/page-footer-nav.test.tsx create mode 100644 packages/ui/src/components/popover.test.tsx create mode 100644 packages/ui/src/components/scroll-area.test.tsx create mode 100644 packages/ui/src/components/select.test.tsx create mode 100644 packages/ui/src/components/separator.test.tsx create mode 100644 packages/ui/src/components/sheet.test.tsx create mode 100644 packages/ui/src/components/switch.test.tsx create mode 100644 packages/ui/src/components/tabs.test.tsx create mode 100644 packages/ui/src/components/theme-provider.test.tsx create mode 100644 packages/ui/src/components/tooltip.test.tsx create mode 100644 packages/ui/src/i18n/i18n.test.tsx create mode 100644 packages/ui/src/lib/utils.test.ts diff --git a/packages/ui/src/components/accordion.test.tsx b/packages/ui/src/components/accordion.test.tsx new file mode 100644 index 0000000..b4ec908 --- /dev/null +++ b/packages/ui/src/components/accordion.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "./accordion"; + +// jsdom does not implement PointerEvent, which Base UI dispatches on click. +if (typeof window.PointerEvent === "undefined") { + window.PointerEvent = class PointerEvent extends MouseEvent {} as typeof window.PointerEvent; +} + +function renderAccordion(props?: React.ComponentProps) { + return render( + + + First + First panel body + + + Second + Second panel body + + + ); +} + +describe("Accordion", () => { + it("renders a trigger per item", () => { + renderAccordion(); + expect(screen.getByRole("button", { name: "First" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Second" })).toBeInTheDocument(); + }); + + it("starts collapsed", () => { + renderAccordion(); + expect(screen.getByRole("button", { name: "First" })).toHaveAttribute( + "aria-expanded", + "false" + ); + }); + + it("expands a panel when its trigger is clicked", async () => { + const user = userEvent.setup(); + renderAccordion(); + + const trigger = screen.getByRole("button", { name: "First" }); + await user.click(trigger); + + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("First panel body")).toBeVisible(); + }); + + it("collapses an expanded panel when its trigger is clicked again", async () => { + const user = userEvent.setup(); + renderAccordion({ defaultValue: ["a"] }); + + const trigger = screen.getByRole("button", { name: "First" }); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + + it("fires onValueChange with the opened item's value", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderAccordion({ onValueChange }); + + await user.click(screen.getByRole("button", { name: "Second" })); + + expect(onValueChange).toHaveBeenCalled(); + expect(onValueChange.mock.calls[0][0]).toContain("b"); + }); + + it("carries data-slot attributes", () => { + const { container } = renderAccordion(); + expect(container.querySelector('[data-slot="accordion"]')).toBeInTheDocument(); + expect(container.querySelector('[data-slot="accordion-item"]')).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="accordion-trigger"]') + ).toBeInTheDocument(); + }); + + it("merges a consumer className on the root", () => { + const { container } = renderAccordion({ className: "my-accordion" }); + expect(container.querySelector('[data-slot="accordion"]')).toHaveClass( + "my-accordion" + ); + }); +}); diff --git a/packages/ui/src/components/api-method.test.tsx b/packages/ui/src/components/api-method.test.tsx index 3c70c66..94c2bbe 100644 --- a/packages/ui/src/components/api-method.test.tsx +++ b/packages/ui/src/components/api-method.test.tsx @@ -9,6 +9,12 @@ describe("ParamRow", () => { expect(screen.getByText("string | Buffer | URL")).toBeInTheDocument(); expect(screen.getByText("Filename to read.")).toBeInTheDocument(); }); + + it("omits the description span when no description is given", () => { + render(); + expect(screen.getByText("signal")).toBeInTheDocument(); + expect(screen.getByText("AbortSignal")).toBeInTheDocument(); + }); }); describe("ApiMethod", () => { @@ -45,4 +51,21 @@ describe("ApiMethod", () => { render({'console.log("hi")'}} />); expect(screen.getByText('console.log("hi")')).toBeInTheDocument(); }); + + it("renders a non-string (ReactNode) signature as-is and labels the anchor generically", () => { + render( + custom signature node} anchorId="custom" />, + ); + expect(screen.getByTestId("sig")).toBeInTheDocument(); + // emphasizeSignature can't split a node, so the anchor uses the fallback name. + expect(screen.getByRole("link", { name: "Link to this method" })).toHaveAttribute( + "href", + "#custom", + ); + }); + + it("leaves a signature with no parenthesis unsplit", () => { + const { container } = render(); + expect(container).toHaveTextContent("fs.constants"); + }); }); diff --git a/packages/ui/src/components/breadcrumbs.test.tsx b/packages/ui/src/components/breadcrumbs.test.tsx new file mode 100644 index 0000000..f599249 --- /dev/null +++ b/packages/ui/src/components/breadcrumbs.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Breadcrumbs } from "./breadcrumbs"; + +const segments = [ + { label: "Runtime", href: "#runtime" }, + { label: "Guides" }, // no href → plain text + { label: "Debugging" }, // last → current page +]; + +describe("Breadcrumbs", () => { + it("renders the segments as an ordered list", () => { + render(); + const list = screen.getByRole("list"); + expect(list.tagName).toBe("OL"); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + + it("marks the last segment as the current page and does not link it", () => { + render(); + const current = screen.getByText("Debugging"); + expect(current).toHaveAttribute("aria-current", "page"); + expect(current.tagName).toBe("SPAN"); + expect(screen.queryByRole("link", { name: "Debugging" })).not.toBeInTheDocument(); + }); + + it("renders segments with an href as links", () => { + render(); + const link = screen.getByRole("link", { name: "Runtime" }); + expect(link).toHaveAttribute("href", "#runtime"); + }); + + it("renders segments without an href as plain text", () => { + render(); + const guides = screen.getByText("Guides"); + expect(guides.tagName).toBe("SPAN"); + expect(guides).not.toHaveAttribute("aria-current"); + expect(screen.queryByRole("link", { name: "Guides" })).not.toBeInTheDocument(); + }); + + it("renders a separator between segments, hidden from assistive tech", () => { + const { container } = render(); + const separators = container.querySelectorAll("svg[aria-hidden]"); + // One separator after every segment except the last. + expect(separators).toHaveLength(segments.length - 1); + }); + + it("merges a consumer className onto the nav", () => { + render(); + const nav = screen.getByRole("navigation", { name: "Breadcrumb" }); + expect(nav).toHaveClass("custom-x"); + expect(nav).toHaveClass("text-sm"); + }); +}); diff --git a/packages/ui/src/components/callout.test.tsx b/packages/ui/src/components/callout.test.tsx new file mode 100644 index 0000000..35369aa --- /dev/null +++ b/packages/ui/src/components/callout.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Callout } from "./callout"; + +describe("Callout", () => { + it("renders as a note landmark with its children", () => { + render(Body content here.); + expect(screen.getByRole("note")).toBeInTheDocument(); + expect(screen.getByText("Body content here.")).toBeInTheDocument(); + }); + + it("defaults to the Note tone label", () => { + render(x); + expect(screen.getByText("Note")).toBeInTheDocument(); + }); + + it("uses the tone's label for other tones", () => { + render(x); + expect(screen.getByText("Warning")).toBeInTheDocument(); + expect(screen.queryByText("Note")).not.toBeInTheDocument(); + }); + + it("renders a title override in place of the default label", () => { + render( + + x + , + ); + expect(screen.getByText("Custom heading")).toBeInTheDocument(); + expect(screen.queryByText("Tip")).not.toBeInTheDocument(); + }); + + it("merges a consumer className", () => { + render(x); + expect(screen.getByRole("note")).toHaveClass("custom-x"); + }); +}); diff --git a/packages/ui/src/components/code-block.test.tsx b/packages/ui/src/components/code-block.test.tsx new file mode 100644 index 0000000..461eac5 --- /dev/null +++ b/packages/ui/src/components/code-block.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { CodeBlock } from "./code-block"; + +describe("CodeBlock — editor variant", () => { + it("renders the filename, a line-number gutter, a vim status bar, and a copy button", () => { + const code = "const a = 1;\nconst b = 2;\nconst c = 3;"; + const { container } = render( + , + ); + + // Filename shows in the title bar and again in the status bar. + expect(screen.getAllByText("app.ts").length).toBeGreaterThanOrEqual(1); + + // The gutter is the aria-hidden div listing one number per code line. + const gutter = container.querySelector("div[aria-hidden]"); + expect(gutter).not.toBeNull(); + expect(gutter!.textContent!.split("\n")).toEqual(["1", "2", "3"]); + + // Vim status bar. + expect(screen.getByText("NORMAL")).toBeInTheDocument(); + expect(container).toHaveTextContent("utf-8 · typescript · 3L"); + + // Icon-only copy button, named from the localized "Copy". + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + }); + + it("hides the gutter and status bar when lineNumbers/statusBar are false", () => { + const { container } = render( + , + ); + expect(container.querySelector("div[aria-hidden]")).toBeNull(); + expect(screen.queryByText("NORMAL")).not.toBeInTheDocument(); + }); + + it("renders traffic lights", () => { + const { container } = render(); + expect(container.querySelector('[class*="ff5f56"]')).not.toBeNull(); + }); +}); + +describe("CodeBlock — terminal variant", () => { + it("renders the lang as the title row and no traffic lights", () => { + const { container } = render(); + expect(screen.getByText("bash")).toBeInTheDocument(); + expect(container.querySelector('[class*="ff5f56"]')).toBeNull(); + }); + + it("falls back to the localized TERMINAL title when no lang is given", () => { + render(); + expect(screen.getByText("Terminal")).toBeInTheDocument(); + }); +}); + +describe("CodeBlock — body", () => { + it("renders `children` in place of the prism-highlighted body", () => { + render( + + custom pre-highlighted body + , + ); + expect(screen.getByText("custom pre-highlighted body")).toBeInTheDocument(); + }); + + it("renders one line span per code line even when lines are identical", () => { + const { container } = render(); + // Each highlighted line is a `span.block`; keyed() keeps the React keys + // unique across the duplicate content, so all three render. + expect(container.querySelectorAll("span.block")).toHaveLength(3); + }); +}); diff --git a/packages/ui/src/components/command-palette.test.tsx b/packages/ui/src/components/command-palette.test.tsx new file mode 100644 index 0000000..3d08170 --- /dev/null +++ b/packages/ui/src/components/command-palette.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { CommandPalette, type CommandGroup } from "./command-palette"; + +function makeGroups(overrides?: { + home?: () => void; + docs?: () => void; + python?: () => void; +}): CommandGroup[] { + return [ + { + heading: "Pages", + items: [ + { id: "home", title: "Home", onSelect: overrides?.home }, + { id: "docs", title: "Docs", subtitle: "Read the docs", onSelect: overrides?.docs }, + ], + }, + { + heading: "Languages", + items: [{ id: "py", title: "Python", keywords: "python snake", onSelect: overrides?.python }], + }, + ]; +} + +describe("CommandPalette", () => { + it("renders nothing while closed", () => { + render(); + expect(screen.queryByRole("button", { name: /Home/ })).not.toBeInTheDocument(); + }); + + it("renders the search input, group headings, and items when open", () => { + render(); + expect(screen.getByRole("textbox", { name: "Find anything" })).toBeInTheDocument(); + expect(screen.getByText("Pages")).toBeInTheDocument(); + expect(screen.getByText("Languages")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Home/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Python/ })).toBeInTheDocument(); + }); + + it("filters items as the user types", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "python"); + + expect(screen.getByRole("button", { name: /Python/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Home/ })).not.toBeInTheDocument(); + // The empty "Pages" group is dropped entirely. + expect(screen.queryByText("Pages")).not.toBeInTheDocument(); + }); + + it("shows the empty message when nothing matches", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "zzzzz"); + + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("fires the item's onSelect and requests close when clicked", async () => { + const user = userEvent.setup(); + const home = vi.fn(); + const onOpenChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /Home/ })); + + expect(home).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("selects the item under the keyboard cursor on Enter", async () => { + const user = userEvent.setup(); + const home = vi.fn(); + const docs = vi.fn(); + render(); + + // Cursor starts on the first item (Home); ArrowDown moves to Docs. + await user.keyboard("{ArrowDown}{Enter}"); + + expect(docs).toHaveBeenCalledTimes(1); + expect(home).not.toHaveBeenCalled(); + }); + + it("wraps to the last item on ArrowUp from the top", async () => { + const user = userEvent.setup(); + const python = vi.fn(); + render(); + + await user.keyboard("{ArrowUp}{Enter}"); + + expect(python).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/dialog.test.tsx b/packages/ui/src/components/dialog.test.tsx new file mode 100644 index 0000000..512ac1f --- /dev/null +++ b/packages/ui/src/components/dialog.test.tsx @@ -0,0 +1,153 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogClose, +} from "./dialog"; + +function renderDialog(props?: { + onOpenChange?: (open: boolean) => void; + footerCloseButton?: boolean; + showCloseButton?: boolean; +}) { + return render( + + Open dialog + + + Delete project + This action cannot be undone. + + + Cancel + + + + ); +} + +describe("Dialog", () => { + it("does not render content until the trigger is activated", () => { + renderDialog(); + // Base UI portals the popup to document.body; closed = not in the DOM. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open dialog" })).toBeInTheDocument(); + }); + + it("opens the dialog on trigger click", async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + }); + + it("labels the dialog by its title and describes it by its description", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + // aria-labelledby wiring means the accessible name is the title text. + expect(screen.getByRole("dialog", { name: "Delete project" })).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveAccessibleDescription( + "This action cannot be undone." + ); + }); + + it("closes when the built-in close button is clicked", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes when Escape is pressed", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("fires onOpenChange when opening and closing", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderDialog({ onOpenChange }); + + await user.click(screen.getByRole("button", { name: "Open dialog" })); + expect(onOpenChange.mock.lastCall?.[0]).toBe(true); + + await user.keyboard("{Escape}"); + expect(onOpenChange.mock.lastCall?.[0]).toBe(false); + }); + + it("hides the built-in close button when showCloseButton is false", async () => { + const user = userEvent.setup(); + render( + + Open dialog + + No close button + + + ); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("renders the footer close button when DialogFooter showCloseButton is set", async () => { + const user = userEvent.setup(); + // Disable the built-in close so only the footer's Close button exists. + renderDialog({ footerCloseButton: true, showCloseButton: false }); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + // The footer's own Close button carries the label "Close". + await user.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("merges a consumer className onto the content popup", async () => { + const user = userEvent.setup(); + render( + + Open dialog + + Styled + + + ); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + expect(screen.getByRole("dialog")).toHaveClass("custom-content"); + }); + + it("exposes data-slot attributes on rendered parts", async () => { + const user = userEvent.setup(); + renderDialog(); + await user.click(screen.getByRole("button", { name: "Open dialog" })); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-slot", "dialog-content"); + expect(screen.getByText("Delete project")).toHaveAttribute("data-slot", "dialog-title"); + expect(screen.getByText("This action cannot be undone.")).toHaveAttribute( + "data-slot", + "dialog-description" + ); + }); +}); diff --git a/packages/ui/src/components/dropdown-menu.test.tsx b/packages/ui/src/components/dropdown-menu.test.tsx new file mode 100644 index 0000000..deccb43 --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.test.tsx @@ -0,0 +1,320 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from "./dropdown-menu"; + +describe("DropdownMenu", () => { + it("does not render its content until the trigger is activated", () => { + render( + + Actions + + Edit + + + ); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument(); + }); + + it("opens the menu on trigger click and shows its items", async () => { + const user = userEvent.setup(); + render( + + Actions + + Edit + Duplicate + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByRole("menu")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Duplicate" })).toBeInTheDocument(); + }); + + it("fires an item's onClick and closes the menu on selection", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render( + + Actions + + Edit + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Edit" })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render( + + Actions + + Edit + + + ); + await user.click(screen.getByRole("button", { name: "Actions" })); + expect(await screen.findByRole("menu")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("renders a group with a label and a separator", async () => { + const user = userEvent.setup(); + render( + + Actions + + + Account + Profile + + + Sign out + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByText("Account")).toHaveAttribute("data-slot", "dropdown-menu-label"); + expect(document.querySelector("[data-slot=dropdown-menu-group]")).toBeInTheDocument(); + expect(document.querySelector("[data-slot=dropdown-menu-separator]")).toBeInTheDocument(); + }); + + it("marks a label as inset via data-inset", async () => { + const user = userEvent.setup(); + render( + + Actions + + + Section + + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByText("Section")).toHaveAttribute("data-inset", "true"); + }); + + it("applies the destructive variant to an item via data-variant", async () => { + const user = userEvent.setup(); + render( + + Actions + + Delete + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByRole("menuitem", { name: "Delete" })).toHaveAttribute( + "data-variant", + "destructive" + ); + }); + + it("renders a shortcut hint inside an item", async () => { + const user = userEvent.setup(); + render( + + Actions + + + Save + ⌘S + + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByText("⌘S")).toHaveAttribute("data-slot", "dropdown-menu-shortcut"); + }); + + it("toggles a checkbox item and fires onCheckedChange", async () => { + const user = userEvent.setup(); + const onCheckedChange = vi.fn(); + + function Harness() { + const [checked, setChecked] = useState(false); + return ( + + Actions + + { + onCheckedChange(value); + setChecked(value); + }} + closeOnClick={false} + > + Show grid + + + + ); + } + + render(); + await user.click(screen.getByRole("button", { name: "Actions" })); + + const item = await screen.findByRole("menuitemcheckbox", { name: "Show grid" }); + expect(item).toHaveAttribute("aria-checked", "false"); + + await user.click(item); + + expect(onCheckedChange).toHaveBeenLastCalledWith(true); + expect( + screen.getByRole("menuitemcheckbox", { name: "Show grid" }) + ).toHaveAttribute("aria-checked", "true"); + }); + + it("selects a radio item within a radio group", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Harness() { + const [value, setValue] = useState("list"); + return ( + + View + + { + onValueChange(next); + setValue(next); + }} + > + + List + + + Grid + + + + + ); + } + + render(); + await user.click(screen.getByRole("button", { name: "View" })); + + expect(await screen.findByRole("menuitemradio", { name: "List" })).toHaveAttribute( + "aria-checked", + "true" + ); + + await user.click(screen.getByRole("menuitemradio", { name: "Grid" })); + + expect(onValueChange).toHaveBeenLastCalledWith("grid"); + expect(screen.getByRole("menuitemradio", { name: "Grid" })).toHaveAttribute( + "aria-checked", + "true" + ); + }); + + it("opens a submenu and reveals its content", async () => { + const user = userEvent.setup(); + render( + + Actions + + + More + + Archive + + + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + const subTrigger = await screen.findByRole("menuitem", { name: "More" }); + expect(subTrigger).toHaveAttribute("data-slot", "dropdown-menu-sub-trigger"); + expect(screen.queryByRole("menuitem", { name: "Archive" })).not.toBeInTheDocument(); + + await user.click(subTrigger); + + expect(await screen.findByRole("menuitem", { name: "Archive" })).toBeInTheDocument(); + }); + + it("merges a consumer className onto the content popup", async () => { + const user = userEvent.setup(); + render( + + Actions + + Edit + + + ); + + await user.click(screen.getByRole("button", { name: "Actions" })); + + expect(await screen.findByRole("menu")).toHaveClass("custom-menu"); + }); + + it("exposes data-slot attributes on the trigger, content, and items", async () => { + const user = userEvent.setup(); + render( + + Actions + + Edit + + + ); + const trigger = screen.getByRole("button", { name: "Actions" }); + expect(trigger).toHaveAttribute("data-slot", "dropdown-menu-trigger"); + + await user.click(trigger); + + expect(await screen.findByRole("menu")).toHaveAttribute("data-slot", "dropdown-menu-content"); + expect(screen.getByRole("menuitem", { name: "Edit" })).toHaveAttribute( + "data-slot", + "dropdown-menu-item" + ); + }); +}); diff --git a/packages/ui/src/components/elide-logo.test.tsx b/packages/ui/src/components/elide-logo.test.tsx new file mode 100644 index 0000000..b140fd0 --- /dev/null +++ b/packages/ui/src/components/elide-logo.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { ElideLogo, ElideMark } from "./elide-logo"; + +describe("ElideLogo", () => { + it("renders the mark and the wordmark by default, with the wordmark hidden", () => { + const { container } = render(); + const svgs = container.querySelectorAll("svg"); + expect(svgs).toHaveLength(2); + // The wordmark is decorative alongside the mark. + const hidden = container.querySelector("svg[aria-hidden]"); + expect(hidden).toBeInTheDocument(); + }); + + it("exposes a single accessible image named Elide", () => { + render(); + // The wordmark's aria-hidden removes it from the tree, so only the mark is named. + expect(screen.getByRole("img", { name: "Elide" })).toBeInTheDocument(); + }); + + it("renders only the mark when markOnly is set", () => { + const { container } = render(); + const svgs = container.querySelectorAll("svg"); + expect(svgs).toHaveLength(1); + expect(container.querySelector("svg[aria-hidden]")).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Elide" })).toBeInTheDocument(); + }); + + it("gives each mark on a page a distinct gradient id", () => { + const { container } = render( + <> + + + , + ); + const gradients = container.querySelectorAll("linearGradient"); + expect(gradients).toHaveLength(2); + const [a, b] = Array.from(gradients).map((g) => g.getAttribute("id")); + expect(a).toBeTruthy(); + expect(b).toBeTruthy(); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/ui/src/components/navigation-menu.test.tsx b/packages/ui/src/components/navigation-menu.test.tsx new file mode 100644 index 0000000..647a88e --- /dev/null +++ b/packages/ui/src/components/navigation-menu.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeAll } from "vitest"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from "./navigation-menu"; + +// jsdom is missing several browser APIs Base UI's NavigationMenu relies on. +beforeAll(() => { + if (typeof window.PointerEvent === "undefined") { + window.PointerEvent = class PointerEvent extends MouseEvent {} as typeof window.PointerEvent; + } + if (typeof window.ResizeObserver === "undefined") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof window.ResizeObserver; + } +}); + +function renderMenu() { + return render( + + + + Products + + Runtime + + + + Docs + + + + ); +} + +describe("NavigationMenu", () => { + it("renders the top-level trigger and links", () => { + renderMenu(); + expect(screen.getByRole("button", { name: /Products/ })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Docs" })).toBeInTheDocument(); + }); + + it("applies the shared trigger style and data-slot to the trigger", () => { + renderMenu(); + const trigger = screen.getByRole("button", { name: /Products/ }); + expect(trigger).toHaveAttribute("data-slot", "navigation-menu-trigger"); + // From navigationMenuTriggerStyle (navigation-menu-variants.ts). + expect(trigger).toHaveClass("inline-flex"); + }); + + it("keeps the popup content out of the DOM until opened", () => { + renderMenu(); + expect(screen.queryByRole("link", { name: "Runtime" })).not.toBeInTheDocument(); + }); + + it("reveals the content links when the trigger is opened", async () => { + const user = userEvent.setup(); + renderMenu(); + + await user.click(screen.getByRole("button", { name: /Products/ })); + + expect(await screen.findByRole("link", { name: "Runtime" })).toBeInTheDocument(); + }); + + it("carries data-slot attributes on the menu structure", () => { + const { container } = renderMenu(); + expect( + container.querySelector('[data-slot="navigation-menu"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="navigation-menu-list"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="navigation-menu-item"]') + ).toBeInTheDocument(); + }); + + it("merges a consumer className on the root", () => { + const { container } = render( + + + + Home + + + + ); + expect(container.querySelector('[data-slot="navigation-menu"]')).toHaveClass( + "my-nav" + ); + }); +}); diff --git a/packages/ui/src/components/page-footer-nav.test.tsx b/packages/ui/src/components/page-footer-nav.test.tsx new file mode 100644 index 0000000..359f24b --- /dev/null +++ b/packages/ui/src/components/page-footer-nav.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { PageFooterNav } from "./page-footer-nav"; + +const prev = { label: "Getting Started", href: "#getting-started" }; +const next = { label: "Configuration", href: "#configuration" }; + +describe("PageFooterNav", () => { + it("renders a pagination landmark", () => { + render(); + expect(screen.getByRole("navigation", { name: "Pagination" })).toBeInTheDocument(); + }); + + it("renders the previous link with its label and href", () => { + render(); + const link = screen.getByRole("link", { name: /Getting Started/ }); + expect(link).toHaveAttribute("href", "#getting-started"); + expect(link).toHaveTextContent("Previous"); + }); + + it("renders the next link with its label and href", () => { + render(); + const link = screen.getByRole("link", { name: /Configuration/ }); + expect(link).toHaveAttribute("href", "#configuration"); + expect(link).toHaveTextContent("Next"); + }); + + it("omits the previous link when prev is not provided", () => { + render(); + expect(screen.queryByText("Previous")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Configuration/ })).toBeInTheDocument(); + }); + + it("omits the next link when next is not provided", () => { + render(); + expect(screen.queryByText("Next")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Getting Started/ })).toBeInTheDocument(); + }); + + it("merges a consumer className onto the nav", () => { + render(); + expect(screen.getByRole("navigation", { name: "Pagination" })).toHaveClass("custom-x"); + }); +}); diff --git a/packages/ui/src/components/popover.test.tsx b/packages/ui/src/components/popover.test.tsx new file mode 100644 index 0000000..1172917 --- /dev/null +++ b/packages/ui/src/components/popover.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} from "./popover"; + +function renderPopover(props?: { + onOpenChange?: (open: boolean) => void; + contentClassName?: string; +}) { + return render( + + Open popover + + + Notifications + You have no new alerts. + + + + ); +} + +describe("Popover", () => { + it("does not render its content until opened", () => { + renderPopover(); + expect(screen.queryByText("You have no new alerts.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open popover" })).toBeInTheDocument(); + }); + + it("opens the content on trigger click", async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole("button", { name: "Open popover" })); + + expect(screen.getByText("Notifications")).toBeInTheDocument(); + expect(screen.getByText("You have no new alerts.")).toBeInTheDocument(); + }); + + it("closes when Escape is pressed", async () => { + const user = userEvent.setup(); + renderPopover(); + await user.click(screen.getByRole("button", { name: "Open popover" })); + expect(screen.getByText("Notifications")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByText("Notifications")).not.toBeInTheDocument(); + }); + + it("fires onOpenChange when opening and closing", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderPopover({ onOpenChange }); + + await user.click(screen.getByRole("button", { name: "Open popover" })); + expect(onOpenChange.mock.lastCall?.[0]).toBe(true); + + await user.keyboard("{Escape}"); + expect(onOpenChange.mock.lastCall?.[0]).toBe(false); + }); + + it("merges a consumer className onto the popup content", async () => { + const user = userEvent.setup(); + renderPopover({ contentClassName: "custom-popover" }); + await user.click(screen.getByRole("button", { name: "Open popover" })); + + expect(screen.getByText("Notifications").closest("[data-slot=popover-content]")).toHaveClass( + "custom-popover" + ); + }); + + it("exposes data-slot attributes on rendered parts", async () => { + const user = userEvent.setup(); + renderPopover(); + await user.click(screen.getByRole("button", { name: "Open popover" })); + + expect(screen.getByText("Notifications")).toHaveAttribute("data-slot", "popover-title"); + expect(screen.getByText("You have no new alerts.")).toHaveAttribute( + "data-slot", + "popover-description" + ); + }); + + it("marks the trigger with data-slot and toggles its expanded state", async () => { + const user = userEvent.setup(); + renderPopover(); + const trigger = screen.getByRole("button", { name: "Open popover" }); + expect(trigger).toHaveAttribute("data-slot", "popover-trigger"); + + await user.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/packages/ui/src/components/scroll-area.test.tsx b/packages/ui/src/components/scroll-area.test.tsx new file mode 100644 index 0000000..8b3a301 --- /dev/null +++ b/packages/ui/src/components/scroll-area.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, beforeAll } from "vitest"; +import { ScrollArea, ScrollBar } from "./scroll-area"; + +// Base UI's scroll area observes element size; jsdom lacks ResizeObserver. +// Without real layout it never measures an overflow, so the scrollbar/thumb +// stay unmounted — the assertions below only cover what renders in jsdom. +beforeAll(() => { + if (typeof window.ResizeObserver === "undefined") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof window.ResizeObserver; + } +}); + +describe("ScrollArea", () => { + it("renders its children inside the viewport", () => { + render( + +

Scrollable content

+
+ ); + const content = screen.getByText("Scrollable content"); + expect(content).toBeInTheDocument(); + expect(content.closest('[data-slot="scroll-area-viewport"]')).not.toBeNull(); + }); + + it("renders the root and viewport slots", () => { + const { container } = render( + +

Body

+
+ ); + expect(container.querySelector('[data-slot="scroll-area"]')).toBeInTheDocument(); + expect( + container.querySelector('[data-slot="scroll-area-viewport"]') + ).toBeInTheDocument(); + }); + + it("merges a consumer className on the root", () => { + const { container } = render( + +

Body

+
+ ); + expect(container.querySelector('[data-slot="scroll-area"]')).toHaveClass( + "my-scroll" + ); + }); + + it("renders a horizontal ScrollBar within the root without error", () => { + // Exercises the ScrollBar branch; Base UI keeps the scrollbar unmounted + // until it measures an overflow, which jsdom cannot provide. + const { container } = render( + + +

Body

+
+ ); + expect(container.querySelector('[data-slot="scroll-area"]')).toBeInTheDocument(); + expect(screen.getByText("Body")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/select.test.tsx b/packages/ui/src/components/select.test.tsx new file mode 100644 index 0000000..a943614 --- /dev/null +++ b/packages/ui/src/components/select.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; + +// jsdom is missing several browser APIs Base UI's Select relies on. +beforeAll(() => { + if (typeof window.PointerEvent === "undefined") { + window.PointerEvent = class PointerEvent extends MouseEvent {} as typeof window.PointerEvent; + } + if (typeof window.ResizeObserver === "undefined") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof window.ResizeObserver; + } + Element.prototype.scrollIntoView = () => {}; + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } +}); + +function renderSelect(props?: React.ComponentProps) { + return render( + + ); +} + +describe("Select", () => { + it("renders a trigger showing the placeholder", () => { + renderSelect(); + const trigger = screen.getByRole("combobox"); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent("Pick a fruit"); + }); + + it("keeps the popup out of the DOM until opened", () => { + renderSelect(); + expect(screen.queryByRole("option", { name: "Apple" })).not.toBeInTheDocument(); + }); + + it("opens the popup and reveals the items on trigger click", async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByRole("option", { name: "Apple" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument(); + }); + + it("selects an item, firing onValueChange and updating the trigger", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderSelect({ onValueChange }); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByRole("option", { name: "Banana" })); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenLastCalledWith("banana", expect.anything()); + // SelectValue renders the raw value (no `items` label map is supplied). + expect(screen.getByRole("combobox")).toHaveTextContent("banana"); + }); + + it("renders the selected value from defaultValue", () => { + renderSelect({ defaultValue: "apple" }); + expect(screen.getByRole("combobox")).toHaveTextContent("apple"); + }); + + it("carries the trigger data-slot and default size", () => { + renderSelect(); + const trigger = screen.getByRole("combobox"); + expect(trigger).toHaveAttribute("data-slot", "select-trigger"); + expect(trigger).toHaveAttribute("data-size", "default"); + }); + + it("merges a consumer className on the trigger", () => { + render( + + ); + expect(screen.getByRole("combobox")).toHaveClass("my-trigger"); + }); +}); diff --git a/packages/ui/src/components/separator.test.tsx b/packages/ui/src/components/separator.test.tsx new file mode 100644 index 0000000..110335d --- /dev/null +++ b/packages/ui/src/components/separator.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Separator } from "./separator"; + +describe("Separator", () => { + it("renders with the separator role", () => { + render(); + expect(screen.getByRole("separator")).toBeInTheDocument(); + }); + + it("defaults to a horizontal orientation", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal"); + }); + + it("reflects a vertical orientation", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical"); + }); + + it("carries the separator data-slot", () => { + render(); + expect(screen.getByRole("separator")).toHaveAttribute("data-slot", "separator"); + }); + + it("merges a consumer className", () => { + render(); + expect(screen.getByRole("separator")).toHaveClass("my-divider"); + }); +}); diff --git a/packages/ui/src/components/sheet.test.tsx b/packages/ui/src/components/sheet.test.tsx new file mode 100644 index 0000000..59daec9 --- /dev/null +++ b/packages/ui/src/components/sheet.test.tsx @@ -0,0 +1,164 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + SheetClose, +} from "./sheet"; + +function renderSheet(props?: { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; + onOpenChange?: (open: boolean) => void; + contentClassName?: string; +}) { + return render( + + Open sheet + + + Settings + Manage your workspace preferences. + + + Done + + + + ); +} + +describe("Sheet", () => { + it("does not render content until the trigger is activated", () => { + renderSheet(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open sheet" })).toBeInTheDocument(); + }); + + it("opens the sheet on trigger click", async () => { + const user = userEvent.setup(); + renderSheet(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Manage your workspace preferences.")).toBeInTheDocument(); + }); + + it("labels the sheet by its title and describes it by its description", async () => { + const user = userEvent.setup(); + renderSheet(); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog", { name: "Settings" })).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveAccessibleDescription( + "Manage your workspace preferences." + ); + }); + + it("closes when the built-in close button is clicked", async () => { + const user = userEvent.setup(); + renderSheet(); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + await user.click(screen.getByRole("button", { name: "Close" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes when Escape is pressed", async () => { + const user = userEvent.setup(); + renderSheet(); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("fires onOpenChange when opening and closing", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + renderSheet({ onOpenChange }); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + expect(onOpenChange.mock.lastCall?.[0]).toBe(true); + + await user.keyboard("{Escape}"); + expect(onOpenChange.mock.lastCall?.[0]).toBe(false); + }); + + it("hides the built-in close button when showCloseButton is false", async () => { + const user = userEvent.setup(); + renderSheet({ showCloseButton: false }); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Close" })).not.toBeInTheDocument(); + }); + + it("defaults the content to the right side", async () => { + const user = userEvent.setup(); + renderSheet(); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-side", "right"); + }); + + it.each(["top", "right", "bottom", "left"] as const)( + "reflects the %s side on the content via data-side", + async (side) => { + const user = userEvent.setup(); + renderSheet({ side }); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-side", side); + } + ); + + it("merges a consumer className onto the content", async () => { + const user = userEvent.setup(); + renderSheet({ contentClassName: "custom-sheet" }); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toHaveClass("custom-sheet"); + }); + + it("exposes data-slot attributes on the header, footer, title, and description", async () => { + const user = userEvent.setup(); + renderSheet(); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + expect(screen.getByRole("dialog")).toHaveAttribute("data-slot", "sheet-content"); + expect(screen.getByText("Settings")).toHaveAttribute("data-slot", "sheet-title"); + expect(screen.getByText("Manage your workspace preferences.")).toHaveAttribute( + "data-slot", + "sheet-description" + ); + // Header and footer are plain divs identified only by data-slot. + expect(document.querySelector("[data-slot=sheet-header]")).toBeInTheDocument(); + expect(document.querySelector("[data-slot=sheet-footer]")).toBeInTheDocument(); + }); + + it("closes via a custom SheetClose child", async () => { + const user = userEvent.setup(); + // Disable the built-in close so the custom "Done" button is unambiguous. + renderSheet({ showCloseButton: false }); + await user.click(screen.getByRole("button", { name: "Open sheet" })); + + await user.click(screen.getByRole("button", { name: "Done" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/switch.test.tsx b/packages/ui/src/components/switch.test.tsx new file mode 100644 index 0000000..34631a9 --- /dev/null +++ b/packages/ui/src/components/switch.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { Switch } from "./switch"; + +// jsdom does not implement PointerEvent, which Base UI dispatches on click. +if (typeof window.PointerEvent === "undefined") { + window.PointerEvent = class PointerEvent extends MouseEvent {} as typeof window.PointerEvent; +} + +describe("Switch", () => { + it("renders with the switch role", () => { + render(); + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + it("is unchecked by default", () => { + render(); + expect(screen.getByRole("switch")).not.toBeChecked(); + }); + + it("honors defaultChecked", () => { + render(); + expect(screen.getByRole("switch")).toBeChecked(); + }); + + it("toggles checked state and fires onCheckedChange on click", async () => { + const user = userEvent.setup(); + const onCheckedChange = vi.fn(); + render(); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + expect(toggle).toBeChecked(); + expect(onCheckedChange).toHaveBeenCalledTimes(1); + expect(onCheckedChange).toHaveBeenLastCalledWith(true, expect.anything()); + + await user.click(toggle); + expect(toggle).not.toBeChecked(); + expect(onCheckedChange).toHaveBeenLastCalledWith(false, expect.anything()); + }); + + it("does not toggle when disabled", async () => { + const user = userEvent.setup(); + const onCheckedChange = vi.fn(); + render(); + + await user.click(screen.getByRole("switch")); + + expect(onCheckedChange).not.toHaveBeenCalled(); + expect(screen.getByRole("switch")).not.toBeChecked(); + }); + + it("carries the switch data-slot and reflects the size", () => { + render(); + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("data-slot", "switch"); + expect(toggle).toHaveAttribute("data-size", "sm"); + }); + + it("merges a consumer className", () => { + render(); + expect(screen.getByRole("switch")).toHaveClass("my-switch"); + }); +}); diff --git a/packages/ui/src/components/table-of-contents.test.tsx b/packages/ui/src/components/table-of-contents.test.tsx index 1db8951..1639b03 100644 --- a/packages/ui/src/components/table-of-contents.test.tsx +++ b/packages/ui/src/components/table-of-contents.test.tsx @@ -1,6 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { TableOfContents } from "./table-of-contents"; const items = [ @@ -52,3 +52,99 @@ describe("TableOfContents", () => { expect(links.every((link) => !link.hasAttribute("aria-current"))).toBe(true); }); }); + +describe("TableOfContents — scroll-spy", () => { + // Minimal IntersectionObserver stub: capture the callback so the test can + // drive section visibility manually (jsdom ships none). + class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + callback: IntersectionObserverCallback; + constructor(cb: IntersectionObserverCallback) { + this.callback = cb; + MockIntersectionObserver.instances.push(this); + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + // Test helper: fire the observer callback with synthetic entries. + emit(entries: { id: string; isIntersecting: boolean }[]) { + const records = entries.map((e) => ({ + isIntersecting: e.isIntersecting, + target: document.getElementById(e.id)!, + })) as unknown as IntersectionObserverEntry[]; + act(() => this.callback(records, this as unknown as IntersectionObserver)); + } + } + + beforeEach(() => { + MockIntersectionObserver.instances = []; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function renderWithSections() { + return render( + <> +
Overview section
+
Installation section
+
Advanced section
+ + , + ); + } + + it("marks the intersecting section as current, then clears it when it leaves", () => { + renderWithSections(); + const observer = MockIntersectionObserver.instances[0]; + expect(observer).toBeDefined(); + + observer.emit([{ id: "installation", isIntersecting: true }]); + expect(screen.getByRole("link", { name: "Installation" })).toHaveAttribute( + "aria-current", + "location", + ); + + observer.emit([{ id: "installation", isIntersecting: false }]); + expect(screen.getByRole("link", { name: "Installation" })).not.toHaveAttribute("aria-current"); + }); + + it("picks the first item in list order when several sections are visible", () => { + renderWithSections(); + const observer = MockIntersectionObserver.instances[0]; + + observer.emit([ + { id: "advanced", isIntersecting: true }, + { id: "installation", isIntersecting: true }, + ]); + + // Both visible, but "installation" precedes "advanced" in `items`. + expect(screen.getByRole("link", { name: "Installation" })).toHaveAttribute( + "aria-current", + "location", + ); + expect(screen.getByRole("link", { name: "Advanced usage" })).not.toHaveAttribute( + "aria-current", + ); + }); + + it("does not scroll-spy when activeId is controlled", () => { + render( + <> +
Overview section
+ + , + ); + // The effect returns early for a controlled activeId — no observer created. + expect(MockIntersectionObserver.instances).toHaveLength(0); + expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute( + "aria-current", + "location", + ); + }); +}); diff --git a/packages/ui/src/components/tabs.test.tsx b/packages/ui/src/components/tabs.test.tsx new file mode 100644 index 0000000..f083fd4 --- /dev/null +++ b/packages/ui/src/components/tabs.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; + +// jsdom does not implement PointerEvent, which Base UI dispatches on click. +if (typeof window.PointerEvent === "undefined") { + window.PointerEvent = class PointerEvent extends MouseEvent {} as typeof window.PointerEvent; +} + +function renderTabs( + props?: React.ComponentProps, + listProps?: React.ComponentProps +) { + return render( + + + Overview + Pricing + + Overview panel + Pricing panel + + ); +} + +describe("Tabs", () => { + it("renders a tablist with a tab per trigger", () => { + renderTabs(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Pricing" })).toBeInTheDocument(); + }); + + it("shows the default tab's panel and marks it selected", () => { + renderTabs(); + expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("tabpanel")).toHaveTextContent("Overview panel"); + }); + + it("switches to another tab's panel on click", async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByRole("tab", { name: "Pricing" })); + + expect(screen.getByRole("tab", { name: "Pricing" })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("tabpanel")).toHaveTextContent("Pricing panel"); + }); + + it("fires onValueChange with the newly selected value", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderTabs({ onValueChange }); + + await user.click(screen.getByRole("tab", { name: "Pricing" })); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenLastCalledWith("pricing", expect.anything()); + }); + + it("applies the default TabsList variant", () => { + renderTabs(); + const list = screen.getByRole("tablist"); + expect(list).toHaveAttribute("data-variant", "default"); + expect(list).toHaveClass("bg-muted"); + }); + + it("applies the line TabsList variant", () => { + renderTabs(undefined, { variant: "line" }); + const list = screen.getByRole("tablist"); + expect(list).toHaveAttribute("data-variant", "line"); + expect(list).toHaveClass("bg-transparent"); + }); + + it("carries the tabs data-slot", () => { + const { container } = renderTabs(); + expect(container.querySelector('[data-slot="tabs"]')).toBeInTheDocument(); + expect(container.querySelector('[data-slot="tabs-trigger"]')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/theme-provider.test.tsx b/packages/ui/src/components/theme-provider.test.tsx new file mode 100644 index 0000000..c2bb9ad --- /dev/null +++ b/packages/ui/src/components/theme-provider.test.tsx @@ -0,0 +1,186 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { ThemeProvider } from "./theme-provider"; +import { useTheme } from "./theme-context"; + +function Consumer() { + const { theme, setTheme, toggle } = useTheme(); + return ( +
+ {theme} + + +
+ ); +} + +/** + * This jsdom setup exposes no working Storage (Node's own `localStorage` stub + * shadows jsdom's and throws), so back it with an in-memory implementation. + */ +class MemoryStorage { + private store = new Map(); + get length() { + return this.store.size; + } + getItem(key: string) { + return this.store.has(key) ? this.store.get(key)! : null; + } + setItem(key: string, value: string) { + this.store.set(key, String(value)); + } + removeItem(key: string) { + this.store.delete(key); + } + clear() { + this.store.clear(); + } + key(index: number) { + return Array.from(this.store.keys())[index] ?? null; + } +} + +/** jsdom lacks matchMedia; build a minimal stub returning a fixed match. */ +function stubMatchMedia(matches: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +beforeEach(() => { + vi.unstubAllGlobals(); + // Fresh storage per test; matchMedia stays undefined unless a test stubs it. + vi.stubGlobal("localStorage", new MemoryStorage()); + document.documentElement.className = ""; + delete document.documentElement.dataset.theme; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useTheme", () => { + it("throws when used outside a ThemeProvider", () => { + // Silence the expected React error boundary logging. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/); + spy.mockRestore(); + }); +}); + +describe("ThemeProvider", () => { + it("defaults to dark when there is no stored value and no matchMedia", () => { + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(document.documentElement).toHaveClass("dark"); + expect(document.documentElement.dataset.theme).toBe("dark"); + }); + + it("reads the persisted value from localStorage under the default key", () => { + window.localStorage.setItem("eld-theme", "light"); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + expect(document.documentElement).not.toHaveClass("dark"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); + + it("uses a custom storage key", () => { + window.localStorage.setItem("my-key", "light"); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + }); + + it("honors a light OS preference when nothing is stored", () => { + stubMatchMedia(false); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + }); + + it("honors a dark OS preference when nothing is stored", () => { + stubMatchMedia(true); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + }); + + it("prefers a stored value over the OS preference", () => { + stubMatchMedia(true); // OS says dark + window.localStorage.setItem("eld-theme", "light"); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + }); + + it("setTheme flips the theme, updates , and persists it", async () => { + const user = userEvent.setup(); + render( + + + , + ); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + + await user.click(screen.getByRole("button", { name: "set-light" })); + + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + expect(document.documentElement).not.toHaveClass("dark"); + expect(document.documentElement.dataset.theme).toBe("light"); + expect(window.localStorage.getItem("eld-theme")).toBe("light"); + }); + + it("toggle flips between dark and light", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const toggle = screen.getByRole("button", { name: "toggle" }); + + await user.click(toggle); + expect(screen.getByTestId("theme")).toHaveTextContent("light"); + expect(window.localStorage.getItem("eld-theme")).toBe("light"); + + await user.click(toggle); + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(document.documentElement).toHaveClass("dark"); + expect(window.localStorage.getItem("eld-theme")).toBe("dark"); + }); +}); diff --git a/packages/ui/src/components/tooltip.test.tsx b/packages/ui/src/components/tooltip.test.tsx new file mode 100644 index 0000000..d8bd75c --- /dev/null +++ b/packages/ui/src/components/tooltip.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, +} from "./tooltip"; + +function renderTooltip(contentClassName?: string) { + return render( + + + Hover me + Helpful hint + + + ); +} + +describe("Tooltip", () => { + it("does not render its content until the trigger is hovered", () => { + renderTooltip(); + expect(screen.queryByText("Helpful hint")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Hover me" })).toBeInTheDocument(); + }); + + it("shows the tooltip content on hover", async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole("button", { name: "Hover me" })); + + expect(await screen.findByText("Helpful hint")).toBeInTheDocument(); + }); + + it("hides the tooltip content when the pointer leaves", async () => { + const user = userEvent.setup(); + renderTooltip(); + const trigger = screen.getByRole("button", { name: "Hover me" }); + + await user.hover(trigger); + expect(await screen.findByText("Helpful hint")).toBeInTheDocument(); + + await user.unhover(trigger); + // Content leaves the DOM once the close animation/state settles. + expect(screen.queryByText("Helpful hint")).not.toBeInTheDocument(); + }); + + it("reveals the tooltip on keyboard focus of the trigger", async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.tab(); + expect(screen.getByRole("button", { name: "Hover me" })).toHaveFocus(); + expect(await screen.findByText("Helpful hint")).toBeInTheDocument(); + }); + + it("merges a consumer className onto the popup content", async () => { + const user = userEvent.setup(); + renderTooltip("custom-tooltip"); + + await user.hover(screen.getByRole("button", { name: "Hover me" })); + + const content = await screen.findByText("Helpful hint"); + expect(content.closest("[data-slot=tooltip-content]")).toHaveClass("custom-tooltip"); + }); + + it("exposes data-slot attributes on the trigger and content", async () => { + const user = userEvent.setup(); + renderTooltip(); + const trigger = screen.getByRole("button", { name: "Hover me" }); + expect(trigger).toHaveAttribute("data-slot", "tooltip-trigger"); + + await user.hover(trigger); + const content = await screen.findByText("Helpful hint"); + expect(content).toHaveAttribute("data-slot", "tooltip-content"); + }); +}); diff --git a/packages/ui/src/i18n/i18n.test.tsx b/packages/ui/src/i18n/i18n.test.tsx new file mode 100644 index 0000000..9ccf63a --- /dev/null +++ b/packages/ui/src/i18n/i18n.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { MessagesProvider } from "./provider"; +import { useMessages } from "./context"; +import type { PartialMessages } from "./messages"; + +describe("useMessages", () => { + it("returns the English defaults when there is no provider", () => { + const { result } = renderHook(() => useMessages()); + expect(result.current.appNav.navLabel).toBe("Main navigation"); + expect(result.current.codeBlock.copy).toBe("Copy"); + expect(result.current.tableOfContents.title).toBe("On this page"); + }); +}); + +describe("MessagesProvider", () => { + it("deep-merges a partial locale (two levels) over the defaults", () => { + const messages: PartialMessages = { + appNav: { searchLabel: "Buscar" }, + codeBlock: { copy: "Copiar" }, + }; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useMessages(), { wrapper }); + + // Overridden keys change. + expect(result.current.appNav.searchLabel).toBe("Buscar"); + expect(result.current.codeBlock.copy).toBe("Copiar"); + + // Untouched keys within an overridden component keep the English value. + expect(result.current.appNav.navLabel).toBe("Main navigation"); + expect(result.current.codeBlock.copied).toBe("Copied"); + + // Components absent from the override are untouched entirely. + expect(result.current.tableOfContents.title).toBe("On this page"); + }); +}); diff --git a/packages/ui/src/lib/utils.test.ts b/packages/ui/src/lib/utils.test.ts new file mode 100644 index 0000000..d4ce2fc --- /dev/null +++ b/packages/ui/src/lib/utils.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { cn, keyed } from "./utils"; + +describe("cn", () => { + it("merges conditional classes, dropping falsy ones", () => { + const active: boolean = [].length > 0; + expect(cn("a", active && "b", null, undefined, "c")).toBe("a c"); + }); + + it("lets tailwind-merge resolve conflicting utilities (last wins)", () => { + expect(cn("px-2", "px-4")).toBe("px-4"); + expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500"); + }); +}); + +describe("keyed", () => { + it("returns content-derived keys via the base function", () => { + const result = keyed(["a", "b", "c"], (x) => x); + expect(result).toEqual([ + { item: "a", key: "a" }, + { item: "b", key: "b" }, + { item: "c", key: "c" }, + ]); + }); + + it("suffixes duplicate bases with an occurrence counter so keys stay unique", () => { + const result = keyed(["dup", "dup", "dup"], (x) => x); + expect(result.map((r) => r.key)).toEqual(["dup", "dup~1", "dup~2"]); + // Still unique. + expect(new Set(result.map((r) => r.key)).size).toBe(3); + }); + + it("keeps keys stable across a reorder of distinct items", () => { + const base = (x: string) => x; + const forward = keyed(["a", "b", "c"], base); + const reordered = keyed(["c", "a", "b"], base); + const keyFor = (rs: { item: string; key: string }[], item: string) => + rs.find((r) => r.item === item)!.key; + for (const item of ["a", "b", "c"]) { + expect(keyFor(reordered, item)).toBe(keyFor(forward, item)); + } + }); + + it("returns an empty array for an empty input", () => { + expect(keyed([], (x) => String(x))).toEqual([]); + }); +});