From a15fa2ceef91feace69742eda438990403e763cc Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Wed, 25 Feb 2026 15:20:39 +0600 Subject: [PATCH] refactor: replace custom layout with shadcn sidebar primitives Refactor the Layout system to use shadcn Sidebar components instead of custom responsive sidebar, mobile drawer, and flyout implementations. This removes ~1500 lines of custom code and replaces them with standard shadcn primitives while preserving the data-driven LayoutMenu API. Key changes: - Add shadcn sidebar.tsx and sheet.tsx primitives - Layout now wraps SidebarProvider, LayoutSidebar wraps Sidebar - LayoutMenu uses SidebarMenu/SidebarMenuItem/SidebarMenuButton - Add useSidebarOptional for standalone LayoutMenu usage - Add useIsMobile hook with synchronous init (prevents render flash) - Suppress CSS transitions on mount via data-mounted attribute - Use theme-compatible border-sidebar-border colors - Move layout components from ui/ to wordpress/ directory - Rewrite stories with 11 focused examples including sidebar footer Co-Authored-By: Claude Opus 4.6 --- src/components/settings/settings-sidebar.tsx | 2 +- src/components/ui/Layout.stories.tsx | 575 --------------- src/components/ui/index.ts | 47 +- src/components/ui/layout-menu.tsx | 655 ----------------- src/components/ui/layout.tsx | 323 -------- src/components/ui/sheet.tsx | 129 ++++ src/components/ui/sidebar.tsx | 734 +++++++++++++++++++ src/components/wordpress/Layout.stories.tsx | 450 ++++++++++++ src/components/wordpress/layout-menu.tsx | 626 ++++++++++++++++ src/components/wordpress/layout.tsx | 295 ++++++++ src/hooks/use-mobile.ts | 23 + src/index.ts | 37 +- 12 files changed, 2336 insertions(+), 1560 deletions(-) delete mode 100644 src/components/ui/Layout.stories.tsx delete mode 100644 src/components/ui/layout-menu.tsx delete mode 100644 src/components/ui/layout.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/wordpress/Layout.stories.tsx create mode 100644 src/components/wordpress/layout-menu.tsx create mode 100644 src/components/wordpress/layout.tsx create mode 100644 src/hooks/use-mobile.ts diff --git a/src/components/settings/settings-sidebar.tsx b/src/components/settings/settings-sidebar.tsx index c5cdf25..4206ab7 100644 --- a/src/components/settings/settings-sidebar.tsx +++ b/src/components/settings/settings-sidebar.tsx @@ -4,7 +4,7 @@ import { useSettings } from './settings-context'; import { LayoutMenu, type LayoutMenuItemData, -} from '../ui/layout-menu'; +} from '../wordpress/layout-menu'; import type { SettingsElement } from './settings-types'; import * as LucideIcons from 'lucide-react'; import { cn } from '@/lib/utils'; diff --git a/src/components/ui/Layout.stories.tsx b/src/components/ui/Layout.stories.tsx deleted file mode 100644 index 047d48c..0000000 --- a/src/components/ui/Layout.stories.tsx +++ /dev/null @@ -1,575 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { - BarChart3, - FileText, - FolderOpen, - Home, - Package, - Settings, - Users, -} from "lucide-react"; -import { Button } from "./button"; -import { - Layout, - LayoutBody, - LayoutFooter, - LayoutHeader, - LayoutMain, - LayoutMenu, - LayoutSidebar, - type LayoutMenuGroupData, - type LayoutMenuItemData, -} from "./index"; - -const menuDataStructureCode = `// Menu item (supports nested children) -interface LayoutMenuItemData { - id: string; - label: string; - secondaryLabel?: string; // Optional second line - href?: string; // Link URL (use ) - onClick?: () => void; // Click handler for leaf items - children?: LayoutMenuItemData[]; // Nested submenu - icon?: ReactNode; - disabled?: boolean; - className?: string; -} - -// Group (section with label + items) -interface LayoutMenuGroupData { - id: string; - label: string; - secondaryLabel?: string; - items: LayoutMenuItemData[]; - className?: string; -} - -// Usage: pass items (flat) or groups (sections) - console.log('Clicked', item.id)} -/>`; - -const meta = { - title: "UI/Layout", - component: Layout, - parameters: { - layout: "fullscreen", - controls: { - include: [ - "sidebarPosition", - "sidebarVariant", - "sidebarBreakpoint", - "defaultSidebarOpen", - "className", - ], - }, - docs: { - description: { - component: - "Responsive app layout with optional header, footer, and left/right sidebar. " + - "Sidebar is **expandable and collapsible on desktop**: use the header menu button to toggle; when collapsed, the sidebar animates to zero width and main content expands. " + - "On mobile, the sidebar behaves as a drawer. " + - "Sidebar can contain a searchable, multi-label nested menu. " + - "See the **Menu data structure** story for `LayoutMenuItemData` and `LayoutMenuGroupData` types.", - }, - }, - }, - tags: ["autodocs"], - argTypes: { - sidebarPosition: { - control: "select", - options: ["left", "right", null], - description: "Sidebar position: left, right, or null for no sidebar", - }, - sidebarVariant: { - control: "select", - options: ["drawer", "inline", "overlay"], - description: "Sidebar behavior on mobile", - }, - sidebarBreakpoint: { - control: "select", - options: ["sm", "md", "lg", "xl", "2xl"], - description: - "Breakpoint at which sidebar is in-flow (desktop). Above this, sidebar can be expanded/collapsed via the header toggle.", - }, - defaultSidebarOpen: { - control: "boolean", - description: - "Initial open state for sidebar (applies to both mobile and desktop). Use true to show sidebar open on load.", - }, - className: { - control: "text", - description: "Additional CSS classes for the root", - }, - children: { - control: false, - description: "Layout content (Header, Body, Footer)", - }, - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -/** Use the Controls panel to change sidebar position, variant, breakpoint, and default open. */ -export const WithControls: Story = { - args: { - sidebarPosition: "right", - sidebarVariant: "drawer", - sidebarBreakpoint: "lg", - defaultSidebarOpen: true, - className: "bg-background", - }, - render: (args) => { - const sidebar = args.sidebarPosition ? ( - - - - ) : null; - const main = ( - -

- Change props in the Controls panel below. Sidebar position:{" "} - {args.sidebarPosition ?? "none"}. -

-
- ); - return ( - - - Controls demo - - - {args.sidebarPosition === "left" ? ( - <> - {sidebar} - {main} - - ) : ( - <> - {main} - {sidebar} - - )} - - - Footer - - - ); - }, -}; - -/** - * **Expandable and collapsible sidebar (desktop)** - * - * On desktop (at and above `sidebarBreakpoint`), the sidebar can be expanded and collapsed: - * - Use the **menu icon** in the header to toggle the sidebar. - * - When **collapsed**, the sidebar animates to zero width and the main content area expands. - * - When **expanded**, the sidebar shows at its configured width (e.g. `w-72`). - * - * Use `defaultSidebarOpen` to control the initial state (e.g. `true` to start with the sidebar open). - * On mobile, the sidebar remains a drawer that overlays content when open. - */ -export const ExpandableCollapsibleSidebar: Story = { - parameters: { - docs: { - description: { - story: - "Demonstrates the sidebar expand/collapse behavior on desktop. Use the menu icon in the header to toggle. " + - "When collapsed, the sidebar width animates to zero and main content expands. " + - "Resize to mobile to see the drawer behavior.", - }, - }, - }, - args: { - sidebarPosition: "left", - sidebarBreakpoint: "lg", - defaultSidebarOpen: false, - className: "bg-background", - }, - render: (args) => ( - - - Expandable sidebar - - - - - - -
-

Desktop: expandable and collapsible

-

- Click the menu icon in the header to expand or collapse the - sidebar. When collapsed, this content area grows to use the full - width. Use defaultSidebarOpen to start with the - sidebar open. -

-
-
-
- - Footer - -
- ), -}; - -const sampleNestedItems: LayoutMenuItemData[] = [ - { - id: "dashboard", - label: "Dashboard", - secondaryLabel: "Overview", - icon: , - onClick: () => {}, - }, - { - id: "reports", - label: "Reports", - secondaryLabel: "Analytics & exports", - icon: , - children: [ - { - id: "sales", - label: "Sales", - secondaryLabel: "By period", - icon: , - onClick: () => {}, - }, - { - id: "products", - label: "Products", - secondaryLabel: "Inventory", - icon: , - children: [ - { - id: "categories", - label: "Categories", - icon: , - onClick: () => {}, - }, - ], - }, - ], - }, - { - id: "users", - label: "Users", - secondaryLabel: "Manage accounts", - icon: , - onClick: () => {}, - }, - { - id: "settings", - label: "Settings", - secondaryLabel: "App configuration", - icon: , - onClick: () => {}, - }, -]; - -const sampleGroups: LayoutMenuGroupData[] = [ - { - id: "main", - label: "Main", - secondaryLabel: "Primary navigation", - items: sampleNestedItems, - }, - { - id: "tools", - label: "Tools", - items: [ - { - id: "import", - label: "Import", - secondaryLabel: "Bulk import data", - onClick: () => {}, - }, - { - id: "export", - label: "Export", - secondaryLabel: "Download reports", - onClick: () => {}, - }, - ], - }, -]; - -export const FullLayout: Story = { - render: () => { - const sampleNestedItems: LayoutMenuItemData[] = [ - { - id: "dashboard", - label: "Dashboard", - secondaryLabel: "Overview", - icon: , - onClick: () => {}, - }, - { - id: "reports", - label: "Reports", - secondaryLabel: "Analytics & exports", - icon: , - children: [ - { - id: "sales", - label: "Sales", - secondaryLabel: "By period", - icon: , - onClick: () => {}, - }, - { - id: "products", - label: "Products", - secondaryLabel: "Inventory", - icon: , - children: [ - { - id: "categories", - label: "Categories", - icon: , - onClick: () => {}, - }, - ], - }, - ], - }, - { - id: "users", - label: "Users", - secondaryLabel: "Manage accounts", - icon: , - onClick: () => {}, - }, - { - id: "settings", - label: "Settings", - secondaryLabel: "App configuration", - icon: , - onClick: () => {}, - }, - ]; - - const sampleGroups: LayoutMenuGroupData[] = [ - { - id: "main", - label: "Main", - secondaryLabel: "Primary navigation", - items: sampleNestedItems, - }, - { - id: "tools", - label: "Tools", - items: [ - { - id: "import", - label: "Import", - secondaryLabel: "Bulk import data", - onClick: () => {}, - }, - { - id: "export", - label: "Export", - secondaryLabel: "Download reports", - onClick: () => {}, - }, - ], - }, - ]; - return ( - - - App Title - - - - -
-

Main content

-

- This is the main content area. The menu bar is on the right and - is searchable with multi-label nested items. Resize the window - to see the responsive behavior: on small screens the sidebar - becomes a drawer. -

-
-
- - { - // Callback: parent items expand on click; leaf items run item.onClick - console.log("Menu item clicked:", item.id, item.label); - }} - /> - -
- - - © 2025 Example. Footer is optional (nullable). - - -
- ); - }, -}; - -export const LeftSidebar: Story = { - render: () => ( - - - Sidebar on left - - - - - - - -
-

Main content

-

- Sidebar is on the left. Put LayoutSidebar before LayoutMain in - LayoutBody for left position. -

-
-
-
- - © 2025 - -
- ), -}; - -/** Customize hover/focus and active (selected) colors via menuItemClassName and activeItemClassName. */ -export const CustomHoverAndActiveStyles: Story = { - render: () => ( - - - Custom menu colors - - - -

- Hover and active states use menuItemClassName and{" "} - activeItemClassName (e.g. primary colors). -

-
- - - -
-
- ), -}; - -export const NoSidebar: Story = { - render: () => ( - - - No sidebar - - - - -
-

Main content only

-

- Use sidebarPosition={null} for no sidebar. The header - toggle is hidden and LayoutSidebar would render nothing if used. -

-
-
-
- - © 2025 - -
- ), -}; - -export const WithoutFooter: Story = { - render: () => ( - - - No footer - - - -

- When you don’t pass a footer (or pass null), no footer is rendered. -

-
- - - -
-
- ), -}; - -export const FlatMenu: Story = { - render: () => ( - - - Flat menu (no groups) - - - -

- Menu can be a flat list of items instead of groups. -

-
- - - -
-
- ), -}; - -/** Menu data types for `items` and `groups` props. Use with `LayoutMenu`. */ -export const MenuDataStructure: Story = { - parameters: { - docs: { - description: { - story: - "TypeScript interfaces for menu items and groups. Pass `items` (flat array) or `groups` (array of sections) to `LayoutMenu`. Use `onItemClick` for a single callback when any item is clicked (expand still happens on parent click).", - }, - }, - }, - render: () => ( -
-
-        {menuDataStructureCode}
-      
-
- ), -}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 67beabb..8842b12 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -149,6 +149,47 @@ export { // ColorPicker component export { ColorPicker, type ColorPickerProps } from "./color-picker"; +// Sheet component +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} from "./sheet"; + +// Sidebar component +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, + useSidebarOptional, +} from "./sidebar"; + // Skeleton component export { Skeleton } from "./skeleton"; @@ -201,10 +242,8 @@ export { type LayoutMainProps, type LayoutSidebarProps, type LayoutFooterProps, - type LayoutSidebarPosition, - type LayoutSidebarVariant, type LayoutContextValue, -} from "./layout"; +} from "../wordpress/layout"; // Layout menu (searchable, multi-label nested menu for LayoutSidebar) export { @@ -214,4 +253,4 @@ export { type LayoutMenuGroupData, type LayoutMenuProps, type LayoutMenuSearchProps, -} from "./layout-menu"; +} from "../wordpress/layout-menu"; diff --git a/src/components/ui/layout-menu.tsx b/src/components/ui/layout-menu.tsx deleted file mode 100644 index 81f0e14..0000000 --- a/src/components/ui/layout-menu.tsx +++ /dev/null @@ -1,655 +0,0 @@ -import { cn } from "@/lib/utils"; -import { ChevronDown, ChevronRight, Search } from "lucide-react"; -import { - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type HTMLAttributes, - type KeyboardEvent, - type ReactNode, -} from "react"; -import { Input } from "./input"; - -/* ============================================ - Types: multi-label nested menu items - ============================================ */ - -export interface LayoutMenuItemData { - id: string; - label: string; - /** Secondary line (description, badge, etc.) */ - secondaryLabel?: string; - href?: string; - onClick?: () => void; - children?: LayoutMenuItemData[]; - icon?: ReactNode; - disabled?: boolean; - /** Custom className for the item row */ - className?: string; - /** Test ID for e2e selectors — rendered as `data-testid` on the interactive element */ - testId?: string; -} - -export interface LayoutMenuGroupData { - id: string; - label: string; - /** Optional secondary label for the group header */ - secondaryLabel?: string; - items: LayoutMenuItemData[]; - className?: string; -} - -/* ============================================ - Filter nested items by search query - ============================================ */ - -function matchesSearch(item: LayoutMenuItemData, query: string): boolean { - const q = query.trim().toLowerCase(); - if (!q) return true; - const label = item.label.toLowerCase(); - const secondary = (item.secondaryLabel ?? "").toLowerCase(); - if (label.includes(q) || secondary.includes(q)) return true; - if (item.children?.some((c) => matchesSearch(c, q))) return true; - return false; -} - -function filterMenuItems( - items: LayoutMenuItemData[], - query: string -): LayoutMenuItemData[] { - const q = query.trim().toLowerCase(); - if (!q) return items; - return items - .map((item) => { - const childMatch = item.children?.length - ? filterMenuItems(item.children, query) - : []; - const selfMatch = matchesSearch(item, query); - if (selfMatch) return item; - if (childMatch.length > 0) return { ...item, children: childMatch }; - return null; - }) - .filter(Boolean) as LayoutMenuItemData[]; -} - -function filterGroups( - groups: LayoutMenuGroupData[], - query: string -): LayoutMenuGroupData[] { - const q = query.trim().toLowerCase(); - if (!q) return groups; - return groups - .map((grp) => ({ - ...grp, - items: filterMenuItems(grp.items, query), - })) - .filter((grp) => grp.items.length > 0); -} - -/* ============================================ - LayoutMenuSearch - ============================================ */ - -export interface LayoutMenuSearchProps - extends Omit, "onChange"> { - value?: string; - onChange?: (value: string) => void; - placeholder?: string; - className?: string; - inputClassName?: string; -} - -export const LayoutMenuSearch = forwardRef< - HTMLDivElement, - LayoutMenuSearchProps ->( - ( - { - value, - onChange, - placeholder = "Search menu…", - className, - inputClassName, - ...props - }, - ref - ) => { - return ( -
-
- - onChange?.(e.target.value)} - placeholder={placeholder} - className={cn("h-8 pl-8", inputClassName)} - aria-label="Search menu" - /> -
-
- ); - } -); - -LayoutMenuSearch.displayName = "LayoutMenuSearch"; - -/* ============================================ - LayoutMenu (search + list container) - ============================================ */ - -export interface LayoutMenuProps extends HTMLAttributes { - /** Flat list of items (no groups) */ - items?: LayoutMenuItemData[]; - /** Grouped items (sections with labels); takes precedence over items */ - groups?: LayoutMenuGroupData[]; - /** Search is shown when searchable is true */ - searchable?: boolean; - searchPlaceholder?: string; - /** Id of the currently selected/active item (e.g. current page). Item is styled and has aria-current. */ - activeItemId?: string | null; - /** Class applied to every menu item row. Use to customize hover/focus, e.g. "hover:bg-primary/10 focus-visible:ring-primary". */ - menuItemClassName?: string; - /** Class applied to the active (selected) item. Use to customize active state, e.g. "bg-primary text-primary-foreground". */ - activeItemClassName?: string; - /** Called when any menu item row is clicked (parent or leaf). Parent click still toggles expand. */ - onItemClick?: (item: LayoutMenuItemData) => void; - /** Custom render for each item */ - renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; - /** Custom render for group header */ - renderGroupLabel?: (group: LayoutMenuGroupData) => ReactNode; - className?: string; -} - -export const LayoutMenu = forwardRef( - ( - { - items = [], - groups, - searchable = true, - searchPlaceholder, - activeItemId, - menuItemClassName, - activeItemClassName, - onItemClick, - renderItem, - renderGroupLabel, - className, - ...props - }, - ref - ) => { - const [search, setSearch] = useState(""); - const menuContainerRef = useRef(null); - - const filteredItems = useMemo( - () => (items ? filterMenuItems(items, search) : []), - [items, search] - ); - const filteredGroups = useMemo( - () => (groups ? filterGroups(groups, search) : []), - [groups, search] - ); - - const handleMenuKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.getAttribute("role") !== "menuitem") return; - const container = menuContainerRef.current; - if (!container) return; - - const items = Array.from( - container.querySelectorAll('[role="menuitem"]') - ); - const currentIndex = items.indexOf(target); - if (currentIndex === -1) return; - - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - if (currentIndex < items.length - 1) { - items[currentIndex + 1].focus(); - } - break; - case "ArrowUp": - e.preventDefault(); - if (currentIndex > 0) { - items[currentIndex - 1].focus(); - } - break; - case "ArrowRight": { - e.preventDefault(); - const hasPopup = target.getAttribute("aria-haspopup") === "true"; - const expanded = target.getAttribute("aria-expanded") === "true"; - if (hasPopup && !expanded) { - (target as HTMLButtonElement).click(); - setTimeout(() => { - const submenu = - target.nextElementSibling?.querySelector( - '[role="menuitem"]' - ); - submenu?.focus(); - }, 0); - } else if (hasPopup && expanded) { - const firstChild = - target.nextElementSibling?.querySelector( - '[role="menuitem"]' - ); - firstChild?.focus(); - } - break; - } - case "ArrowLeft": { - e.preventDefault(); - const currentUl = target.closest('ul[role="menu"]'); - const parentLi = currentUl?.parentElement; - if (parentLi) { - const parentMenuitem = parentLi.querySelector( - '[role="menuitem"]' - ); - if (parentMenuitem && parentMenuitem !== target) { - parentMenuitem.focus(); - const parentExpanded = - parentMenuitem.getAttribute("aria-expanded") === "true"; - if (parentExpanded) { - (parentMenuitem as HTMLButtonElement).click(); - } - } - } - break; - } - case "Home": - e.preventDefault(); - items[0]?.focus(); - break; - case "End": - e.preventDefault(); - items[items.length - 1]?.focus(); - break; - default: - break; - } - }, - [] - ); - - return ( -
- {searchable && ( - - )} -
- {filteredGroups.length > 0 - ? filteredGroups.map((group) => ( - - )) - : filteredItems.length > 0 && ( - - )} - {filteredGroups.length === 0 && - filteredItems.length === 0 && - search.trim() && ( -
- No results for "{search}" -
- )} -
-
- ); - } -); - -LayoutMenu.displayName = "LayoutMenu"; - -/* ============================================ - LayoutMenuGroup - ============================================ */ - -interface LayoutMenuGroupInternalProps { - group: LayoutMenuGroupData; - activeItemId?: string | null; - menuItemClassName?: string; - activeItemClassName?: string; - onItemClick?: (item: LayoutMenuItemData) => void; - renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; - renderGroupLabel?: (group: LayoutMenuGroupData) => ReactNode; -} - -function LayoutMenuGroup({ - group, - activeItemId, - menuItemClassName, - activeItemClassName, - onItemClick, - renderItem, - renderGroupLabel, -}: LayoutMenuGroupInternalProps) { - return ( -
-
- {renderGroupLabel ? ( - renderGroupLabel(group) - ) : ( - <> - {group.label} - {group.secondaryLabel && ( - - {group.secondaryLabel} - - )} - - )} -
- -
- ); -} - -/* ============================================ - LayoutMenuItemList (recursive) - ============================================ */ - -interface LayoutMenuItemListProps { - items: LayoutMenuItemData[]; - depth: number; - activeItemId?: string | null; - menuItemClassName?: string; - activeItemClassName?: string; - onItemClick?: (item: LayoutMenuItemData) => void; - renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; -} - -function LayoutMenuItemList({ - items, - depth, - activeItemId, - menuItemClassName, - activeItemClassName, - onItemClick, - renderItem, -}: LayoutMenuItemListProps) { - return ( -
    - {items.map((item) => ( - - ))} -
- ); -} - -/* ============================================ - LayoutMenuItemNode (single item or expandable parent) - ============================================ */ - -interface LayoutMenuItemNodeProps { - item: LayoutMenuItemData; - depth: number; - activeItemId?: string | null; - menuItemClassName?: string; - activeItemClassName?: string; - onItemClick?: (item: LayoutMenuItemData) => void; - renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; -} - -/** - * Check if any descendant of an item matches the active ID. - * Used to auto-expand parent items that contain the active selection. - */ -function hasActiveDescendant( - item: LayoutMenuItemData, - activeId: string | null | undefined -): boolean { - if (!activeId || !item.children) return false; - return item.children.some( - (child) => - child.id === activeId || hasActiveDescendant(child, activeId) - ); -} - -function LayoutMenuItemNode({ - item, - depth, - activeItemId, - menuItemClassName, - activeItemClassName, - onItemClick, - renderItem, -}: LayoutMenuItemNodeProps) { - const containsActive = useMemo( - () => hasActiveDescendant(item, activeItemId), - [item, activeItemId] - ); - const [open, setOpen] = useState(containsActive); - const hasChildren = item.children && item.children.length > 0; - - // Auto-expand when a descendant becomes active (e.g. programmatic navigation) - useEffect(() => { - if (containsActive) setOpen(true); - }, [containsActive]); - - const handleToggle = useCallback(() => { - setOpen((o) => !o); - }, []); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - if (hasChildren) { - setOpen((o) => !o); - onItemClick?.(item); - } else { - item.onClick?.(); - onItemClick?.(item); - } - } - // ArrowRight / ArrowLeft are handled by the menu container for focus movement - }, - [hasChildren, item, onItemClick] - ); - - const isActive = activeItemId != null && item.id === activeItemId; - - const content = renderItem ? ( - renderItem(item, depth) - ) : ( - - ); - - return ( -
  • - {content} - {hasChildren && open && ( - - )} -
  • - ); -} - -/* ============================================ - LayoutMenuItemRow (default row: icon, labels, chevron) - ============================================ */ - -interface LayoutMenuItemRowProps { - item: LayoutMenuItemData; - depth: number; - hasChildren: boolean; - open: boolean; - isActive: boolean; - menuItemClassName?: string; - activeItemClassName?: string; - onToggle: () => void; - onItemClick?: (item: LayoutMenuItemData) => void; - onKeyDown: (e: KeyboardEvent) => void; -} - -function LayoutMenuItemRow({ - item, - depth, - hasChildren, - open, - isActive, - menuItemClassName, - activeItemClassName, - onToggle, - onItemClick, - onKeyDown, -}: LayoutMenuItemRowProps) { - const paddingLeft = 12 + depth * 12; - - const handleRowClick = useCallback(() => { - if (hasChildren) { - onToggle(); - } else { - item.onClick?.(); - } - onItemClick?.(item); - }, [hasChildren, onToggle, item, onItemClick]); - - const Comp = item.href && !hasChildren ? "a" : "button"; - const compProps = - item.href && !hasChildren - ? { - href: item.href, - onClick: () => onItemClick?.(item), - } - : { - type: "button" as const, - onClick: handleRowClick, - }; - - return ( - - {item.icon && ( - {item.icon} - )} - - {item.label} - {item.secondaryLabel && ( - - {item.secondaryLabel} - - )} - - {hasChildren && ( - - {open ? ( - - ) : ( - - )} - - )} - - ); -} diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx deleted file mode 100644 index 4463921..0000000 --- a/src/components/ui/layout.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Menu, X } from "lucide-react"; -import { - createContext, - forwardRef, - useContext, - useState, - type HTMLAttributes, - type ReactNode, -} from "react"; -import { Button } from "./button"; - -/* ============================================ - Layout context (sidebar open state, breakpoint) - ============================================ */ - -export type LayoutSidebarVariant = "drawer" | "inline" | "overlay"; - -/** Sidebar position: "left" | "right" | null (no sidebar) */ -export type LayoutSidebarPosition = "left" | "right" | null; - -export interface LayoutContextValue { - sidebarOpen: boolean; - setSidebarOpen: React.Dispatch>; - sidebarVariant: LayoutSidebarVariant; - sidebarBreakpoint: string; - sidebarPosition: LayoutSidebarPosition; - isMobile: boolean; -} - -const LayoutContext = createContext(null); - -function useLayout() { - const ctx = useContext(LayoutContext); - if (!ctx) throw new Error("Layout subcomponents must be used within Layout."); - return ctx; -} - -/* ============================================ - Layout Root - ============================================ */ - -export interface LayoutProps extends HTMLAttributes { - children?: ReactNode; - className?: string; - /** Sidebar position: "left", "right", or null for no sidebar */ - sidebarPosition?: LayoutSidebarPosition; - /** Sidebar behavior: drawer (slides in), inline (always visible on desktop), overlay (over content) */ - sidebarVariant?: LayoutSidebarVariant; - /** Tailwind breakpoint at which sidebar is always visible, e.g. "lg", "md" */ - sidebarBreakpoint?: string; - /** Initial open state for mobile sidebar */ - defaultSidebarOpen?: boolean; -} - -export const Layout = forwardRef( - ( - { - className, - sidebarPosition = "right", - sidebarVariant = "drawer", - sidebarBreakpoint = "lg", - defaultSidebarOpen = false, - children, - ...props - }, - ref - ) => { - const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); - const value: LayoutContextValue = { - sidebarOpen, - setSidebarOpen, - sidebarVariant, - sidebarBreakpoint, - sidebarPosition: sidebarPosition ?? null, - isMobile: true, // will be set by CSS/JS or consumer can override via provider - }; - return ( - -
    - {children} -
    -
    - ); - } -); - -Layout.displayName = "Layout"; - -/* ============================================ - Layout Header - ============================================ */ - -export interface LayoutHeaderProps extends HTMLAttributes { - children?: ReactNode; - className?: string; - /** Show a menu button that toggles the sidebar on small screens */ - showSidebarToggle?: boolean; -} - -export const LayoutHeader = forwardRef( - ({ className, children, showSidebarToggle = true, ...props }, ref) => { - const { setSidebarOpen, sidebarPosition } = useLayout(); - const hasSidebar = - sidebarPosition === "left" || sidebarPosition === "right"; - return ( -
    - {hasSidebar && showSidebarToggle && ( - - )} - {children} -
    - ); - } -); - -LayoutHeader.displayName = "LayoutHeader"; - -/* ============================================ - Layout Body (header + main + sidebar + footer wrapper) - ============================================ */ - -export interface LayoutBodyProps extends HTMLAttributes { - children?: ReactNode; - className?: string; -} - -export const LayoutBody = forwardRef( - ({ className, children, ...props }, ref) => { - return ( -
    - {children} -
    - ); - } -); - -LayoutBody.displayName = "LayoutBody"; - -/* ============================================ - Layout Main (content area) - ============================================ */ - -export interface LayoutMainProps extends HTMLAttributes { - children?: ReactNode; - className?: string; -} - -export const LayoutMain = forwardRef( - ({ className, children, ...props }, ref) => { - return ( -
    - {children} -
    - ); - } -); - -LayoutMain.displayName = "LayoutMain"; - -/* ============================================ - Layout Sidebar (left or right; nullable when Layout has sidebarPosition={null}) - ============================================ */ - -export interface LayoutSidebarProps extends HTMLAttributes { - children?: ReactNode; - className?: string; - /** Width when open, e.g. "w-72", "16rem" */ - width?: string; -} - -export const LayoutSidebar = forwardRef( - ({ className, children, width = "w-72", ...props }, ref) => { - const { - sidebarOpen, - setSidebarOpen, - sidebarBreakpoint, - sidebarPosition, - } = useLayout(); - - if (sidebarPosition !== "left" && sidebarPosition !== "right") { - return null; - } - - const isLeft = sidebarPosition === "left"; - const bp = sidebarBreakpoint; - const breakpointClass = - bp === "lg" ? "lg:flex" : bp === "md" ? "md:flex" : "xl:flex"; - const collapseWhenClosed = - bp === "lg" - ? "lg:w-0 lg:min-w-0 lg:overflow-hidden" - : bp === "md" - ? "md:w-0 md:min-w-0 md:overflow-hidden" - : "xl:w-0 xl:min-w-0 xl:overflow-hidden"; - const desktopTransition = - bp === "lg" - ? "lg:transition-[width] lg:duration-200" - : bp === "md" - ? "md:transition-[width] md:duration-200" - : "xl:transition-[width] xl:duration-200"; - - return ( - <> - {/* Backdrop on mobile: fades in/out, closes sidebar on click when visible */} -
    setSidebarOpen(false)} - /> - - - ); - } -); - -LayoutSidebar.displayName = "LayoutSidebar"; - -/* ============================================ - Layout Footer (nullable) - ============================================ */ - -export interface LayoutFooterProps extends HTMLAttributes { - children?: ReactNode; - className?: string; -} - -export const LayoutFooter = forwardRef( - ({ className, children, ...props }, ref) => { - if (children == null || children === undefined) return null; - return ( -
    - {children} -
    - ); - } -); - -LayoutFooter.displayName = "LayoutFooter"; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..8f4253b --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..327c7ea --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,734 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +/** Returns sidebar context or null when used outside a SidebarProvider. */ +function useSidebarOptional() { + return React.useContext(SidebarContext) +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + // Delay enables CSS transitions only after the first paint, + // preventing the sidebar from visibly animating on mount. + requestAnimationFrame(() => setMounted(true)) + }, []) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
    + {children} +
    +
    + ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
    + {children} +
    + ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
    {children}
    +
    +
    + ) + } + + return ( +
    + {/* This is what handles the sidebar gap on desktop */} +
    + +
    + ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + + +

    + Place LayoutHeader inside LayoutMain so it + only spans the content area, not the sidebar. +

    + + + + ), +}; + +/** Sidebar with header and footer sections. */ +export const SidebarWithFooter: Story = { + render: () => ( + + + + +
    +
    + A +
    +
    + Acme Inc. + Enterprise +
    +
    +
    + + +
    +
    + JD +
    +
    + John Doe + john@acme.com +
    +
    +
    +
    + + + Sidebar header & footer + +

    + Use SidebarHeader and SidebarFooter inside{" "} + LayoutSidebar to add branding at the top and user info at the bottom. + These sections stay fixed while the menu scrolls. +

    +
    +
    +
    + ), +}; + +/** + * Toggle the sidebar from external code using WordPress hooks. + * + * ```ts + * import { doAction } from "@wordpress/hooks"; + * doAction("myapp_layout_toggle"); + * ``` + */ +export const WordPressHooks: Story = { + render: () => ( +
    +
    + +
    + + + + + + + + + WordPress hooks + +

    + The button above lives outside the Layout tree but toggles the sidebar + via doAction("myapp_layout_toggle"). Set the namespace prop + on Layout to control the hook name. +

    +
    +
    +
    +
    + ), +}; diff --git a/src/components/wordpress/layout-menu.tsx b/src/components/wordpress/layout-menu.tsx new file mode 100644 index 0000000..f5f357c --- /dev/null +++ b/src/components/wordpress/layout-menu.tsx @@ -0,0 +1,626 @@ +import { cn } from "@/lib/utils"; +import { ChevronRight, Search } from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useState, + type HTMLAttributes, + type ReactNode, +} from "react"; +import { Input } from "../ui/input"; +import { + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + useSidebarOptional, +} from "../ui/sidebar"; +import { Collapsible } from "@base-ui/react/collapsible"; + +/* ============================================ + Sidebar provider detection + ============================================ */ + +/** + * Wraps children in SidebarProvider if one isn't already present. + * This allows LayoutMenu to be used standalone (e.g. in Settings sidebar). + */ +function EnsureSidebarProvider({ children }: { children: ReactNode }) { + const sidebar = useSidebarOptional(); + if (sidebar) { + return <>{children}; + } + return {children}; +} + +/* ============================================ + Types: multi-label nested menu items + ============================================ */ + +export interface LayoutMenuItemData { + id: string; + label: string; + /** Secondary line (description, badge, etc.) */ + secondaryLabel?: string; + href?: string; + onClick?: () => void; + children?: LayoutMenuItemData[]; + icon?: ReactNode; + disabled?: boolean; + /** Custom className for the item row */ + className?: string; + /** Test ID for e2e selectors — rendered as `data-testid` on the interactive element */ + testId?: string; +} + +export interface LayoutMenuGroupData { + id: string; + label: string; + /** Optional secondary label for the group header */ + secondaryLabel?: string; + items: LayoutMenuItemData[]; + className?: string; +} + +/* ============================================ + Filter nested items by search query + ============================================ */ + +function matchesSearch(item: LayoutMenuItemData, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const label = item.label.toLowerCase(); + const secondary = (item.secondaryLabel ?? "").toLowerCase(); + if (label.includes(q) || secondary.includes(q)) return true; + if (item.children?.some((c) => matchesSearch(c, q))) return true; + return false; +} + +function filterMenuItems( + items: LayoutMenuItemData[], + query: string +): LayoutMenuItemData[] { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items + .map((item) => { + const childMatch = item.children?.length + ? filterMenuItems(item.children, query) + : []; + const selfMatch = matchesSearch(item, query); + if (selfMatch) return item; + if (childMatch.length > 0) return { ...item, children: childMatch }; + return null; + }) + .filter(Boolean) as LayoutMenuItemData[]; +} + +function filterGroups( + groups: LayoutMenuGroupData[], + query: string +): LayoutMenuGroupData[] { + const q = query.trim().toLowerCase(); + if (!q) return groups; + return groups + .map((grp) => ({ + ...grp, + items: filterMenuItems(grp.items, query), + })) + .filter((grp) => grp.items.length > 0); +} + +/* ============================================ + LayoutMenuSearch + ============================================ */ + +export interface LayoutMenuSearchProps + extends Omit, "onChange"> { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + className?: string; + inputClassName?: string; +} + +export const LayoutMenuSearch = forwardRef< + HTMLDivElement, + LayoutMenuSearchProps +>( + ( + { + value, + onChange, + placeholder = "Search menu…", + className, + inputClassName, + ...props + }, + ref + ) => { + return ( +
    +
    + + onChange?.(e.target.value)} + placeholder={placeholder} + className={cn("h-8 pl-8", inputClassName)} + aria-label="Search menu" + /> +
    +
    + ); + } +); + +LayoutMenuSearch.displayName = "LayoutMenuSearch"; + +/* ============================================ + LayoutMenu (search + list container) + ============================================ */ + +export interface LayoutMenuProps extends HTMLAttributes { + /** Flat list of items (no groups) */ + items?: LayoutMenuItemData[]; + /** Grouped items (sections with labels); takes precedence over items */ + groups?: LayoutMenuGroupData[]; + /** Search is shown when searchable is true */ + searchable?: boolean; + searchPlaceholder?: string; + /** Id of the currently selected/active item (e.g. current page). Item is styled and has aria-current. */ + activeItemId?: string | null; + /** Class applied to every menu item row. */ + menuItemClassName?: string; + /** Class applied to the active (selected) item. */ + activeItemClassName?: string; + /** Called when any menu item row is clicked (parent or leaf). Parent click still toggles expand. */ + onItemClick?: (item: LayoutMenuItemData) => void; + /** Custom render for each item */ + renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; + /** Custom render for group header */ + renderGroupLabel?: (group: LayoutMenuGroupData) => ReactNode; + className?: string; +} + +export const LayoutMenu = forwardRef( + ( + { + items = [], + groups, + searchable = false, + searchPlaceholder, + activeItemId, + menuItemClassName, + activeItemClassName, + onItemClick, + renderItem, + renderGroupLabel, + className, + ...props + }, + ref + ) => { + const [search, setSearch] = useState(""); + + const filteredItems = useMemo( + () => (items ? filterMenuItems(items, search) : []), + [items, search] + ); + const filteredGroups = useMemo( + () => (groups ? filterGroups(groups, search) : []), + [groups, search] + ); + + return ( + + + {searchable && ( + + + + + + )} + + {filteredGroups.length > 0 + ? filteredGroups.map((group) => ( + + + {renderGroupLabel ? ( + renderGroupLabel(group) + ) : ( + <> + {group.label} + {group.secondaryLabel && ( + + {group.secondaryLabel} + + )} + + )} + + + + {group.items.map((item) => ( + + ))} + + + + )) + : filteredItems.length > 0 && ( + + + + {filteredItems.map((item) => ( + + ))} + + + + )} + + {filteredGroups.length === 0 && + filteredItems.length === 0 && + search.trim() && ( +
    + No results for "{search}" +
    + )} +
    +
    + ); + } +); + +LayoutMenu.displayName = "LayoutMenu"; + +/* ============================================ + MenuItemRenderer — recursive renderer for items + ============================================ */ + +/** + * Check if any descendant of an item matches the active ID. + * Used to auto-expand parent items that contain the active selection. + */ +function hasActiveDescendant( + item: LayoutMenuItemData, + activeId: string | null | undefined +): boolean { + if (!activeId || !item.children) return false; + return item.children.some( + (child) => + child.id === activeId || hasActiveDescendant(child, activeId) + ); +} + +interface MenuItemRendererProps { + item: LayoutMenuItemData; + depth: number; + activeItemId?: string | null; + menuItemClassName?: string; + activeItemClassName?: string; + onItemClick?: (item: LayoutMenuItemData) => void; + renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode; +} + +function MenuItemRenderer({ + item, + depth, + activeItemId, + menuItemClassName, + activeItemClassName, + onItemClick, + renderItem, +}: MenuItemRendererProps) { + const hasChildren = item.children && item.children.length > 0; + const isActive = activeItemId != null && item.id === activeItemId; + const containsActive = useMemo( + () => hasActiveDescendant(item, activeItemId), + [item, activeItemId] + ); + const [open, setOpen] = useState(containsActive); + + // Auto-expand when a descendant becomes active + useEffect(() => { + if (containsActive) setOpen(true); + }, [containsActive]); + + const handleClick = useCallback(() => { + if (hasChildren) { + setOpen((o) => !o); + } else { + item.onClick?.(); + } + onItemClick?.(item); + }, [hasChildren, item, onItemClick]); + + // Custom render + if (renderItem) { + return ( + + {renderItem(item, depth)} + + ); + } + + // Leaf item (no children) at depth 0 + if (!hasChildren && depth === 0) { + const comp = item.href ? "a" : "button"; + return ( + + + : undefined + } + tooltip={item.label} + isActive={isActive} + onClick={handleClick} + className={cn( + menuItemClassName, + isActive && activeItemClassName, + item.className, + )} + data-active={isActive || undefined} + disabled={item.disabled} + > + {item.icon && ( + {item.icon} + )} + + {item.label} + {item.secondaryLabel && ( + + {item.secondaryLabel} + + )} + + + + ); + } + + // Parent item with children at depth 0 + if (hasChildren && depth === 0) { + return ( + + + onItemClick?.(item)} + className={cn( + menuItemClassName, + isActive && activeItemClassName, + item.className, + )} + data-active={isActive || undefined} + disabled={item.disabled} + /> + } + > + {item.icon && ( + {item.icon} + )} + + {item.label} + {item.secondaryLabel && ( + + {item.secondaryLabel} + + )} + + + + + } + > + {item.children!.map((child) => ( + + ))} + + + + ); + } + + // Items at depth > 0 — rendered by SubMenuItemRenderer + return null; +} + +/* ============================================ + SubMenuItemRenderer — renders children inside SidebarMenuSub + ============================================ */ + +function SubMenuItemRenderer({ + item, + depth, + activeItemId, + menuItemClassName, + activeItemClassName, + onItemClick, + renderItem, +}: MenuItemRendererProps) { + const hasChildren = item.children && item.children.length > 0; + const isActive = activeItemId != null && item.id === activeItemId; + const containsActive = useMemo( + () => hasActiveDescendant(item, activeItemId), + [item, activeItemId] + ); + const [open, setOpen] = useState(containsActive); + + useEffect(() => { + if (containsActive) setOpen(true); + }, [containsActive]); + + const handleClick = useCallback(() => { + if (hasChildren) { + setOpen((o) => !o); + } else { + item.onClick?.(); + } + onItemClick?.(item); + }, [hasChildren, item, onItemClick]); + + if (renderItem) { + return ( + + {renderItem(item, depth)} + + ); + } + + // Leaf sub-item + if (!hasChildren) { + const comp = item.href ? "a" : "button"; + return ( + + + :