refactor: implement nested menu support in sidebar using recursive components and flyout menus
This commit is contained in:
@@ -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,41 +28,68 @@ 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 (
|
return (
|
||||||
<Tooltip label={item.label} position="right" withArrow transitionProps={{ transition: 'fade-right' }}>
|
<Tooltip label={item.label} position="right" withArrow transitionProps={{ transition: 'fade-right' }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component={Link}
|
component={Link as any}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
variant={isActive ? 'light' : 'subtle'}
|
variant={isActive ? 'light' : 'subtle'}
|
||||||
color={isActive ? undefined : 'gray'}
|
color={isActive ? undefined : 'gray'}
|
||||||
@@ -73,6 +100,82 @@ const MenuItemMini = memo(function MenuItemMini({ item, isActive }: MenuItemMini
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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 (
|
||||||
|
<Menu
|
||||||
|
trigger="hover"
|
||||||
|
position={isRoot ? 'right-start' : 'right-start'}
|
||||||
|
offset={isRoot ? 12 : 4}
|
||||||
|
withArrow={isRoot}
|
||||||
|
loop={false}
|
||||||
|
withinPortal
|
||||||
|
openDelay={100}
|
||||||
|
closeDelay={300}
|
||||||
|
>
|
||||||
|
<Menu.Target>{Target}</Menu.Target>
|
||||||
|
<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: 'master-data',
|
||||||
|
label: 'Master Data',
|
||||||
|
icon: Database,
|
||||||
|
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',
|
key: 'example-full-page',
|
||||||
label: 'Example Full Page',
|
label: 'Example Full Page',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/app/example/full-page/index',
|
path: '/app/example/full-page/index',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'example-single-page',
|
||||||
|
label: 'Example Single Page',
|
||||||
|
icon: FileText,
|
||||||
|
path: '/app/example/single-page/index',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user