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',
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user