Skip to content

Commit 49c12ae

Browse files
committed
feat(sidebar): dynamic nested menu support
1 parent b4712e3 commit 49c12ae

File tree

4 files changed

+104
-17
lines changed

4 files changed

+104
-17
lines changed

resources/js/components/app-sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const mainNavItems: NavItem[] = [
2121
title: 'Dashboard',
2222
href: dashboard(),
2323
icon: LayoutGrid,
24+
items:[] // optional nested menus
2425
},
2526
];
2627

resources/js/components/nav-main.tsx

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,119 @@ import {
44
SidebarMenu,
55
SidebarMenuButton,
66
SidebarMenuItem,
7+
SidebarMenuSub,
8+
SidebarMenuSubButton,
9+
SidebarMenuSubItem,
10+
useSidebar,
711
} from '@/components/ui/sidebar';
812
import { resolveUrl } from '@/lib/utils';
913
import { type NavItem } from '@/types';
1014
import { Link, usePage } from '@inertiajs/react';
15+
import { ChevronRight } from 'lucide-react';
16+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
17+
import { useState } from 'react';
18+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
1119

1220
export function NavMain({ items = [] }: { items: NavItem[] }) {
1321
const page = usePage();
22+
const [activeMenu, setActiveMenu] = useState<string | null>(page.url);
23+
const { state } = useSidebar();
1424
return (
1525
<SidebarGroup className="px-2 py-0">
1626
<SidebarGroupLabel>Platform</SidebarGroupLabel>
1727
<SidebarMenu>
18-
{items.map((item) => (
19-
<SidebarMenuItem key={item.title}>
20-
<SidebarMenuButton
21-
asChild
22-
isActive={page.url.startsWith(
23-
resolveUrl(item.href),
28+
{items.map((item, index) => {
29+
const hasChildren = item.items && item.items.length > 0;
30+
const menuKey = item.href && item.href !== "#" ? String(item.href) : `#menu-${index}`;
31+
const childMatch = hasChildren && item.items!.some((c) => page.url.startsWith(resolveUrl(c.href)));
32+
const isOpen = hasChildren
33+
? activeMenu === menuKey || childMatch // active parent menu if child menu in active state
34+
: activeMenu === menuKey || page.url.startsWith(resolveUrl(item.href));
35+
const isDropdownOpen = activeMenu === menuKey;
36+
const MenuLabel = (
37+
<>
38+
{item.icon && <item.icon />}
39+
<span>{item.title}</span>
40+
{hasChildren && state === "expanded" && (
41+
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
2442
)}
25-
tooltip={{ children: item.title }}
26-
>
27-
<Link href={item.href} prefetch>
28-
{item.icon && <item.icon />}
29-
<span>{item.title}</span>
30-
</Link>
31-
</SidebarMenuButton>
32-
</SidebarMenuItem>
33-
))}
43+
</>
44+
);
45+
return (
46+
<SidebarMenuItem key={item.title}>
47+
{state === "expanded" && (
48+
<Collapsible open={isOpen} onOpenChange={() => { setActiveMenu(isOpen ? null : menuKey); }} className="group/collapsible">
49+
<CollapsibleTrigger asChild disabled={!hasChildren}>
50+
<SidebarMenuButton asChild isActive={isOpen} tooltip={{ children: item.title }}>
51+
<Link href={item.href} onClick={() => setActiveMenu(menuKey)} preserveState >
52+
{item.icon && <item.icon />}
53+
<span>{item.title}</span>
54+
{hasChildren && (
55+
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
56+
)}
57+
</Link>
58+
</SidebarMenuButton>
59+
</CollapsibleTrigger>
60+
{hasChildren && (
61+
<CollapsibleContent>
62+
<SidebarMenuSub>
63+
{item.items!.map((sub) => {
64+
const subActive = page.url.startsWith(resolveUrl(sub.href));
65+
return (
66+
<SidebarMenuSubItem key={sub.title}>
67+
<SidebarMenuSubButton asChild isActive={subActive}>
68+
<Link href={sub.href} preserveState>
69+
{sub.icon && <sub.icon />}
70+
<span>{sub.title}</span>
71+
</Link>
72+
</SidebarMenuSubButton>
73+
</SidebarMenuSubItem>
74+
);
75+
})}
76+
</SidebarMenuSub>
77+
</CollapsibleContent>
78+
)}
79+
</Collapsible>
80+
)}
81+
{state === "collapsed" && (
82+
<DropdownMenu
83+
open={isDropdownOpen}
84+
onOpenChange={(o) => setActiveMenu(o ? menuKey : null)}
85+
>
86+
<DropdownMenuTrigger asChild>
87+
<SidebarMenuButton
88+
asChild
89+
onMouseEnter={() => hasChildren && setActiveMenu(menuKey)}
90+
tooltip={!hasChildren ? { children: item.title } : undefined}
91+
>
92+
<Link href={item.href !== "#" ? item.href : "#"} preserveState >
93+
{MenuLabel}
94+
</Link>
95+
</SidebarMenuButton>
96+
</DropdownMenuTrigger>
97+
{hasChildren && (
98+
<DropdownMenuContent align="end" side="right" onMouseLeave={() => setActiveMenu(null)} >
99+
{item.items!.map((sub) => {
100+
const subActive = page.url.startsWith(resolveUrl(sub.href));
101+
return (
102+
<SidebarMenuSubItem key={sub.title}>
103+
<SidebarMenuSubButton asChild isActive = {subActive}>
104+
<Link href={sub.href}>
105+
{sub.icon && <sub.icon />}
106+
<span>{sub.title}</span>
107+
</Link>
108+
</SidebarMenuSubButton>
109+
</SidebarMenuSubItem>
110+
);
111+
})}
112+
</DropdownMenuContent>
113+
)}
114+
</DropdownMenu>
115+
)}
116+
</SidebarMenuItem>
117+
);
118+
})}
34119
</SidebarMenu>
35120
</SidebarGroup>
36121
);
37-
}
122+
}

resources/js/components/ui/sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
638638
data-slot="sidebar-menu-sub"
639639
data-sidebar="menu-sub"
640640
className={cn(
641-
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
641+
"border-sidebar-border ml-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l pl-2.5 py-0.5",
642642
"group-data-[collapsible=icon]:hidden",
643643
className
644644
)}

resources/js/types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface NavItem {
2020
href: NonNullable<InertiaLinkProps['href']>;
2121
icon?: LucideIcon | null;
2222
isActive?: boolean;
23+
items?: NavItem[];
2324
}
2425

2526
export interface SharedData {

0 commit comments

Comments
 (0)