This commit is contained in:
estifanos
2026-08-12 06:40:43 +00:00
parent 4a68bdf169
commit 444c3111ab

View File

@@ -1,7 +1,8 @@
import { Fragment } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
AppShell, AppShell,
Badge, Badge,
Group,
NavLink, NavLink,
Popover, Popover,
ScrollArea, ScrollArea,
@@ -13,6 +14,7 @@ import {
useMantineColorScheme, useMantineColorScheme,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconChevronDown,
IconChevronLeft, IconChevronLeft,
IconChevronRight, IconChevronRight,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -66,6 +68,10 @@ interface SidebarItemProps {
collapsed: boolean; collapsed: boolean;
activePath: string; activePath: string;
onNavigate: (item: NavItem) => void; onNavigate: (item: NavItem) => void;
/** Whether this item's children are expanded. Ignored when it has none. */
opened: boolean;
/** Called with the new expanded state when the header is toggled. */
onToggle: (opened: boolean) => void;
} }
/** /**
@@ -77,7 +83,7 @@ interface SidebarItemProps {
* nest, so a parent becomes a hover flyout instead of silently losing its * nest, so a parent becomes a hover flyout instead of silently losing its
* children. * children.
*/ */
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) { function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const ItemIcon = item.icon; const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length); const hasChildren = Boolean(item.children?.length);
@@ -103,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
</Badge> </Badge>
); );
const rightSection = item.soon ? ( // Parent headers always carry a chevron so the expand/collapse state is
// never ambiguous, even when a badge is also present.
const chevron = hasChildren ? (
opened ? (
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
) : (
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
)
) : null;
const soonBadge = (
<Badge size="xs" variant="light" color="gray" radius="sm"> <Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')} {t('nav.soon', 'Soon')}
</Badge> </Badge>
);
const rightSection = hasChildren ? (
<Group gap={4} wrap="nowrap">
{item.soon ? soonBadge : badgeNode}
{chevron}
</Group>
) : item.soon ? (
soonBadge
) : ( ) : (
badgeNode || undefined badgeNode || undefined
); );
@@ -185,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
label={t(item.label)} label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />} leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection} rightSection={rightSection}
// Auto-expands the group the user is currently inside. disableRightSectionRotation
defaultOpened={hasChildren ? branchActive : undefined} // Controlled so the group re-opens if the active route moves inside it
// later (see the auto-expand effect in AppSidebar), not just on mount.
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)} onClick={() => !hasChildren && onNavigate(item)}
variant="light" variant="light"
styles={{ styles={{
@@ -259,6 +287,48 @@ export function AppSidebar({
? 'var(--mantine-color-gray-3)' ? 'var(--mantine-color-gray-3)'
: 'var(--mantine-color-gray-7)'; : 'var(--mantine-color-gray-7)';
const sections = useMemo(() => toSections(navItems), [navItems]);
// Expand/collapse state per collapsible header, keyed by section label for
// NavSection headings and by item label for a parent item's children.
// Sections default open (today every section is always visible); a nested
// item's children default to open only when the active route is inside it,
// matching the previous `defaultOpened` behaviour.
const [openMap, setOpenMap] = useState<Record<string, boolean>>({});
// If the active route moves into a header the user had collapsed, expand
// it back open so the highlighted item stays visible. Never collapses
// anything — that stays purely a manual, per-header action.
useEffect(() => {
setOpenMap((prev) => {
let changed = false;
const next = { ...prev };
sections.forEach((section, sectionIndex) => {
const sectionKey = section.label ?? `section-${sectionIndex}`;
if (
section.label &&
next[sectionKey] !== true &&
section.items.some((item) => isBranchActive(item, activePath))
) {
next[sectionKey] = true;
changed = true;
}
section.items.forEach((item) => {
if (item.children?.length && next[item.label] !== true && isBranchActive(item, activePath)) {
next[item.label] = true;
changed = true;
}
});
});
return changed ? next : prev;
});
}, [activePath, sections]);
const toggleSection = (key: string) =>
setOpenMap((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
const setItemOpened = (key: string, next: boolean) =>
setOpenMap((prev) => ({ ...prev, [key]: next }));
return ( return (
<> <>
{/* Brand header */} {/* Brand header */}
@@ -308,41 +378,64 @@ export function AppSidebar({
{/* Navigation items */} {/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md"> <AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={2}> <Stack gap={2}>
{toSections(navItems).map((section, sectionIndex) => ( {sections.map((section, sectionIndex) => {
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}> const sectionKey = section.label ?? `section-${sectionIndex}`;
{/* Headings are noise when only icons are visible. */} const sectionOpened = openMap[sectionKey] ?? true;
{section.label && !collapsed && ( return (
<Text <Stack gap={2} key={sectionKey}>
size="xs" {/* Headings are noise when only icons are visible. */}
fw={700} {section.label && !collapsed && (
c="dimmed" <UnstyledButton
mt={sectionIndex === 0 ? 0 : rem(14)} onClick={() => toggleSection(sectionKey)}
pl={rem(12)} aria-expanded={sectionOpened}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }} style={{
> display: 'flex',
{t(section.label)} alignItems: 'center',
</Text> justifyContent: 'space-between',
)} width: '100%',
{section.label && collapsed && sectionIndex > 0 && ( padding: `0 ${rem(12)}`,
<div marginTop: sectionIndex === 0 ? 0 : rem(14),
style={{ }}
height: 1, >
margin: `${rem(8)} ${rem(6)}`, <Text
backgroundColor: 'var(--mantine-color-gray-2)', size="xs"
}} fw={700}
/> c="dimmed"
)} style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
{section.items.map((item) => ( >
<SidebarItem {t(section.label)}
key={item.label} </Text>
item={item} {sectionOpened ? (
collapsed={collapsed} <IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
activePath={activePath} ) : (
onNavigate={onNavigate} <IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
/> )}
))} </UnstyledButton>
</Stack> )}
))} {section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
)}
{(!section.label || sectionOpened || collapsed) &&
section.items.map((item) => (
<SidebarItem
key={item.label}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
opened={openMap[item.label] ?? isBranchActive(item, activePath)}
onToggle={(next) => setItemOpened(item.label, next)}
/>
))}
</Stack>
);
})}
</Stack> </Stack>
</AppShell.Section> </AppShell.Section>