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 { 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 { ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import type { MenuItemType } from '../types/menu.types';
|
||||
@@ -28,50 +28,153 @@ interface SidebarMenuProps {
|
||||
|
||||
interface MenuItemExpandedProps {
|
||||
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 isActive = activeKeys.has(item.key);
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
const isExactActive = isActive && !hasChildren;
|
||||
const isParentActive = isActive && hasChildren;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
component={Link}
|
||||
to={item.path}
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={item.label}
|
||||
leftSection={<Icon size={18} />}
|
||||
active={isActive}
|
||||
active={isExactActive}
|
||||
defaultOpened={isActive} // Auto-expand if active
|
||||
variant="light"
|
||||
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;
|
||||
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 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 (
|
||||
<Tooltip label={item.label} position="right" withArrow transitionProps={{ transition: 'fade-right' }}>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to={item.path}
|
||||
variant={isActive ? 'light' : 'subtle'}
|
||||
color={isActive ? undefined : 'gray'}
|
||||
size="lg"
|
||||
aria-label={item.label}
|
||||
>
|
||||
<Icon size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<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 isMini = variant === 'mini';
|
||||
|
||||
// Determine active state: match if current path starts with the menu item's base path.
|
||||
// Stripping trailing '/index' from item.path so parent routes also match child routes.
|
||||
// Recursive active state calculation
|
||||
const activeKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const item of items) {
|
||||
// e.g. item.path = '/app/example/full-page/index'
|
||||
// basePath = '/app/example/full-page'
|
||||
|
||||
const checkActive = (item: MenuItemType): boolean => {
|
||||
let isActive = false;
|
||||
|
||||
// 1. Exact match logic (handles trailing /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);
|
||||
}
|
||||
|
||||
return isActive;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
checkActive(item);
|
||||
}
|
||||
return keys;
|
||||
}, [pathname, items]);
|
||||
@@ -109,9 +243,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
|
||||
if (isMini) {
|
||||
return (
|
||||
<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) => (
|
||||
<MenuItemMini key={item.key} item={item} isActive={activeKeys.has(item.key)} />
|
||||
<MenuItemFlyout key={item.key} item={item} activeKeys={activeKeys} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -134,9 +268,9 @@ export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, w
|
||||
// -- Expanded Mode --------------------------------------------------------
|
||||
return (
|
||||
<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) => (
|
||||
<MenuItemExpanded key={item.key} item={item} isActive={activeKeys.has(item.key)} />
|
||||
<MenuItemExpanded key={item.key} item={item} activeKeys={activeKeys} />
|
||||
))}
|
||||
</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';
|
||||
|
||||
/**
|
||||
@@ -6,21 +18,105 @@ import type { MenuItemType } from '../types/menu.types';
|
||||
*
|
||||
* Paths are absolute and must align with the router hierarchy:
|
||||
* 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[] = [
|
||||
{
|
||||
key: 'example-single-page',
|
||||
label: 'Example Single Page',
|
||||
icon: FileText,
|
||||
path: '/app/example/single-page/index',
|
||||
key: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'example-full-page',
|
||||
label: 'Example Full Page',
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/example/full-page/index',
|
||||
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',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user