refactor: implement nested menu support in sidebar using recursive components and flyout menus

This commit is contained in:
Firman Ramdhani
2026-07-03 14:19:34 +07:00
parent 81fa9aa9d6
commit 5572bca964
2 changed files with 276 additions and 46 deletions
@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo } from 'react'; import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider } from '@repo/ui/components'; import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu } from '@repo/ui/components';
import { useCoreAppShell } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components';
import { ChevronsLeft, ChevronsRight } from 'lucide-react'; import { ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types'; import type { MenuItemType } from '../types/menu.types';
@@ -28,50 +28,153 @@ interface SidebarMenuProps {
interface MenuItemExpandedProps { interface MenuItemExpandedProps {
item: MenuItemType; item: MenuItemType;
isActive: boolean; activeKeys: Set<string>;
} }
const MenuItemExpanded = memo(function MenuItemExpanded({ item, isActive }: MenuItemExpandedProps) { const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys }: MenuItemExpandedProps) {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0;
const isExactActive = isActive && !hasChildren;
const isParentActive = isActive && hasChildren;
return ( return (
<NavLink <NavLink
component={Link} component={hasChildren ? 'button' : (Link as any)}
to={item.path} to={hasChildren ? undefined : item.path}
label={item.label} label={item.label}
leftSection={<Icon size={18} />} leftSection={<Icon size={18} />}
active={isActive} active={isExactActive}
defaultOpened={isActive} // Auto-expand if active
variant="light" variant="light"
styles={{ styles={{
root: { borderRadius: 'var(--mantine-radius-md)' }, root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'var(--mantine-primary-color-filled)' : undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}} }}
/> >
{hasChildren &&
item.children!.map((child) => (
<MenuItemExpanded key={child.key} item={child} activeKeys={activeKeys} />
))}
</NavLink>
); );
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Individual Menu Item (Mini / Collapsed) // Individual Menu Item (Mini / Collapsed / Flyout)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface MenuItemMiniProps { interface MenuItemFlyoutProps {
item: MenuItemType; item: MenuItemType;
isActive: boolean; activeKeys: Set<string>;
isRoot?: boolean;
} }
const MenuItemMini = memo(function MenuItemMini({ item, isActive }: MenuItemMiniProps) { const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = true }: MenuItemFlyoutProps) {
const Icon = item.icon; const Icon = item.icon;
const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0;
// -- Leaf Item (No children) --
if (!hasChildren) {
if (isRoot) {
return (
<Tooltip label={item.label} position="right" withArrow transitionProps={{ transition: 'fade-right' }}>
<ActionIcon
component={Link as any}
to={item.path}
variant={isActive ? 'light' : 'subtle'}
color={isActive ? undefined : 'gray'}
size="lg"
aria-label={item.label}
>
<Icon size={20} />
</ActionIcon>
</Tooltip>
);
} else {
return (
<Menu.Item
component={Link as any}
to={item.path}
leftSection={<Icon size={14} />}
color={isActive ? 'var(--mantine-primary-color-filled)' : undefined}
styles={{
itemLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 240,
},
}}
>
{item.label}
</Menu.Item>
);
}
}
// -- Parent Item (Has children) --
const Target = isRoot ? (
<ActionIcon
variant={isActive ? 'light' : 'subtle'}
color={isActive ? undefined : 'gray'}
size="lg"
aria-label={item.label}
>
<Icon size={20} />
</ActionIcon>
) : (
<Menu.Item
leftSection={<Icon size={14} />}
rightSection={<ChevronsRight size={14} />}
color={isActive ? 'var(--mantine-primary-color-filled)' : undefined}
closeMenuOnClick={false} // Mencegah parent tertutup saat memunculkan Level 3
styles={{
itemLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 240,
},
}}
>
{item.label}
</Menu.Item>
);
return ( return (
<Tooltip label={item.label} position="right" withArrow transitionProps={{ transition: 'fade-right' }}> <Menu
<ActionIcon trigger="hover"
component={Link} position={isRoot ? 'right-start' : 'right-start'}
to={item.path} offset={isRoot ? 12 : 4}
variant={isActive ? 'light' : 'subtle'} withArrow={isRoot}
color={isActive ? undefined : 'gray'} loop={false}
size="lg" withinPortal
aria-label={item.label} openDelay={100}
> closeDelay={300}
<Icon size={20} /> >
</ActionIcon> <Menu.Target>{Target}</Menu.Target>
</Tooltip> <Menu.Dropdown>
{isRoot && (
<>
<Menu.Label>{item.label}</Menu.Label>
<Menu.Divider />
</>
)}
{item.children!.map((child) => (
<MenuItemFlyout key={child.key} item={child} activeKeys={activeKeys} isRoot={false} />
))}
</Menu.Dropdown>
</Menu>
); );
}); });
@@ -86,17 +189,48 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
const variant = variantOverride ?? contextVariant; const variant = variantOverride ?? contextVariant;
const isMini = variant === 'mini'; const isMini = variant === 'mini';
// Determine active state: match if current path starts with the menu item's base path. // Recursive active state calculation
// Stripping trailing '/index' from item.path so parent routes also match child routes.
const activeKeys = useMemo(() => { const activeKeys = useMemo(() => {
const keys = new Set<string>(); const keys = new Set<string>();
for (const item of items) {
// e.g. item.path = '/app/example/full-page/index' const checkActive = (item: MenuItemType): boolean => {
// basePath = '/app/example/full-page' let isActive = false;
// 1. Exact match logic (handles trailing /index)
const basePath = item.path.replace(/\/index$/, ''); const basePath = item.path.replace(/\/index$/, '');
if (pathname === item.path || pathname.startsWith(basePath + '/') || pathname === basePath) { if (pathname === item.path || pathname === basePath) {
isActive = true;
}
// 2. Prefix match logic (for nested module routes like /detail/:id)
// We protect root paths like '/' or '/app' from matching everything.
if (!isActive && basePath !== '/' && basePath !== '/app' && basePath !== '') {
if (pathname.startsWith(basePath + '/')) {
isActive = true;
}
}
// 3. Recursive children match
if (item.children) {
for (const child of item.children) {
// We intentionally avoid .some() here so checkActive runs for all children
// ensuring every active child gets its key added to the Set.
if (checkActive(child)) {
isActive = true;
}
}
}
// If item or any of its children are active, mark this item as active
if (isActive) {
keys.add(item.key); keys.add(item.key);
} }
return isActive;
};
for (const item of items) {
checkActive(item);
} }
return keys; return keys;
}, [pathname, items]); }, [pathname, items]);
@@ -109,9 +243,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
if (isMini) { if (isMini) {
return ( return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Stack align="center" gap="xs" p="xs" style={{ flex: 1 }}> <Stack align="center" gap="xs" p="lg" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
{items.map((item) => ( {items.map((item) => (
<MenuItemMini key={item.key} item={item} isActive={activeKeys.has(item.key)} /> <MenuItemFlyout key={item.key} item={item} activeKeys={activeKeys} />
))} ))}
</Stack> </Stack>
@@ -134,9 +268,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
// -- Expanded Mode -------------------------------------------------------- // -- Expanded Mode --------------------------------------------------------
return ( return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}> <Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Box p="xs" style={{ flex: 1, overflowY: 'auto' }}> <Box p="lg" style={{ flex: 1, overflowY: 'auto' }}>
{items.map((item) => ( {items.map((item) => (
<MenuItemExpanded key={item.key} item={item} isActive={activeKeys.has(item.key)} /> <MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} />
))} ))}
</Box> </Box>
@@ -1,4 +1,16 @@
import { FileText, LayoutDashboard } from 'lucide-react'; import {
FileText,
LayoutDashboard,
Database,
Settings,
CreditCard,
Users,
Box,
Layers,
Warehouse,
Activity,
Globe,
} from 'lucide-react';
import type { MenuItemType } from '../types/menu.types'; import type { MenuItemType } from '../types/menu.types';
/** /**
@@ -6,21 +18,105 @@ import type { MenuItemType } from '../types/menu.types';
* *
* Paths are absolute and must align with the router hierarchy: * Paths are absolute and must align with the router hierarchy:
* BrowserRouter → /app/* → /example/* → /single-page/* | /full-page/* * BrowserRouter → /app/* → /example/* → /single-page/* | /full-page/*
*
* Each module's factory defines sub-routes (e.g. /index, /detail/:id).
* Menu items point to the default /index sub-route.
*/ */
export const MENU_ITEMS: MenuItemType[] = [ export const MENU_ITEMS: MenuItemType[] = [
{ {
key: 'example-single-page', key: 'dashboard',
label: 'Example Single Page', label: 'Dashboard',
icon: FileText, icon: LayoutDashboard,
path: '/app/example/single-page/index', path: '/app/dashboard',
}, },
{ {
key: 'example-full-page', key: 'master-data',
label: 'Example Full Page', label: 'Master Data',
icon: LayoutDashboard, icon: Database,
path: '/app/example/full-page/index', path: '/app/master-data',
children: [
{
key: 'inventory',
label: 'Inventory',
icon: Box,
path: '/app/master-data/inventory',
children: [
{
key: 'products',
label: 'Products',
icon: Layers,
path: '/app/master-data/inventory/products',
},
{
key: 'categories',
label: 'Categories',
icon: Globe,
path: '/app/master-data/inventory/categories',
},
{
key: 'warehouses',
label: 'Warehouses',
icon: Warehouse,
path: '/app/master-data/inventory/warehouses',
},
],
},
{
key: 'human-resources',
label: 'Human Resources',
icon: Users,
path: '/app/master-data/human-resources',
},
{
key: 'finance',
label: 'Finance',
icon: CreditCard,
path: '/app/master-data/finance',
},
],
},
{
key: 'transactions',
label: 'Transactions',
icon: Activity,
path: '/app/transactions',
},
{
key: 'settings',
label: 'Settings & Configuration',
icon: Settings,
path: '/app/settings',
children: [
{
key: 'long-text-1',
label: 'Configuration Management for External Vendors',
icon: Settings,
path: '/app/settings/vendor-config',
},
{
key: 'long-text-2',
label: 'Extremely Long Menu Name To Test Text Truncation Handling Properly',
icon: FileText,
path: '/app/settings/long-menu-test',
},
],
},
{
key: 'example-module',
label: 'Example Module',
icon: Database,
path: '/app/example-module',
children: [
{
key: 'example-full-page',
label: 'Example Full Page',
icon: LayoutDashboard,
path: '/app/example/full-page/index',
},
{
key: 'example-single-page',
label: 'Example Single Page',
icon: FileText,
path: '/app/example/single-page/index',
},
],
}, },
]; ];