diff --git a/src/components/ElevationAndShape.stories.tsx b/src/components/ElevationAndShape.stories.tsx index 0707255c..3f443bb4 100644 --- a/src/components/ElevationAndShape.stories.tsx +++ b/src/components/ElevationAndShape.stories.tsx @@ -6,11 +6,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite" const ELEVATION_LEVELS = [ { level: 0, cssClass: "shadow-elevation-0", cssVar: "--elevation-0", description: "Flat / no shadow" }, - { level: 1, cssClass: "shadow-elevation-1", cssVar: "--elevation-1", description: "Cards, buttons at rest" }, - { level: 2, cssClass: "shadow-elevation-2", cssVar: "--elevation-2", description: "Raised cards, menus" }, - { level: 3, cssClass: "shadow-elevation-3", cssVar: "--elevation-3", description: "Navigation drawers, FABs" }, - { level: 4, cssClass: "shadow-elevation-4", cssVar: "--elevation-4", description: "App bars, elevated navigation" }, - { level: 5, cssClass: "shadow-elevation-5", cssVar: "--elevation-5", description: "Dialogs, modals" }, + { level: 1, cssClass: "shadow-elevation-1", cssVar: "--elevation-1", description: "Hairline raise (≙ shadow-2xs)" }, + { level: 2, cssClass: "shadow-elevation-2", cssVar: "--elevation-2", description: "Subtle cards, chips (≙ shadow-xs)" }, + { level: 3, cssClass: "shadow-elevation-3", cssVar: "--elevation-3", description: "Cards, tab pills, sidebar (≙ shadow-sm)" }, + { level: 4, cssClass: "shadow-elevation-4", cssVar: "--elevation-4", description: "Menus, popovers, selects (≙ shadow-md)" }, + { level: 5, cssClass: "shadow-elevation-5", cssVar: "--elevation-5", description: "Dialogs, sheets, submenus (≙ shadow-lg)" }, + { level: 6, cssClass: "shadow-elevation-6", cssVar: "--elevation-6", description: "Reserved headroom (≙ shadow-xl)" }, + { level: 7, cssClass: "shadow-elevation-7", cssVar: "--elevation-7", description: "Maximum lift (≙ shadow-2xl)" }, ] // --------------------------------------------------------------------------- diff --git a/src/components/ai/confirmation.tsx b/src/components/ai/confirmation.tsx index a3c6c5d2..e9272c94 100644 --- a/src/components/ai/confirmation.tsx +++ b/src/components/ai/confirmation.tsx @@ -84,7 +84,7 @@ export const Confirmation = ({ ({
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index 6ccb50e4..4621e2ec 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -113,7 +113,7 @@ function ComboboxContent({ diff --git a/src/components/ui/context-menu.stories.tsx b/src/components/ui/context-menu.stories.tsx index 0134f626..1f71f5b7 100644 --- a/src/components/ui/context-menu.stories.tsx +++ b/src/components/ui/context-menu.stories.tsx @@ -1,12 +1,21 @@ import { CopyIcon, FolderIcon, PencilIcon, Trash2Icon } from "lucide-react" -import { expect, within } from "storybook/test" +import React from "react" +import { expect, userEvent, waitFor, within } from "storybook/test" import { ContextMenu, + ContextMenuCheckboxItem, ContextMenuContent, + ContextMenuGroup, ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "./context-menu" @@ -122,4 +131,232 @@ export const Destructive: Story = { expect(await body.findByText("Delete")).toBeInTheDocument() }) }, +} + +export const WithGroup: Story = { + render: () => ( + + + Right click this area + + + + File + + + Rename + ⌘R + + + + Duplicate + ⌘D + + + + + Actions + + + Move to folder + + + + + ), + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Trigger renders", async () => { + expect(canvas.getByText("Right click this area")).toBeInTheDocument() + }) + + await step("Open menu and verify grouped items", async () => { + const trigger = canvas.getByText("Right click this area") + trigger.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 8, clientY: 8 }), + ) + const body = within(canvasElement.ownerDocument.body) + expect(await body.findByText("File")).toBeInTheDocument() + expect(body.getByText("Actions")).toBeInTheDocument() + expect(body.getByText("Rename")).toBeInTheDocument() + expect(body.getByText("Move to folder")).toBeInTheDocument() + }) + }, +} + +export const WithSubMenu: Story = { + render: () => ( + + + Right click this area + + + + + Rename + + + + + Move to + + + Projects + Archive + Trash + + + + + + Delete + + + + ), + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Trigger renders", async () => { + expect(canvas.getByText("Right click this area")).toBeInTheDocument() + }) + + await step("Open menu and verify sub-trigger", async () => { + const trigger = canvas.getByText("Right click this area") + trigger.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 8, clientY: 8 }), + ) + const body = within(canvasElement.ownerDocument.body) + expect(await body.findByText("Move to")).toBeInTheDocument() + expect( + body.getByText("Move to").closest("[data-slot='context-menu-sub-trigger']"), + ).toBeInTheDocument() + }) + + await step("Hover sub-trigger opens sub-content", async () => { + const body = within(canvasElement.ownerDocument.body) + const subTrigger = body.getByText("Move to") + await userEvent.hover(subTrigger) + await waitFor(() => { + expect(body.getByText("Projects")).toBeInTheDocument() + }) + expect(body.getByText("Archive")).toBeInTheDocument() + }) + }, +} + +export const WithCheckboxItems: Story = { + render: () => { + const [showGrid, setShowGrid] = React.useState(true) + const [showRulers, setShowRulers] = React.useState(false) + + return ( + + + Right click this area + + + View options + + + Show grid + + + Show rulers + + + + ) + }, + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Trigger renders", async () => { + expect(canvas.getByText("Right click this area")).toBeInTheDocument() + }) + + await step("Open menu and verify checkbox items", async () => { + const trigger = canvas.getByText("Right click this area") + trigger.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 8, clientY: 8 }), + ) + const body = within(canvasElement.ownerDocument.body) + expect(await body.findByText("Show grid")).toBeInTheDocument() + expect(body.getByText("Show rulers")).toBeInTheDocument() + }) + + await step("Checked item has data-state checked", async () => { + const body = within(canvasElement.ownerDocument.body) + const gridItem = body.getByText("Show grid").closest("[data-slot='context-menu-checkbox-item']")! + expect(gridItem).toHaveAttribute("data-state", "checked") + const rulersItem = body.getByText("Show rulers").closest("[data-slot='context-menu-checkbox-item']")! + expect(rulersItem).toHaveAttribute("data-state", "unchecked") + }) + }, +} + +export const WithRadioItems: Story = { + render: () => { + const [zoom, setZoom] = React.useState("100") + + return ( + + + Right click this area + + + Zoom level + + + 50% + 100% + 150% + + + + ) + }, + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Trigger renders", async () => { + expect(canvas.getByText("Right click this area")).toBeInTheDocument() + }) + + await step("Open menu and verify radio items", async () => { + const trigger = canvas.getByText("Right click this area") + trigger.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 8, clientY: 8 }), + ) + const body = within(canvasElement.ownerDocument.body) + expect(await body.findByText("100%")).toBeInTheDocument() + expect(body.getByText("50%")).toBeInTheDocument() + expect(body.getByText("150%")).toBeInTheDocument() + }) + + await step("Selected radio item has data-state checked", async () => { + const body = within(canvasElement.ownerDocument.body) + const hundredItem = body.getByText("100%").closest("[data-slot='context-menu-radio-item']")! + expect(hundredItem).toHaveAttribute("data-state", "checked") + const fiftyItem = body.getByText("50%").closest("[data-slot='context-menu-radio-item']")! + expect(fiftyItem).toHaveAttribute("data-state", "unchecked") + }) + }, } \ No newline at end of file diff --git a/src/components/ui/context-menu.test.tsx b/src/components/ui/context-menu.test.tsx new file mode 100644 index 00000000..ad68c63e --- /dev/null +++ b/src/components/ui/context-menu.test.tsx @@ -0,0 +1,434 @@ +import * as React from "react" +import { flushSync } from "react-dom" +import { createRoot } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "./context-menu" + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +let container: HTMLDivElement +let root: ReturnType + +beforeEach(() => { + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + flushSync(() => root.unmount()) + container.remove() +}) + +function render(ui: React.ReactElement) { + flushSync(() => root.render(ui)) + return container +} + +// Helper: renders a complete context-menu tree. Content renders via a Portal +// once the menu is open — call openMenu() after render() to open it. +function Menu({ children }: { children?: React.ReactNode }) { + return ( + + Right click + {children} + + ) +} + +// Open the context menu by dispatching a contextmenu event on the trigger. +// Wrapped in flushSync so all resulting React state updates are flushed. +function openMenu() { + const trigger = document.querySelector("[data-slot='context-menu-trigger']") as HTMLElement + flushSync(() => { + trigger.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 1, clientY: 1 }), + ) + }) +} + +// --------------------------------------------------------------------------- +// ContextMenuShortcut +// --------------------------------------------------------------------------- + +describe("ContextMenuShortcut", () => { + it("renders with data-slot attribute", () => { + render(⌘K) + const el = document.querySelector("[data-slot='context-menu-shortcut']") + expect(el).toBeTruthy() + expect(el!.textContent).toBe("⌘K") + }) + + it("merges custom className", () => { + render(⌘X) + const el = document.querySelector("[data-slot='context-menu-shortcut']") + expect(el!.className).toContain("extra") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuLabel +// --------------------------------------------------------------------------- + +describe("ContextMenuLabel", () => { + it("renders with data-slot", () => { + render(Section) + expect(document.querySelector("[data-slot='context-menu-label']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset label) + const el = document.querySelector("[data-slot='context-menu-label']") + expect(el).toBeTruthy() + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("merges custom className", () => { + render(L) + const el = document.querySelector("[data-slot='context-menu-label']") + expect(el!.className).toContain("my-label") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuSeparator +// --------------------------------------------------------------------------- + +describe("ContextMenuSeparator", () => { + it("renders with data-slot", () => { + render() + expect(document.querySelector("[data-slot='context-menu-separator']")).toBeTruthy() + }) + + it("merges custom className", () => { + render() + const el = document.querySelector("[data-slot='context-menu-separator']") + expect(el!.className).toContain("sep-cls") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenu + ContextMenuTrigger +// --------------------------------------------------------------------------- + +describe("ContextMenu and ContextMenuTrigger", () => { + it("trigger renders with data-slot", () => { + render( + + Right click + , + ) + expect(document.querySelector("[data-slot='context-menu-trigger']")).toBeTruthy() + }) + + it("trigger applies select-none by default", () => { + render( + + Right click + , + ) + const el = document.querySelector("[data-slot='context-menu-trigger']") + expect(el!.className).toContain("select-none") + }) + + it("trigger merges custom className", () => { + render( + + t + , + ) + const el = document.querySelector("[data-slot='context-menu-trigger']") + expect(el!.className).toContain("trigger-cls") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuContent (opened via contextmenu event → portal renders) +// --------------------------------------------------------------------------- + +describe("ContextMenuContent", () => { + it("renders with data-slot when menu is open", () => { + render(Item) + openMenu() + expect(document.querySelector("[data-slot='context-menu-content']")).toBeTruthy() + }) + + it("renders with custom className on content", () => { + render( + + Right click + + Item + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-content']") + expect(el!.className).toContain("content-cls") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuGroup +// --------------------------------------------------------------------------- + +describe("ContextMenuGroup", () => { + it("renders with data-slot", () => { + render( + + + Grouped item + + , + ) + openMenu() + expect(document.querySelector("[data-slot='context-menu-group']")).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuItem +// --------------------------------------------------------------------------- + +describe("ContextMenuItem", () => { + it("renders with data-slot", () => { + render(Item) + openMenu() + expect(document.querySelector("[data-slot='context-menu-item']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset item) + openMenu() + const el = document.querySelector("[data-slot='context-menu-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("renders with destructive variant", () => { + render(Delete) + openMenu() + const el = document.querySelector("[data-slot='context-menu-item']") + expect(el!.getAttribute("data-variant")).toBe("destructive") + }) + + it("default variant is 'default'", () => { + render(Default) + openMenu() + const el = document.querySelector("[data-slot='context-menu-item']") + expect(el!.getAttribute("data-variant")).toBe("default") + }) + + it("merges custom className", () => { + render(I) + openMenu() + const el = document.querySelector("[data-slot='context-menu-item']") + expect(el!.className).toContain("my-item") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuCheckboxItem +// --------------------------------------------------------------------------- + +describe("ContextMenuCheckboxItem", () => { + it("renders with data-slot", () => { + render(Option) + openMenu() + expect(document.querySelector("[data-slot='context-menu-checkbox-item']")).toBeTruthy() + }) + + it("renders checked state", () => { + render(Checked) + openMenu() + expect(document.querySelector("[data-slot='context-menu-checkbox-item']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset) + openMenu() + const el = document.querySelector("[data-slot='context-menu-checkbox-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("renders children text", () => { + render(My Option) + openMenu() + const el = document.querySelector("[data-slot='context-menu-checkbox-item']") + expect(el!.textContent).toContain("My Option") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuRadioGroup + ContextMenuRadioItem +// --------------------------------------------------------------------------- + +describe("ContextMenuRadioGroup and ContextMenuRadioItem", () => { + it("renders radio group with data-slot", () => { + render( + + + Option A + Option B + + , + ) + openMenu() + expect(document.querySelector("[data-slot='context-menu-radio-group']")).toBeTruthy() + expect(document.querySelectorAll("[data-slot='context-menu-radio-item']").length).toBe(2) + }) + + it("radio item renders with inset prop", () => { + render( + + + Inset + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-radio-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("radio item renders children text", () => { + render( + + + Radio label + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-radio-item']") + expect(el!.textContent).toContain("Radio label") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuSub + ContextMenuSubTrigger + ContextMenuSubContent +// --------------------------------------------------------------------------- + +describe("ContextMenuSub components", () => { + it("renders sub-trigger with data-slot", () => { + render( + + + More options + + Sub item + + + , + ) + openMenu() + expect(document.querySelector("[data-slot='context-menu-sub-trigger']")).toBeTruthy() + }) + + it("renders sub-content with data-slot when forceMount", () => { + render( + + + More + + Sub + + + , + ) + openMenu() + expect(document.querySelector("[data-slot='context-menu-sub-content']")).toBeTruthy() + }) + + it("sub-trigger includes chevron icon", () => { + render( + + + Sub menu + + , + ) + openMenu() + const trigger = document.querySelector("[data-slot='context-menu-sub-trigger']") + // The ChevronRightIcon svg should be inside the trigger + expect(trigger!.querySelector("svg")).toBeTruthy() + }) + + it("sub-trigger renders with inset prop", () => { + render( + + + Inset sub + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-sub-trigger']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("sub-trigger merges custom className", () => { + render( + + + Sub + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-sub-trigger']") + expect(el!.className).toContain("my-sub") + }) + + it("sub-content merges custom className", () => { + render( + + + S + + Item + + + , + ) + openMenu() + const el = document.querySelector("[data-slot='context-menu-sub-content']") + expect(el!.className).toContain("sub-content-cls") + }) +}) + +// --------------------------------------------------------------------------- +// ContextMenuPortal +// --------------------------------------------------------------------------- + +describe("ContextMenuPortal", () => { + it("renders children via portal", () => { + render( + + trigger + +
portal content
+
+
, + ) + expect(document.body.querySelector("[data-testid='portal-child']")).toBeTruthy() + }) +}) diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index a61a74eb..5a1e3ec8 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -67,7 +67,7 @@ function ContextMenuContent({ @@ -128,7 +128,7 @@ function ContextMenuSubContent({ return ( ) diff --git a/src/components/ui/data-table/data-table-column-toggle.tsx b/src/components/ui/data-table/data-table-column-toggle.tsx index dd0cda05..bc0362a0 100644 --- a/src/components/ui/data-table/data-table-column-toggle.tsx +++ b/src/components/ui/data-table/data-table-column-toggle.tsx @@ -67,7 +67,7 @@ function SortableColumnItem({ id, label, visible, onToggle }: SortableColumnItem }} className={cn( "group/col-item flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm select-none hover:bg-accent hover:text-accent-foreground", - isDragging && "z-50 bg-accent text-accent-foreground shadow-sm", + isDragging && "z-50 bg-accent text-accent-foreground shadow-elevation-3", )} > + + + File + + + + Rename + ⌘R + + + Duplicate + ⌘D + + + + + ), + parameters: { + layout: "centered", + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body) + + await step("Label renders", async () => { + expect(body.getByText("File")).toBeInTheDocument() + }) + + await step("Shortcuts render", async () => { + expect(body.getByText("⌘R")).toBeInTheDocument() + expect(body.getByText("⌘D")).toBeInTheDocument() + }) + + await step("Group items render", async () => { + expect(body.getByRole("menuitem", { name: /Rename/ })).toBeInTheDocument() + expect(body.getByRole("menuitem", { name: /Duplicate/ })).toBeInTheDocument() + }) + }, +} + +export const WithCheckboxItems: Story = { + render: () => { + const [showGrid, setShowGrid] = React.useState(true) + const [showRulers, setShowRulers] = React.useState(false) + + return ( + + + + + + Display + + + Show grid + + + Show rulers + + + + ) + }, + parameters: { + layout: "centered", + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body) + + await step("Checkbox items render", async () => { + expect(body.getByText("Show grid")).toBeInTheDocument() + expect(body.getByText("Show rulers")).toBeInTheDocument() + }) + + await step("Checked item has data-state checked", async () => { + const gridItem = body.getByText("Show grid").closest("[data-slot='dropdown-menu-checkbox-item']")! + expect(gridItem).toHaveAttribute("data-state", "checked") + const rulersItem = body.getByText("Show rulers").closest("[data-slot='dropdown-menu-checkbox-item']")! + expect(rulersItem).toHaveAttribute("data-state", "unchecked") + }) + + await step("Clicking unchecked item toggles it", async () => { + await userEvent.click(body.getByText("Show rulers")) + const rulersItem = body.getByText("Show rulers").closest("[data-slot='dropdown-menu-checkbox-item']")! + expect(rulersItem).toHaveAttribute("data-state", "checked") + }) + }, +} + +export const WithRadioItems: Story = { + render: () => { + const [theme, setTheme] = React.useState("system") + + return ( + + + + + + Theme + + + Light + Dark + System + + + + ) + }, + parameters: { + layout: "centered", + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body) + + await step("Radio items render with correct initial selection", async () => { + const systemItem = body.getByText("System").closest("[data-slot='dropdown-menu-radio-item']")! + const lightItem = body.getByText("Light").closest("[data-slot='dropdown-menu-radio-item']")! + expect(systemItem).toHaveAttribute("data-state", "checked") + expect(lightItem).toHaveAttribute("data-state", "unchecked") + }) + + await step("Clicking a different radio item selects it", async () => { + await userEvent.click(body.getByText("Dark")) + const darkItem = body.getByText("Dark").closest("[data-slot='dropdown-menu-radio-item']")! + expect(darkItem).toHaveAttribute("data-state", "checked") + }) + }, +} + +export const WithSubMenu: Story = { + render: () => ( + + + + + + New file + + Share + + Email + Slack + Copy link + + + + Delete + + + ), + parameters: { + layout: "centered", + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body) + + await step("Sub-trigger renders with correct data-slot", async () => { + const shareTrigger = body.getByText("Share") + expect(shareTrigger.closest("[data-slot='dropdown-menu-sub-trigger']")).toBeInTheDocument() + }) + + await step("Hovering sub-trigger opens sub-content", async () => { + const shareTrigger = body.getByText("Share") + await userEvent.hover(shareTrigger) + await waitFor(() => { + expect(body.getByText("Email")).toBeInTheDocument() + }) + expect(body.getByText("Slack")).toBeInTheDocument() + expect(body.getByText("Copy link")).toBeInTheDocument() + }) + }, } \ No newline at end of file diff --git a/src/components/ui/dropdown-menu.test.tsx b/src/components/ui/dropdown-menu.test.tsx new file mode 100644 index 00000000..37ef4335 --- /dev/null +++ b/src/components/ui/dropdown-menu.test.tsx @@ -0,0 +1,382 @@ +import * as React from "react" +import { flushSync } from "react-dom" +import { createRoot } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./dropdown-menu" + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +let container: HTMLDivElement +let root: ReturnType + +beforeEach(() => { + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + flushSync(() => root.unmount()) + container.remove() +}) + +function render(ui: React.ReactElement) { + flushSync(() => root.render(ui)) + return container +} + +// Helper: renders an open dropdown menu so content is always in the DOM. +function Menu({ children }: { children?: React.ReactNode }) { + return ( + + Open + {children} + + ) +} + +// --------------------------------------------------------------------------- +// DropdownMenuShortcut +// --------------------------------------------------------------------------- + +describe("DropdownMenuShortcut", () => { + it("renders with data-slot attribute", () => { + render(⌘K) + const el = document.querySelector("[data-slot='dropdown-menu-shortcut']") + expect(el).toBeTruthy() + expect(el!.textContent).toBe("⌘K") + }) + + it("merges custom className", () => { + render(⌘X) + const el = document.querySelector("[data-slot='dropdown-menu-shortcut']") + expect(el!.className).toContain("sc-cls") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuLabel +// --------------------------------------------------------------------------- + +describe("DropdownMenuLabel", () => { + it("renders with data-slot", () => { + render(Section) + expect(document.querySelector("[data-slot='dropdown-menu-label']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset label) + const el = document.querySelector("[data-slot='dropdown-menu-label']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("merges custom className", () => { + render(L) + const el = document.querySelector("[data-slot='dropdown-menu-label']") + expect(el!.className).toContain("lbl-cls") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuSeparator +// --------------------------------------------------------------------------- + +describe("DropdownMenuSeparator", () => { + it("renders with data-slot", () => { + render() + expect(document.querySelector("[data-slot='dropdown-menu-separator']")).toBeTruthy() + }) + + it("merges custom className", () => { + render() + const el = document.querySelector("[data-slot='dropdown-menu-separator']") + expect(el!.className).toContain("sep-cls") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenu + DropdownMenuTrigger +// --------------------------------------------------------------------------- + +describe("DropdownMenu and DropdownMenuTrigger", () => { + it("trigger renders with data-slot", () => { + render( + + Open + , + ) + expect(document.querySelector("[data-slot='dropdown-menu-trigger']")).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuContent (open=true means content is always rendered) +// --------------------------------------------------------------------------- + +describe("DropdownMenuContent", () => { + it("renders with data-slot when menu is open", () => { + render(Item) + expect(document.querySelector("[data-slot='dropdown-menu-content']")).toBeTruthy() + }) + + it("renders with custom className", () => { + render( + + Open + + Item + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-content']") + expect(el!.className).toContain("content-cls") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuGroup +// --------------------------------------------------------------------------- + +describe("DropdownMenuGroup", () => { + it("renders with data-slot", () => { + render( + + + Grouped item + + , + ) + expect(document.querySelector("[data-slot='dropdown-menu-group']")).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuItem +// --------------------------------------------------------------------------- + +describe("DropdownMenuItem", () => { + it("renders with data-slot", () => { + render(Item) + expect(document.querySelector("[data-slot='dropdown-menu-item']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset item) + const el = document.querySelector("[data-slot='dropdown-menu-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("renders with destructive variant", () => { + render(Delete) + const el = document.querySelector("[data-slot='dropdown-menu-item']") + expect(el!.getAttribute("data-variant")).toBe("destructive") + }) + + it("default variant is 'default'", () => { + render(Default) + const el = document.querySelector("[data-slot='dropdown-menu-item']") + expect(el!.getAttribute("data-variant")).toBe("default") + }) + + it("merges custom className", () => { + render(I) + const el = document.querySelector("[data-slot='dropdown-menu-item']") + expect(el!.className).toContain("my-item") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuCheckboxItem +// --------------------------------------------------------------------------- + +describe("DropdownMenuCheckboxItem", () => { + it("renders with data-slot", () => { + render(Option) + expect(document.querySelector("[data-slot='dropdown-menu-checkbox-item']")).toBeTruthy() + }) + + it("renders checked state", () => { + render(Checked) + expect(document.querySelector("[data-slot='dropdown-menu-checkbox-item']")).toBeTruthy() + }) + + it("renders with inset prop", () => { + render(Inset) + const el = document.querySelector("[data-slot='dropdown-menu-checkbox-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("renders children text", () => { + render(My Option) + const el = document.querySelector("[data-slot='dropdown-menu-checkbox-item']") + expect(el!.textContent).toContain("My Option") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuRadioGroup + DropdownMenuRadioItem +// --------------------------------------------------------------------------- + +describe("DropdownMenuRadioGroup and DropdownMenuRadioItem", () => { + it("renders radio group with data-slot", () => { + render( + + + Option A + Option B + + , + ) + expect(document.querySelector("[data-slot='dropdown-menu-radio-group']")).toBeTruthy() + expect(document.querySelectorAll("[data-slot='dropdown-menu-radio-item']").length).toBe(2) + }) + + it("radio item renders with inset prop", () => { + render( + + + Inset + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-radio-item']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("radio item renders children text", () => { + render( + + + Radio label + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-radio-item']") + expect(el!.textContent).toContain("Radio label") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuSub + DropdownMenuSubTrigger + DropdownMenuSubContent +// --------------------------------------------------------------------------- + +describe("DropdownMenuSub components", () => { + it("renders sub-trigger with data-slot", () => { + render( + + + More options + + Sub item + + + , + ) + expect(document.querySelector("[data-slot='dropdown-menu-sub-trigger']")).toBeTruthy() + }) + + it("renders sub-content with data-slot when forceMount", () => { + render( + + + More + + Sub + + + , + ) + expect(document.querySelector("[data-slot='dropdown-menu-sub-content']")).toBeTruthy() + }) + + it("sub-trigger includes chevron icon", () => { + render( + + + Sub menu + + , + ) + const trigger = document.querySelector("[data-slot='dropdown-menu-sub-trigger']") + expect(trigger!.querySelector("svg")).toBeTruthy() + }) + + it("sub-trigger renders with inset prop", () => { + render( + + + Inset sub + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-sub-trigger']") + expect(el!.getAttribute("data-inset")).toBeTruthy() + }) + + it("sub-trigger merges custom className", () => { + render( + + + Sub + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-sub-trigger']") + expect(el!.className).toContain("my-sub") + }) + + it("sub-content merges custom className", () => { + render( + + + S + + Item + + + , + ) + const el = document.querySelector("[data-slot='dropdown-menu-sub-content']") + expect(el!.className).toContain("sub-content-cls") + }) +}) + +// --------------------------------------------------------------------------- +// DropdownMenuPortal +// --------------------------------------------------------------------------- + +describe("DropdownMenuPortal", () => { + it("renders children via portal into document.body", () => { + render( + + Open + + + Portal item + + + , + ) + // When open, portal content is rendered into document.body + expect(document.body.querySelector("[data-slot='dropdown-menu-content']")).toBeTruthy() + }) +}) diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index b14147e4..94936ff7 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -42,7 +42,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} align={align} - className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} + className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-elevation-4 ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} {...props} /> @@ -243,7 +243,7 @@ function DropdownMenuSubContent({ return ( ) diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx index 3a5958f7..0f8d22ec 100644 --- a/src/components/ui/hover-card.tsx +++ b/src/components/ui/hover-card.tsx @@ -30,7 +30,7 @@ function HoverCardContent({ align={align} sideOffset={sideOffset} className={cn( - "z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + "z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-elevation-4 ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} {...props} diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx index 8e0618bd..91279878 100644 --- a/src/components/ui/menubar.tsx +++ b/src/components/ui/menubar.tsx @@ -77,7 +77,7 @@ function MenubarContent({ align={align} alignOffset={alignOffset} sideOffset={sideOffset} - className={cn("z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )} + className={cn("z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-elevation-4 ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )} {...props} /> @@ -253,7 +253,7 @@ function MenubarSubContent({ return ( ) diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx index 3d0e6c2c..28666b69 100644 --- a/src/components/ui/navigation-menu.tsx +++ b/src/components/ui/navigation-menu.tsx @@ -147,7 +147,7 @@ function NavigationMenuIndicator({ )} {...props} > -
+
) } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 459e5c04..f1420a3f 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -34,7 +34,7 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-3 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-3 text-sm text-popover-foreground shadow-elevation-4 ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )} {...props} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 98af97c9..ca76eea8 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -70,7 +70,7 @@ function SelectContent({ ( +
+ + + + + Notifications + + + + + + Alerts + + + + + + + + + Inbox + + + + + + + + + + + +
Show on Hover Example
+
+
+
+ ), + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Menu items render", async () => { + expect(canvas.getByText("Alerts")).toBeInTheDocument() + expect(canvas.getByText("Inbox")).toBeInTheDocument() + }) + + await step("showOnHover action has opacity-0 class on desktop", async () => { + const hoverAction = canvas.getByRole("button", { name: "Mark all read" }) + expect(hoverAction).toBeInTheDocument() + expect(hoverAction.className).toContain("md:opacity-0") + }) + + await step("Always-visible action does not have opacity-0 class", async () => { + const visibleAction = canvas.getByRole("button", { name: "View inbox" }) + expect(visibleAction).toBeInTheDocument() + expect(visibleAction.className).not.toContain("md:opacity-0") + }) + }, +} + +export const ControlledSidebar: Story = { + render: () => { + const [open, setOpen] = React.useState(true) + + return ( +
+ + + + + + + + + + Home + + + + + + + + +
+ + Controlled Sidebar +
+
+
+
+ ) + }, + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("Sidebar starts open via controlled prop", async () => { + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("expanded") + }) + + await step("Toggle button collapses the sidebar", async () => { + const trigger = canvas.getByRole("button", { name: /toggle sidebar/i }) + await userEvent.click(trigger) + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("collapsed") + }) + + await step("Toggle button re-expands the sidebar", async () => { + const trigger = canvas.getByRole("button", { name: /toggle sidebar/i }) + await userEvent.click(trigger) + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("expanded") + }) + }, +} + +export const KeyboardShortcutToggle: Story = { + render: (args) => renderSidebar(args), + parameters: { + // Auto-generated by sync-storybook-zephyr - do not add manually + zephyr: { testCaseId: "" }, + }, + play: async ({ canvasElement, step }) => { + await step("Sidebar starts expanded", async () => { + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("expanded") + }) + + await step("Ctrl+B collapses sidebar via keyboard shortcut", async () => { + await userEvent.keyboard("{Control>}b{/Control}") + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("collapsed") + }) + + await step("Ctrl+B again expands sidebar", async () => { + await userEvent.keyboard("{Control>}b{/Control}") + const sidebarSlot = canvasElement.querySelector('[data-slot="sidebar"]') + expect(sidebarSlot?.getAttribute("data-state")).toBe("expanded") + }) + }, +} diff --git a/src/components/ui/sidebar.test.tsx b/src/components/ui/sidebar.test.tsx new file mode 100644 index 00000000..2eb5c825 --- /dev/null +++ b/src/components/ui/sidebar.test.tsx @@ -0,0 +1,528 @@ +import * as React from "react" +import { flushSync } from "react-dom" +import { createRoot } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +// Mock useIsMobile before importing sidebar components so that the hook never +// calls window.matchMedia (not implemented in jsdom). +const mockUseIsMobile = vi.hoisted(() => vi.fn<[], boolean>()) + +vi.mock("@/hooks/use-mobile", () => ({ + useIsMobile: mockUseIsMobile, +})) + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "./sidebar" +import { TooltipProvider } from "./tooltip" + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +let container: HTMLDivElement +let root: ReturnType + +beforeEach(() => { + // Default: desktop (non-mobile) environment for all tests + mockUseIsMobile.mockReturnValue(false) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + flushSync(() => root.unmount()) + container.remove() +}) + +function render(ui: React.ReactElement) { + flushSync(() => root.render(ui)) + return container +} + +// Minimal wrapper that gives SidebarProvider all required siblings. +// TooltipProvider is included because SidebarMenuButton with tooltip prop uses Tooltip. +function WithSidebar({ + children, + providerProps, +}: { + children: React.ReactNode + providerProps?: Partial> +}) { + return ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// useSidebar — error when outside provider +// --------------------------------------------------------------------------- + +describe("useSidebar", () => { + it("throws when used outside a SidebarProvider", () => { + let caughtError: Error | undefined + + function ThrowingComponent() { + try { + useSidebar() + } catch (e) { + caughtError = e as Error + } + return null + } + + const c = document.createElement("div") + const r = createRoot(c) + flushSync(() => r.render(React.createElement(ThrowingComponent))) + r.unmount() + + expect(caughtError).toBeDefined() + expect(caughtError!.message).toContain("useSidebar must be used within a SidebarProvider") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarProvider — keyboard shortcut toggles state +// --------------------------------------------------------------------------- + +describe("SidebarProvider keyboard shortcut", () => { + it("Ctrl+b toggles the sidebar state", () => { + render( + + + + + + Item + + + + + , + ) + + const sidebar = document.querySelector("[data-slot='sidebar']") + expect(sidebar?.getAttribute("data-state")).toBe("expanded") + + // Fire the keyboard shortcut (Ctrl+b) + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + ctrlKey: true, + bubbles: true, + }), + ) + flushSync(() => {}) // flush any pending state updates + + expect(document.querySelector("[data-slot='sidebar']")?.getAttribute("data-state")).toBe( + "collapsed", + ) + }) +}) + +// --------------------------------------------------------------------------- +// Sidebar — collapsible="none" renders a static div +// --------------------------------------------------------------------------- + +describe("Sidebar collapsible=none", () => { + it("renders a plain sidebar div without the gap/container structure", () => { + render( + + + Static + + , + ) + // collapsible="none" renders a plain div, not the two-panel desktop structure + expect(container.querySelector("[data-slot='sidebar']")).toBeTruthy() + expect(container.querySelector("[data-testid='content']")?.textContent).toBe("Static") + // No sidebar-gap element in the none mode + expect(container.querySelector("[data-slot='sidebar-gap']")).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Sidebar — mobile path (isMobile = true) +// --------------------------------------------------------------------------- + +describe("Sidebar mobile mode", () => { + it("renders Sheet component when isMobile is true", () => { + mockUseIsMobile.mockReturnValue(true) + + render( + + + Mobile + + + , + ) + + // Click the trigger — in mobile mode this opens the Sheet (sets openMobile=true) + const trigger = document.querySelector("[data-slot='sidebar-trigger']") as HTMLButtonElement + flushSync(() => trigger.click()) + + // SheetContent renders via portal with data-mobile="true" when open + const mobileSidebar = document.querySelector("[data-mobile='true']") + expect(mobileSidebar).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// SidebarTrigger +// --------------------------------------------------------------------------- + +describe("SidebarTrigger", () => { + it("renders with data-slot and toggles sidebar on click", () => { + render( + + + + + + , + ) + + const trigger = document.querySelector("[data-slot='sidebar-trigger']") as HTMLButtonElement + expect(trigger).toBeTruthy() + + const sidebar = document.querySelector("[data-slot='sidebar']") + expect(sidebar?.getAttribute("data-state")).toBe("expanded") + + flushSync(() => trigger.click()) + expect(document.querySelector("[data-slot='sidebar']")?.getAttribute("data-state")).toBe( + "collapsed", + ) + }) +}) + +// --------------------------------------------------------------------------- +// SidebarMenuButton — tooltip variants +// --------------------------------------------------------------------------- + +describe("SidebarMenuButton tooltip", () => { + it("renders without tooltip", () => { + render( + + + + No tooltip + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-button']")).toBeTruthy() + }) + + it("renders with string tooltip", () => { + render( + + + + Button + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-button']")).toBeTruthy() + }) + + it("renders with object tooltip", () => { + render( + + + + Button + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-button']")).toBeTruthy() + }) + + it("renders isActive state", () => { + render( + + + + Active + + + , + ) + const el = document.querySelector("[data-slot='sidebar-menu-button']") + expect(el?.getAttribute("data-active")).toBe("true") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarMenuSkeleton +// --------------------------------------------------------------------------- + +describe("SidebarMenuSkeleton", () => { + it("renders without icon by default", () => { + render( + + + + + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-skeleton']")).toBeTruthy() + expect(document.querySelector("[data-sidebar='menu-skeleton-icon']")).toBeNull() + }) + + it("renders with icon when showIcon=true", () => { + render( + + + + + + + , + ) + expect(document.querySelector("[data-sidebar='menu-skeleton-icon']")).toBeTruthy() + expect(document.querySelector("[data-sidebar='menu-skeleton-text']")).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// SidebarGroupLabel — asChild variant +// --------------------------------------------------------------------------- + +describe("SidebarGroupLabel", () => { + it("renders as div by default", () => { + render( + + + Section + + , + ) + const el = document.querySelector("[data-slot='sidebar-group-label']") + expect(el?.tagName.toLowerCase()).toBe("div") + }) + + it("renders as child element when asChild=true", () => { + render( + + + + Label as span + + + , + ) + const el = document.querySelector("[data-slot='sidebar-group-label']") + expect(el?.tagName.toLowerCase()).toBe("span") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarGroupAction — asChild variant +// --------------------------------------------------------------------------- + +describe("SidebarGroupAction", () => { + it("renders as button by default", () => { + render( + + + + + + , + ) + const el = document.querySelector("[data-slot='sidebar-group-action']") + expect(el?.tagName.toLowerCase()).toBe("button") + }) + + it("renders as child element when asChild=true", () => { + render( + + + + Add + + + , + ) + const el = document.querySelector("[data-slot='sidebar-group-action']") + expect(el?.tagName.toLowerCase()).toBe("a") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarMenuAction — showOnHover variant +// --------------------------------------------------------------------------- + +describe("SidebarMenuAction", () => { + it("renders with data-slot", () => { + render( + + + + Item + + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-action']")).toBeTruthy() + }) + + it("renders with showOnHover=true", () => { + render( + + + + Item + + • + + + + , + ) + const el = document.querySelector("[data-slot='sidebar-menu-action']") + expect(el).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// SidebarMenuBadge +// --------------------------------------------------------------------------- + +describe("SidebarMenuBadge", () => { + it("renders with data-slot and badge text", () => { + render( + + + + Item + 5 + + + , + ) + const el = document.querySelector("[data-slot='sidebar-menu-badge']") + expect(el).toBeTruthy() + expect(el!.textContent).toBe("5") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarMenuSub + SidebarMenuSubItem + SidebarMenuSubButton +// --------------------------------------------------------------------------- + +describe("SidebarMenu sub-components", () => { + it("renders sub-menu structure", () => { + render( + + + + Parent + + + Sub item + + + + + , + ) + expect(document.querySelector("[data-slot='sidebar-menu-sub']")).toBeTruthy() + expect(document.querySelector("[data-slot='sidebar-menu-sub-item']")).toBeTruthy() + expect(document.querySelector("[data-slot='sidebar-menu-sub-button']")).toBeTruthy() + }) + + it("sub-button renders with isActive and size props", () => { + render( + + + + Parent + + + + Active small + + + + + + , + ) + const el = document.querySelector("[data-slot='sidebar-menu-sub-button']") + expect(el?.getAttribute("data-active")).toBe("true") + expect(el?.getAttribute("data-size")).toBe("sm") + }) +}) + +// --------------------------------------------------------------------------- +// SidebarGroupContent +// --------------------------------------------------------------------------- + +describe("SidebarGroupContent", () => { + it("renders with data-slot", () => { + render( + + + + Content + + + , + ) + expect(document.querySelector("[data-slot='sidebar-group-content']")).toBeTruthy() + }) +}) + +// --------------------------------------------------------------------------- +// SidebarProvider — controlled open prop +// --------------------------------------------------------------------------- + +describe("SidebarProvider controlled mode", () => { + it("uses controlled open prop", () => { + const onOpenChange = vi.fn() + render( + + + + + + , + ) + + const sidebar = document.querySelector("[data-slot='sidebar']") + expect(sidebar?.getAttribute("data-state")).toBe("collapsed") + + // Click the trigger — should call onOpenChange + const trigger = document.querySelector("[data-slot='sidebar-trigger']") as HTMLButtonElement + flushSync(() => trigger.click()) + expect(onOpenChange).toHaveBeenCalled() + }) +}) diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index aa83295d..59a9391b 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -242,7 +242,7 @@ function Sidebar({
{children}
@@ -307,7 +307,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {