feat: implement dynamic sidebar navigation with module-based routing and layout integration
This commit is contained in:
@@ -15,7 +15,7 @@ export const FullPageModuleConfig: ModuleConfigEntity = {
|
|||||||
apiUrl: '/full-page',
|
apiUrl: '/full-page',
|
||||||
|
|
||||||
/** Base Web Router URL for UI navigation */
|
/** 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 */
|
/** Architectural category of the module, used for rendering and routing logic */
|
||||||
moduleCategory: 'FULL_PAGE',
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ export const SinglePageModuleConfig: ModuleConfigEntity = {
|
|||||||
apiUrl: '/single-page',
|
apiUrl: '/single-page',
|
||||||
|
|
||||||
/** Base Web Router URL for UI navigation */
|
/** 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 */
|
/** Architectural category of the module, used for rendering and routing logic */
|
||||||
moduleCategory: 'SINGLE_PAGE',
|
moduleCategory: 'SINGLE_PAGE',
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { lazy } from 'react';
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import ModuleLayout from './layouts/module.layout';
|
import ModuleLayout from './layouts/module.layout';
|
||||||
|
|
||||||
const FullPageModule = lazy(() => import('./example/full-page/presentation/factory'));
|
const ExampleModule = lazy(() => import('./example'));
|
||||||
const SinglePageModule = lazy(() => import('./example/single-page/presentation/factory'));
|
|
||||||
|
|
||||||
export default function AppModule() {
|
export default function AppModule() {
|
||||||
return (
|
return (
|
||||||
<ModuleLayout>
|
<ModuleLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/full-page/*" element={<FullPageModule />} />
|
<Route path="/example/*" element={<ExampleModule />} />
|
||||||
<Route path="/single-page/*" element={<SinglePageModule />} />
|
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||||
<Route path="/" element={<Navigate to="/app/full-page" replace={true} />} />
|
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ModuleLayout>
|
</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 { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
|
||||||
import HeaderLayout from './components/header.layout';
|
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 }) {
|
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||||
const configAppShell: CoreAppShellConfig = {
|
const configAppShell: CoreAppShellConfig = {
|
||||||
@@ -17,7 +19,14 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</CoreAppShell>
|
</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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user