mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 07:22:56 +00:00
260 lines
7.7 KiB
TypeScript
260 lines
7.7 KiB
TypeScript
import {
|
|
AppShell,
|
|
Badge,
|
|
NavLink,
|
|
ScrollArea,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
} from '@tabler/icons-react';
|
|
import type { Icon } from '@tabler/icons-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { BrandMark } from '@ema-platform/auth';
|
|
|
|
export interface NavItem {
|
|
label: string;
|
|
icon: Icon;
|
|
to?: string;
|
|
/** Not yet connected to real data — surfaced as a "Soon" badge. */
|
|
soon?: boolean;
|
|
}
|
|
|
|
/** A labelled group of nav items. */
|
|
export interface NavSection {
|
|
/** i18n key or literal heading. Omit for an ungrouped leading block. */
|
|
label?: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
/** Both shapes are accepted so callers can migrate to sections gradually. */
|
|
export type NavEntries = NavItem[] | NavSection[];
|
|
|
|
function toSections(entries: NavEntries): NavSection[] {
|
|
if (entries.length === 0) return [];
|
|
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
|
|
return isSectioned
|
|
? (entries as NavSection[])
|
|
: [{ items: entries as NavItem[] }];
|
|
}
|
|
|
|
interface AppSidebarProps {
|
|
navItems: NavEntries;
|
|
collapsed: boolean;
|
|
activePath: string;
|
|
onToggleCollapse: () => void;
|
|
onNavigate: (item: NavItem) => void;
|
|
brandName: string;
|
|
brandSubtitle: string;
|
|
}
|
|
|
|
export function AppSidebar({
|
|
navItems,
|
|
collapsed,
|
|
activePath,
|
|
onToggleCollapse,
|
|
onNavigate,
|
|
brandName,
|
|
brandSubtitle,
|
|
}: AppSidebarProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const activeNavItem = (item: NavItem) =>
|
|
!!item.to &&
|
|
(activePath === item.to || activePath.startsWith(`${item.to}/`));
|
|
|
|
return (
|
|
<>
|
|
{/* Brand header */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: rem(12),
|
|
height: 74,
|
|
padding: collapsed ? '0 12px' : '0 20px',
|
|
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<BrandMark size={32} />
|
|
{!collapsed && (
|
|
<div style={{ minWidth: 0 }}>
|
|
<Text
|
|
fw={800}
|
|
size="xs"
|
|
style={{
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
lineHeight: 1.2,
|
|
color: 'var(--mantine-color-primary-7)',
|
|
}}
|
|
>
|
|
{brandName}
|
|
</Text>
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
style={{
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
fontSize: rem(10),
|
|
lineHeight: 1.3,
|
|
}}
|
|
>
|
|
{brandSubtitle}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation items */}
|
|
<AppShell.Section grow component={ScrollArea} p="md">
|
|
<Stack gap={2}>
|
|
{toSections(navItems).map((section, sectionIndex) => (
|
|
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
|
|
{/* Headings are noise when only icons are visible. */}
|
|
{section.label && !collapsed && (
|
|
<Text
|
|
size="xs"
|
|
fw={700}
|
|
c="dimmed"
|
|
mt={sectionIndex === 0 ? 0 : rem(14)}
|
|
pl={rem(12)}
|
|
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
|
>
|
|
{t(section.label)}
|
|
</Text>
|
|
)}
|
|
{section.label && collapsed && sectionIndex > 0 && (
|
|
<div
|
|
style={{
|
|
height: 1,
|
|
margin: `${rem(8)} ${rem(6)}`,
|
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
|
}}
|
|
/>
|
|
)}
|
|
{section.items.map((item) => {
|
|
const ItemIcon = item.icon;
|
|
const active = activeNavItem(item);
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip
|
|
key={item.label}
|
|
label={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : t(item.label)}
|
|
position="right"
|
|
withArrow
|
|
>
|
|
<UnstyledButton
|
|
onClick={() => onNavigate(item)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '100%',
|
|
height: rem(40),
|
|
borderRadius: rem(10),
|
|
opacity: item.soon ? 0.55 : 1,
|
|
color: active ? 'var(--mantine-color-blue-6)' : undefined,
|
|
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
|
|
}}
|
|
>
|
|
<ItemIcon size={20} stroke={1.6} />
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<NavLink
|
|
key={item.label}
|
|
active={active}
|
|
label={t(item.label)}
|
|
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
|
// Tells a reviewer at a glance which screens are wired up.
|
|
rightSection={
|
|
item.soon ? (
|
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
|
{t('nav.soon', 'Soon')}
|
|
</Badge>
|
|
) : undefined
|
|
}
|
|
onClick={() => onNavigate(item)}
|
|
variant="light"
|
|
styles={{
|
|
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
|
label: { fontWeight: 500 },
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</AppShell.Section>
|
|
|
|
{/* Collapse toggle */}
|
|
<div
|
|
style={{
|
|
padding: rem(8),
|
|
borderTop: '1px solid var(--mantine-color-gray-2)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Tooltip
|
|
label={collapsed ? t('nav.expandSidebar', 'Expand sidebar') : t('nav.collapseSidebar', 'Collapse sidebar')}
|
|
position="right"
|
|
withArrow
|
|
disabled={!collapsed}
|
|
>
|
|
<UnstyledButton
|
|
onClick={onToggleCollapse}
|
|
visibleFrom="sm"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
gap: rem(10),
|
|
width: '100%',
|
|
padding: '10px 12px',
|
|
borderRadius: rem(12),
|
|
color: 'var(--mantine-color-gray-5)',
|
|
transition: 'all 150ms ease',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
background: 'transparent',
|
|
fontSize: rem(13),
|
|
fontWeight: 500,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'transparent';
|
|
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
|
|
}}
|
|
>
|
|
{collapsed ? (
|
|
<IconChevronRight size={18} stroke={1.6} />
|
|
) : (
|
|
<>
|
|
<IconChevronLeft size={18} stroke={1.6} />
|
|
<span>{t('nav.collapseSidebar', 'Collapse')}</span>
|
|
</>
|
|
)}
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|