Skip to content

Commit b9eb7bd

Browse files
Merge pull request #5 from guardiafinance/feature/add-expandable-navbar-menu-item
feat: add functionality to expand navbar menu items
2 parents 1e06a64 + f3c7a0d commit b9eb7bd

13 files changed

Lines changed: 3270 additions & 79 deletions

File tree

README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,11 @@ import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem }
187187
```
188188

189189
#### **Navbar**
190-
Advanced sidebar navigation with dynamic menu sections and user management.
190+
Advanced sidebar navigation with dynamic menu sections, expandable menu items, and user management.
191191

192192
```tsx
193193
import { Navbar, NavbarProvider } from '@guardiafinance/design-system'
194-
import { Home, Settings, User, BarChart3 } from 'lucide-react'
194+
import { Home, Settings, User, BarChart3, FileText, Database, Code } from 'lucide-react'
195195

196196
const navbarSettings = {
197197
organization: {
@@ -208,7 +208,16 @@ const navbarSettings = {
208208
label: "Overview",
209209
items: [
210210
{ title: "Analytics", icon: BarChart3, path: "/dashboard/analytics" },
211-
{ title: "Reports", icon: BarChart3, path: "/dashboard/reports" }
211+
{ title: "Reports", icon: BarChart3, path: "/dashboard/reports" },
212+
{
213+
title: "Data Management",
214+
icon: Database,
215+
children: [
216+
{ title: "Import Data", icon: FileText, path: "/dashboard/import" },
217+
{ title: "Export Data", icon: FileText, path: "/dashboard/export" },
218+
{ title: "Data Sources", icon: Code, path: "/dashboard/sources" }
219+
]
220+
}
212221
]
213222
}
214223
]
@@ -243,6 +252,29 @@ const navbarSettings = {
243252
</NavbarProvider>
244253
```
245254

255+
**Expandable Menu Items:**
256+
Create nested navigation structures by defining menu items with a `children` array instead of a `path`. These items expand/collapse on click to reveal their child items:
257+
258+
```tsx
259+
{
260+
title: "Data Management",
261+
icon: Database,
262+
children: [
263+
{ title: "Import Data", icon: FileText, path: "/dashboard/import" },
264+
{ title: "Export Data", icon: FileText, path: "/dashboard/export" },
265+
{ title: "Data Sources", icon: Code, path: "/dashboard/sources" }
266+
]
267+
}
268+
```
269+
270+
**Features:**
271+
- Expandable items show a chevron icon that rotates when expanded
272+
- Child items are visually indented with a left border
273+
- Automatically expands when a child route is active
274+
- Hidden when sidebar is collapsed to save space
275+
- Supports `disabled` state on both parent and child items
276+
- Child items support badges and all standard menu item properties
277+
246278
### Form Controls
247279

248280
#### **Button**

package-lock.json

Lines changed: 26 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@guardiafinance/design-system",
3-
"version": "0.0.6",
3+
"version": "0.0.7",
44
"type": "module",
55
"exports": {
66
".": {
@@ -48,6 +48,7 @@
4848
"@radix-ui/react-toggle-group": "^1.1.11",
4949
"@radix-ui/react-tooltip": "^1.2.8",
5050
"@tanstack/react-table": "^8.21.3",
51+
"@types/react-router": "^5.1.20",
5152
"class-variance-authority": "^0.7.1",
5253
"clsx": "^2.1.1",
5354
"input-otp": "^1.4.2",
@@ -59,16 +60,17 @@
5960
"sonner": "^2.0.7",
6061
"tailwind-merge": "^3.3.1",
6162
"tailwindcss-animate": "^1.0.7",
63+
"ts-pattern": "^5.9.0",
6264
"vaul": "^1.1.2"
6365
},
6466
"devDependencies": {
65-
"@rsbuild/plugin-react": "^1.4.0",
66-
"@rslib/core": "^0.14.0",
67-
"@types/react": "^19.1.13",
6867
"@rsbuild/core": "^1.5.11",
68+
"@rsbuild/plugin-react": "^1.4.0",
6969
"@rsbuild/plugin-svgr": "^1.2.2",
70+
"@rslib/core": "^0.14.0",
7071
"@tailwindcss/typography": "^0.5.16",
7172
"@types/node": "^24.5.2",
73+
"@types/react": "^19.1.13",
7274
"@types/react-dom": "^19.1.9",
7375
"autoprefixer": "^10.4.19",
7476
"postcss": "^8.5.6",
@@ -85,4 +87,4 @@
8587
"react-router": "^7.9.1",
8688
"zod": "^4.1.8"
8789
}
88-
}
90+
}

src/components/navbar/dynamic-section.tsx

Lines changed: 120 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,145 @@ import {
66
SidebarMenuButton,
77
SidebarMenuItem
88
} from "../sidebar";
9-
import { NavbarConfiguration, NavigationItem } from "./utils";
9+
import { ChevronRight } from "lucide-react";
10+
import { match, P } from "ts-pattern";
11+
import { type NavbarConfiguration, type MenuItemType } from "./utils";
12+
import { When } from "../../lib/when";
1013

1114
interface DynamicMenuSectionsProps {
1215
config: NavbarConfiguration;
1316
activeArea: string;
1417
activeItem: string | null;
1518
isCollapsed: boolean;
16-
onItemClick: (item: NavigationItem) => void;
19+
onItemClick: (item: MenuItemType) => void;
20+
expandedItems: string[];
1721
}
1822

1923
export const DynamicMenuSections: React.FC<DynamicMenuSectionsProps> = ({
2024
config,
2125
activeArea,
2226
activeItem,
2327
isCollapsed,
24-
onItemClick
28+
onItemClick,
29+
expandedItems
2530
}) => {
2631
const currentAreaConfig = config.areas.find(area => area.title === activeArea);
2732

2833
if (!currentAreaConfig || currentAreaConfig.sections.length === 0) {
2934
return null;
3035
}
3136

37+
const renderMenuItem = (item: MenuItemType) => {
38+
return match(item)
39+
.with({ children: P.array(P.any) }, (expandableItem) => {
40+
if (isCollapsed) {
41+
return null;
42+
}
43+
44+
const isExpanded = expandedItems.includes(expandableItem.title);
45+
46+
return (
47+
<SidebarMenuItem key={expandableItem.title}>
48+
<SidebarMenuButton
49+
isActive={false}
50+
onClick={() => onItemClick(expandableItem)}
51+
tooltip={undefined}
52+
size="default"
53+
disabled={expandableItem.disabled}
54+
className={`
55+
text-brand-fgLight/90 hover:text-brand-fgLight hover:bg-brand-fgLight/15
56+
rounded-lg transition-all duration-200 mb-1.5
57+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 focus-visible:ring-offset-transparent
58+
${expandableItem.disabled ? 'opacity-50 cursor-not-allowed' : ''}
59+
justify-between
60+
`}
61+
>
62+
<div className="flex items-center gap-2 flex-1 min-w-0">
63+
<When condition={Boolean(expandableItem.icon)}>
64+
{expandableItem.icon && <expandableItem.icon className="h-4 w-4 flex-shrink-0" />}
65+
</When>
66+
<span className="text-sm font-medium truncate">
67+
{expandableItem.title}
68+
</span>
69+
</div>
70+
<ChevronRight
71+
className={`h-4 w-4 flex-shrink-0 transition-transform duration-200 ease-in-out ${isExpanded ? 'transform rotate-90' : 'transform rotate-0'
72+
}`}
73+
/>
74+
</SidebarMenuButton>
75+
76+
<When condition={isExpanded}>
77+
<SidebarMenu className="ml-2 mt-1 pr-2 border-l !border-[#7c598f]">
78+
{expandableItem.children.map((child) => (
79+
<SidebarMenuItem key={child.title} className="pl-1">
80+
<SidebarMenuButton
81+
isActive={activeItem === child.title}
82+
onClick={() => onItemClick(child)}
83+
tooltip={undefined}
84+
size="default"
85+
disabled={child.disabled}
86+
className={`
87+
text-brand-fgLight/90 hover:text-brand-fgLight hover:bg-brand-fgLight/15
88+
rounded-lg transition-all duration-200 mb-1.5
89+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 focus-visible:ring-offset-transparent
90+
${activeItem === child.title ? 'bg-brand-fgLight/20 text-brand-fgLight font-medium' : ''}
91+
${child.disabled ? 'opacity-50 cursor-not-allowed' : ''}
92+
`}
93+
>
94+
<When condition={Boolean(child.icon)}>
95+
{child.icon && <child.icon className="h-4 w-4 flex-shrink-0" />}
96+
</When>
97+
<span className="text-sm font-medium truncate">
98+
{child.title}
99+
<When condition={Boolean(child.badge)}>
100+
<span className="ml-2 px-1.5 py-0.5 text-xs bg-brand-orange text-white rounded-full">
101+
{child.badge}
102+
</span>
103+
</When>
104+
</span>
105+
</SidebarMenuButton>
106+
</SidebarMenuItem>
107+
))}
108+
</SidebarMenu>
109+
</When>
110+
</SidebarMenuItem>
111+
);
112+
})
113+
.otherwise((regularItem) => (
114+
<SidebarMenuItem key={regularItem.title}>
115+
<SidebarMenuButton
116+
isActive={activeItem === regularItem.title}
117+
onClick={() => onItemClick(regularItem)}
118+
tooltip={isCollapsed ? regularItem.title : undefined}
119+
size="default"
120+
disabled={regularItem.disabled}
121+
className={`
122+
text-brand-fgLight/90 hover:text-brand-fgLight hover:bg-brand-fgLight/15
123+
rounded-lg transition-all duration-200 mb-1.5
124+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 focus-visible:ring-offset-transparent
125+
${activeItem === regularItem.title ? 'bg-brand-fgLight/20 text-brand-fgLight font-medium' : ''}
126+
${regularItem.disabled ? 'opacity-50 cursor-not-allowed' : ''}
127+
group-data-[collapsible=icon]:justify-center
128+
`}
129+
>
130+
<When condition={Boolean(regularItem.icon)}>
131+
{regularItem.icon && <regularItem.icon className="h-5 w-5 flex-shrink-0" />}
132+
</When>
133+
<When condition={!isCollapsed}>
134+
<span className="text-sm font-medium truncate">
135+
{regularItem.title}
136+
<When condition={Boolean(regularItem.badge)}>
137+
<span className="ml-2 px-1.5 py-0.5 text-xs bg-brand-orange text-white rounded-full">
138+
{regularItem.badge}
139+
</span>
140+
</When>
141+
</span>
142+
</When>
143+
</SidebarMenuButton>
144+
</SidebarMenuItem>
145+
));
146+
};
147+
32148
return (
33149
<>
34150
{currentAreaConfig.sections.map((section) => (
@@ -38,37 +154,7 @@ export const DynamicMenuSections: React.FC<DynamicMenuSectionsProps> = ({
38154
</SidebarGroupLabel>
39155
<SidebarGroupContent>
40156
<SidebarMenu>
41-
{section.items.map((item) => (
42-
<SidebarMenuItem key={item.title}>
43-
<SidebarMenuButton
44-
isActive={activeItem === item.title}
45-
onClick={() => onItemClick(item)}
46-
tooltip={isCollapsed ? item.title : undefined}
47-
size="default"
48-
disabled={item.disabled}
49-
className={`
50-
text-brand-fgLight/90 hover:text-brand-fgLight hover:bg-brand-fgLight/15
51-
rounded-lg transition-all duration-200 mb-1.5
52-
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2 focus-visible:ring-offset-transparent
53-
${activeItem === item.title ? 'bg-brand-fgLight/20 text-brand-fgLight font-medium' : ''}
54-
${item.disabled ? 'opacity-50 cursor-not-allowed' : ''}
55-
group-data-[collapsible=icon]:justify-center
56-
`}
57-
>
58-
<item.icon className="h-5 w-5 flex-shrink-0" />
59-
{!isCollapsed && (
60-
<span className="text-sm font-medium truncate">
61-
{item.title}
62-
{item.badge && (
63-
<span className="ml-2 px-1.5 py-0.5 text-xs bg-brand-orange text-white rounded-full">
64-
{item.badge}
65-
</span>
66-
)}
67-
</span>
68-
)}
69-
</SidebarMenuButton>
70-
</SidebarMenuItem>
71-
))}
157+
{section.items.map(renderMenuItem)}
72158
</SidebarMenu>
73159
</SidebarGroupContent>
74160
</SidebarGroup>

0 commit comments

Comments
 (0)