diff --git a/packages/ui/src/components/filter-bar/filter-bar.test.tsx b/packages/ui/src/components/filter-bar/filter-bar.test.tsx new file mode 100644 index 00000000..e5895020 --- /dev/null +++ b/packages/ui/src/components/filter-bar/filter-bar.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { FilterBar, type FilterBarProps } from "./filter-bar"; + +const difficultyOptions = [ + { label: "All", value: "all" }, + { label: "Beginner", value: "beginner" }, + { label: "Advanced", value: "advanced" }, +]; + +const defaultProps: FilterBarProps = { + currentDifficulty: "", + currentTags: [], + difficultyOptions, + onFiltersChange: vi.fn(), + searchQuery: "", + tags: ["React", "TypeScript"], +}; + +function renderFilterBar(props: Partial = {}) { + const onFiltersChange = vi.fn(); + const view = render( + , + ); + + return { onFiltersChange, ...view }; +} + +describe("FilterBar", () => { + it("renders search, difficulty, and tag filters", () => { + renderFilterBar(); + + expect(screen.getByLabelText("Search")).toBeInTheDocument(); + expect(screen.getByText("Difficulty:")).toBeInTheDocument(); + expect(screen.getByText("Tags:")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); + expect(screen.getByText("React")).toBeInTheDocument(); + }); + + it("emits search filter changes", () => { + const { onFiltersChange } = renderFilterBar(); + + fireEvent.change(screen.getByLabelText("Search"), { + target: { value: "forms" }, + }); + + expect(onFiltersChange).toHaveBeenCalledWith({ search: "forms" }); + }); + + it("emits difficulty changes", () => { + const { onFiltersChange } = renderFilterBar(); + + fireEvent.click(screen.getByRole("button", { name: "Advanced" })); + + expect(onFiltersChange).toHaveBeenCalledWith({ difficulty: "advanced" }); + }); + + it("toggles tags on and off", () => { + const { onFiltersChange, unmount } = renderFilterBar(); + + fireEvent.click(screen.getByText("React")); + + expect(onFiltersChange).toHaveBeenCalledWith({ tags: ["React"] }); + unmount(); + + const selected = renderFilterBar({ currentTags: ["React"] }); + fireEvent.click(screen.getAllByText("React")[0]); + + expect(selected.onFiltersChange).toHaveBeenCalledWith({ tags: [] }); + }); + + it("clears selected tags", () => { + const { onFiltersChange } = renderFilterBar({ currentTags: ["React"] }); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + + expect(onFiltersChange).toHaveBeenCalledWith({ tags: [] }); + }); + + it("summarizes active filters and clears all values", () => { + const { onFiltersChange } = renderFilterBar({ + currentDifficulty: "advanced", + currentTags: ["React"], + searchQuery: "buttons", + }); + + const input = screen.getByLabelText("Search"); + expect(screen.getByText("Active filters:")).toBeInTheDocument(); + expect(screen.getByText('Search "buttons"')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + + expect(onFiltersChange).toHaveBeenCalledWith({ + difficulty: "all", + search: "", + tags: [], + }); + expect(input).toHaveValue(""); + }); +}); diff --git a/packages/ui/src/components/navbar-saas/navbar-saas.test.tsx b/packages/ui/src/components/navbar-saas/navbar-saas.test.tsx new file mode 100644 index 00000000..198754f7 --- /dev/null +++ b/packages/ui/src/components/navbar-saas/navbar-saas.test.tsx @@ -0,0 +1,140 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SidebarProvider } from "../sidebar-provider"; + +import { NavbarSaas, type NavbarSaasProps } from "./navbar-saas"; + +let mockPathname = "/docs"; + +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + + {children} + + ), +})); + +vi.mock("../theme-toggle", () => ({ + ThemeToggle: () => ( + + ), +})); + +const navItems = [ + { href: "/", title: "Home" }, + { href: "/docs", title: "Docs" }, + { href: "/blog", title: "Blog" }, +]; + +function setViewportWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + writable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); +} + +function renderNavbar(props: Partial = {}) { + return render( + + Login} + {...props} + /> + , + ); +} + +describe("NavbarSaas", () => { + beforeEach(() => { + mockPathname = "/docs"; + setViewportWidth(1280); + }); + + it("renders brand, navigation links, right slot, and theme toggle", () => { + renderNavbar(); + + expect(screen.getByRole("link", { name: "Vllnt" })).toHaveAttribute( + "href", + "/", + ); + expect(screen.getByRole("link", { name: "Docs" })).toHaveAttribute( + "href", + "/docs", + ); + expect(screen.getByRole("link", { name: "Login" })).toBeInTheDocument(); + expect(screen.getByLabelText("Toggle theme")).toBeInTheDocument(); + }); + + it("marks the current pathname as active", () => { + renderNavbar(); + + expect(screen.getByRole("link", { name: "Docs" })).toHaveClass( + "text-foreground", + ); + expect(screen.getByRole("link", { name: "Home" })).toHaveClass( + "text-foreground/60", + ); + }); + + it("renders a custom React node brand", () => { + renderNavbar({ + brand: Custom Brand, + rightSlot: undefined, + }); + + expect(screen.getByRole("link", { name: "Custom Brand" })).toHaveAttribute( + "href", + "/custom", + ); + }); + + it("toggles the mobile menu icon when the trigger is clicked", async () => { + setViewportWidth(390); + const { container } = renderNavbar(); + + await waitFor(() => { + expect(container.querySelector(".lucide-menu")).toBeInTheDocument(); + }); + + const trigger = container.querySelector("button[class*='lg:hidden']"); + if (!trigger) { + throw new Error("Expected mobile menu trigger to render"); + } + + fireEvent.click(trigger); + + expect(container.querySelector(".lucide-x")).toBeInTheDocument(); + }); + + it("does not render the mobile trigger when mobile menu is disabled", async () => { + setViewportWidth(390); + const { container } = renderNavbar({ showMobileMenu: false }); + + await waitFor(() => { + expect(container.querySelector(".lucide-menu")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/components/search-bar/search-bar.test.tsx b/packages/ui/src/components/search-bar/search-bar.test.tsx new file mode 100644 index 00000000..fe0bdf4f --- /dev/null +++ b/packages/ui/src/components/search-bar/search-bar.test.tsx @@ -0,0 +1,83 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SearchBar } from "./search-bar"; + +const mockReplace = vi.fn(); +let mockSearchParameters = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => mockSearchParameters, +})); + +describe("SearchBar", () => { + afterEach(() => { + vi.useRealTimers(); + mockReplace.mockClear(); + mockSearchParameters = new URLSearchParams(); + }); + + it("initializes the input from the search URL parameter", () => { + mockSearchParameters = new URLSearchParams("search=buttons"); + + render(); + + expect(screen.getByLabelText("Search posts...")).toHaveValue("buttons"); + }); + + it("submits a trimmed query through the onSearch callback", () => { + const onSearch = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Search posts..."), { + target: { value: " cards " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Search" })); + + expect(onSearch).toHaveBeenCalledWith("cards"); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("updates the URL search parameter when no callback is provided", () => { + mockSearchParameters = new URLSearchParams("category=docs"); + render(); + + fireEvent.change(screen.getByLabelText("Search posts..."), { + target: { value: "forms" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Search" })); + + expect(mockReplace).toHaveBeenCalledWith("?category=docs&search=forms"); + }); + + it("removes the URL search parameter when submitting an empty query", () => { + mockSearchParameters = new URLSearchParams("category=docs&search=forms"); + render(); + + fireEvent.change(screen.getByLabelText("Search posts..."), { + target: { value: " " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Search" })); + + expect(mockReplace).toHaveBeenCalledWith("?category=docs"); + }); + + it("debounces typed changes before calling onSearch", () => { + vi.useFakeTimers(); + const onSearch = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText("Search posts..."), { + target: { value: "alerts" }, + }); + + expect(onSearch).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(onSearch).toHaveBeenCalledWith("alerts"); + }); +}); diff --git a/packages/ui/src/components/search-dialog/search-dialog.test.tsx b/packages/ui/src/components/search-dialog/search-dialog.test.tsx new file mode 100644 index 00000000..deb30726 --- /dev/null +++ b/packages/ui/src/components/search-dialog/search-dialog.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SearchDialog } from "./search-dialog"; + +const items = [ + { + description: "Build form controls", + id: "form", + title: "Form", + }, + { + description: "Display user identity", + id: "avatar", + title: "Avatar", + }, + { + id: "badge", + keywords: "status label", + title: "Badge", + }, +]; + +function renderSearchDialog( + props: Partial[0]> = {}, +) { + const onSelect = vi.fn(); + const view = render( + , + ); + + return { onSelect, ...view }; +} + +describe("SearchDialog", () => { + it("opens the command dialog from the trigger", () => { + renderSearchDialog(); + + fireEvent.click(screen.getByRole("button", { name: /search/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Search components"), + ).toBeInTheDocument(); + expect(screen.getByText("Components")).toBeInTheDocument(); + }); + + it("sorts items by title and renders optional descriptions", () => { + renderSearchDialog(); + + fireEvent.click(screen.getByRole("button", { name: /search/i })); + + const avatar = screen.getByText("Avatar"); + const badge = screen.getByText("Badge"); + const form = screen.getByText("Form"); + + expect(avatar.compareDocumentPosition(badge)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(badge.compareDocumentPosition(form)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(screen.getByText("Display user identity")).toBeInTheDocument(); + }); + + it("selects an item and closes the dialog", async () => { + const { onSelect } = renderSearchDialog(); + + fireEvent.click(screen.getByRole("button", { name: /search/i })); + fireEvent.click(screen.getByText("Avatar")); + + expect(onSelect).toHaveBeenCalledWith(items[1]); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("opens with the keyboard shortcut", () => { + renderSearchDialog(); + + fireEvent.keyDown(window, { key: "k", metaKey: true }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("ignores keyboard shortcuts from text inputs and when disabled", () => { + const input = document.createElement("input"); + document.body.append(input); + const { unmount } = renderSearchDialog(); + + fireEvent.keyDown(input, { key: "k", metaKey: true }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + input.remove(); + unmount(); + + renderSearchDialog({ enableKeyboardShortcut: false }); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/sidebar/sidebar.test.tsx b/packages/ui/src/components/sidebar/sidebar.test.tsx new file mode 100644 index 00000000..88ccf433 --- /dev/null +++ b/packages/ui/src/components/sidebar/sidebar.test.tsx @@ -0,0 +1,199 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SidebarProvider, useSidebar } from "../sidebar-provider"; + +import { Sidebar, type SidebarSection } from "./sidebar"; + +let mockPathname = "/docs/components"; + +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + onClick, + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + { + event.preventDefault(); + onClick?.(event); + }} + > + {children} + + ), +})); + +const sections: SidebarSection[] = [ + { + items: [ + { href: "/", title: "Overview" }, + { href: "/docs/components", title: "Components" }, + ], + }, + { + items: [ + { href: "/docs/forms", title: "Forms" }, + { href: "/docs/navigation", title: "Navigation" }, + ], + title: "Guides", + }, +]; + +function setViewportWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + writable: true, + }); + act(() => { + window.dispatchEvent(new Event("resize")); + }); +} + +function SidebarStateProbe() { + const { open, setOpen } = useSidebar(); + + return ( + <> + {open ? "open" : "closed"} + + + ); +} + +function renderSidebar(sidebarSections: SidebarSection[] = sections) { + return render( + + + + , + ); +} + +describe("Sidebar", () => { + beforeEach(() => { + mockPathname = "/docs/components"; + setViewportWidth(1280); + }); + + it("renders sections, links, and active route state", async () => { + renderSidebar(); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("open"); + }); + + expect(screen.getByText("Guides")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Components" })).toHaveClass( + "bg-accent", + "text-accent-foreground", + ); + expect(screen.getByRole("link", { name: "Forms" })).toHaveAttribute( + "href", + "/docs/forms", + ); + }); + + it("honors collapsible section default state and toggles it", () => { + renderSidebar([ + { + collapsible: true, + defaultOpen: false, + items: [{ href: "/docs/forms", title: "Forms" }], + title: "Guides", + }, + ]); + + const trigger = screen.getByRole("button", { name: "Guides" }); + const panel = trigger.nextElementSibling; + + expect(panel?.className).toContain("grid-rows-[0fr]"); + + fireEvent.click(trigger); + + expect(panel?.className).toContain("grid-rows-[1fr]"); + }); + + it("opens on desktop and closes on mobile resize", async () => { + renderSidebar(); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("open"); + }); + + setViewportWidth(390); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("closed"); + }); + + setViewportWidth(1280); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("open"); + }); + }); + + it("closes the mobile overlay on click", async () => { + setViewportWidth(390); + renderSidebar(); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("closed"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open sidebar" })); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("open"); + }); + + const overlay = document.querySelector("div[role='button']"); + if (!overlay) { + throw new Error("Expected mobile overlay to render"); + } + + fireEvent.click(overlay); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("closed"); + }); + }); + + it("closes the mobile sidebar when a link is selected", async () => { + setViewportWidth(390); + renderSidebar(); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("closed"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open sidebar" })); + fireEvent.click(screen.getByRole("link", { name: "Forms" })); + + await waitFor(() => { + expect(screen.getByTestId("sidebar-state")).toHaveTextContent("closed"); + }); + }); +});