feat: implement dynamic sidebar navigation with module-based routing and layout integration

This commit is contained in:
Firman Ramdhani
2026-07-03 13:26:26 +07:00
parent 0e9188aeae
commit 81fa9aa9d6
8 changed files with 234 additions and 8 deletions
@@ -15,7 +15,7 @@ export const FullPageModuleConfig: ModuleConfigEntity = {
apiUrl: '/full-page',
/** Base Web Router URL for UI navigation */
webUrl: '/app/full-page',
webUrl: '/app/example/full-page',
/** Architectural category of the module, used for rendering and routing logic */
moduleCategory: 'FULL_PAGE',
@@ -0,0 +1,15 @@
import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
const FullPageModule = lazy(() => import('./full-page/presentation/factory'));
const SinglePageModule = lazy(() => import('./single-page/presentation/factory'));
export default function ExampleModule() {
return (
<Routes>
<Route path="/full-page/*" element={<FullPageModule />} />
<Route path="/single-page/*" element={<SinglePageModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
@@ -15,7 +15,7 @@ export const SinglePageModuleConfig: ModuleConfigEntity = {
apiUrl: '/single-page',
/** Base Web Router URL for UI navigation */
webUrl: 'apps/example/single-page',
webUrl: '/app/example/single-page',
/** Architectural category of the module, used for rendering and routing logic */
moduleCategory: 'SINGLE_PAGE',
+3 -5
View File
@@ -2,16 +2,14 @@ import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import ModuleLayout from './layouts/module.layout';
const FullPageModule = lazy(() => import('./example/full-page/presentation/factory'));
const SinglePageModule = lazy(() => import('./example/single-page/presentation/factory'));
const ExampleModule = lazy(() => import('./example'));
export default function AppModule() {
return (
<ModuleLayout>
<Routes>
<Route path="/full-page/*" element={<FullPageModule />} />
<Route path="/single-page/*" element={<SinglePageModule />} />
<Route path="/" element={<Navigate to="/app/full-page" replace={true} />} />
<Route path="/example/*" element={<ExampleModule />} />
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</ModuleLayout>
@@ -0,0 +1,161 @@
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 { useCoreAppShell } from '@repo/ui/components';
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types';
import type { SidebarVariant } from '@repo/ui/components';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SidebarMenuProps {
/** Menu items to render */
items: MenuItemType[];
/**
* Override the sidebar variant. When omitted, reads from CoreAppShell context.
* Use `'expanded'` when rendering in the mobile slot (always show labels).
*/
variantOverride?: SidebarVariant;
/** Whether to show the collapse/expand toggle button (desktop only) */
withToggle?: boolean;
}
// ---------------------------------------------------------------------------
// Individual Menu Item (Expanded)
// ---------------------------------------------------------------------------
interface MenuItemExpandedProps {
item: MenuItemType;
isActive: boolean;
}
const MenuItemExpanded = memo(function MenuItemExpanded({ item, isActive }: MenuItemExpandedProps) {
const Icon = item.icon;
return (
<NavLink
component={Link}
to={item.path}
label={item.label}
leftSection={<Icon size={18} />}
active={isActive}
variant="light"
styles={{
root: { borderRadius: 'var(--mantine-radius-md)' },
}}
/>
);
});
// ---------------------------------------------------------------------------
// Individual Menu Item (Mini / Collapsed)
// ---------------------------------------------------------------------------
interface MenuItemMiniProps {
item: MenuItemType;
isActive: boolean;
}
const MenuItemMini = memo(function MenuItemMini({ item, isActive }: MenuItemMiniProps) {
const Icon = item.icon;
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>
);
});
// ---------------------------------------------------------------------------
// SidebarMenu Component
// ---------------------------------------------------------------------------
export const SidebarMenu = memo(function SidebarMenu({ items, variantOverride, withToggle = false }: SidebarMenuProps) {
const { pathname } = useLocation();
const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell();
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.
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 basePath = item.path.replace(/\/index$/, '');
if (pathname === item.path || pathname.startsWith(basePath + '/') || pathname === basePath) {
keys.add(item.key);
}
}
return keys;
}, [pathname, items]);
const handleToggle = useCallback(() => {
setSidebarVariant(isMini ? 'expanded' : 'mini');
}, [isMini, setSidebarVariant]);
// -- Mini (Collapsed) Mode ------------------------------------------------
if (isMini) {
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Stack align="center" gap="xs" p="xs" style={{ flex: 1 }}>
{items.map((item) => (
<MenuItemMini key={item.key} item={item} isActive={activeKeys.has(item.key)} />
))}
</Stack>
{withToggle && (
<>
<Divider />
<Stack align="center" p="xs">
<Tooltip label="Expand sidebar" position="right" withArrow>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={handleToggle} aria-label="Expand sidebar">
<ChevronsRight size={18} />
</ActionIcon>
</Tooltip>
</Stack>
</>
)}
</Box>
);
}
// -- Expanded Mode --------------------------------------------------------
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
<Box p="xs" style={{ flex: 1, overflowY: 'auto' }}>
{items.map((item) => (
<MenuItemExpanded key={item.key} item={item} isActive={activeKeys.has(item.key)} />
))}
</Box>
{withToggle && (
<>
<Divider />
<Box p="xs">
<NavLink
label="Collapse"
leftSection={<ChevronsLeft size={18} />}
onClick={handleToggle}
variant="subtle"
styles={{
root: { borderRadius: 'var(--mantine-radius-md)', color: 'var(--mantine-color-dimmed)' },
}}
/>
</Box>
</>
)}
</Box>
);
});
@@ -0,0 +1,26 @@
import { FileText, LayoutDashboard } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types';
/**
* Application sidebar menu items.
*
* 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: 'example-full-page',
label: 'Example Full Page',
icon: LayoutDashboard,
path: '/app/example/full-page/index',
},
];
@@ -1,5 +1,7 @@
import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
import HeaderLayout from './components/header.layout';
import { SidebarMenu } from './components/sidebar';
import { MENU_ITEMS } from './data/menu.data';
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
const configAppShell: CoreAppShellConfig = {
@@ -17,7 +19,14 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
};
return (
<CoreAppShell config={configAppShell} slots={{ header: <HeaderLayout /> }}>
<CoreAppShell
config={configAppShell}
slots={{
header: <HeaderLayout />,
sidebar: <SidebarMenu items={MENU_ITEMS} withToggle />,
sidebarMobile: <SidebarMenu items={MENU_ITEMS} variantOverride="expanded" />,
}}
>
{children}
</CoreAppShell>
);
@@ -0,0 +1,17 @@
import type { LucideIcon } from 'lucide-react';
/**
* Represents a single navigation menu item in the sidebar.
*/
export interface MenuItemType {
/** Unique identifier for the menu item */
key: string;
/** Display label */
label: string;
/** Lucide icon component */
icon: LucideIcon;
/** Absolute route path for navigation */
path: string;
/** Optional nested child menu items */
children?: MenuItemType[];
}