From ff1e129c4f4007af2513550c51104006d15921a7 Mon Sep 17 00:00:00 2001 From: Boram Yi Date: Thu, 11 Jun 2026 14:54:31 -0400 Subject: [PATCH 1/3] feat(ui): extend elevation scale to 0-7 mirroring Tailwind shadows, adopt across components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-value the elevation tokens to mirror the Tailwind default shadow scale 1:1 (2xs→1, xs→2, sm→3, md→4, lg→5, xl→6, 2xl→7), adding elevation-6 and elevation-7. Migrating a component from shadow-* to shadow-elevation-* is therefore visually a no-op in light mode. Replace all 25 production Tailwind shadow usages with the matching shadow-elevation-* utility; Dialog moves from elevation-3 (old MD3 value) to elevation-5 to keep overlay-tier weight alongside Sheet. Dark mode: same geometry with alphas tripled (.05→.15, .1→.3, .25→.5) so elevations stay legible on dark surfaces — previously only Dialog adapted; now every shadowed component does. Co-Authored-By: Claude Opus 4.8 --- src/components/ElevationAndShape.stories.tsx | 12 ++++--- src/components/ai/confirmation.tsx | 2 +- src/components/ai/queue.tsx | 2 +- .../PlateMapEditor/PlatePaintGrid.tsx | 2 +- src/components/ui/combobox.tsx | 2 +- src/components/ui/context-menu.tsx | 4 +-- .../data-table/data-table-column-toggle.tsx | 4 +-- .../ui/data-table/data-table-filter.tsx | 2 +- .../ui/data-table/data-table-group.tsx | 2 +- src/components/ui/data-table/data-table.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 4 +-- src/components/ui/hover-card.tsx | 2 +- src/components/ui/menubar.tsx | 4 +-- src/components/ui/navigation-menu.tsx | 2 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/sidebar.tsx | 4 +-- src/components/ui/tabs.tsx | 4 +-- src/index.tailwind.css | 35 ++++++++++++------- 21 files changed, 55 insertions(+), 42 deletions(-) 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 ebab68ee..95167d4a 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.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/sidebar.stories.tsx b/src/components/ui/sidebar.stories.tsx index 8efc2917..cd37f329 100644 --- a/src/components/ui/sidebar.stories.tsx +++ b/src/components/ui/sidebar.stories.tsx @@ -8,6 +8,7 @@ import { StarIcon, UsersIcon, } from "lucide-react" +import React from "react" import { expect, userEvent, within } from "storybook/test" import { @@ -735,3 +736,155 @@ export const SmallMenuButtons: Story = { }) }, } + +export const WithShowOnHover: Story = { + render: (args) => ( +
+ + + + + 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") + }) + }, +} From c98ab4a6d025400cdf3a7c71c7df673c5673a5f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:12:40 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20add=20unit=20tests=20to=20reach=20?= =?UTF-8?q?=E2=89=A595%=20statement=20coverage=20on=20context-menu,=20drop?= =?UTF-8?q?down-menu,=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/context-menu.test.tsx | 434 +++++++++++++++++++ src/components/ui/dropdown-menu.test.tsx | 382 ++++++++++++++++ src/components/ui/sidebar.test.tsx | 528 +++++++++++++++++++++++ 3 files changed, 1344 insertions(+) create mode 100644 src/components/ui/context-menu.test.tsx create mode 100644 src/components/ui/dropdown-menu.test.tsx create mode 100644 src/components/ui/sidebar.test.tsx 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/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/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() + }) +})