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 {
AppShell,
Badge,
Group,
NavLink,
Popover,
ScrollArea,
@@ -13,6 +14,7 @@ import {
useMantineColorScheme,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
@@ -66,6 +68,10 @@ interface SidebarItemProps {
collapsed: boolean;
activePath: string;
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
* children.
*/
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
const { t } = useTranslation();
const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length);
@@ -103,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
</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">
{t('nav.soon', 'Soon')}
</Badge>
);
const rightSection = hasChildren ? (
<Group gap={4} wrap="nowrap">
{item.soon ? soonBadge : badgeNode}
{chevron}
</Group>
) : item.soon ? (
soonBadge
) : (
badgeNode || undefined
);
@@ -185,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection}
// Auto-expands the group the user is currently inside.
defaultOpened={hasChildren ? branchActive : undefined}
disableRightSectionRotation
// 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)}
variant="light"
styles={{
@@ -259,6 +287,48 @@ export function AppSidebar({
? 'var(--mantine-color-gray-3)'
: '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 (
<>
{/* Brand header */}
@@ -308,20 +378,39 @@ export function AppSidebar({
{/* 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}`}>
{sections.map((section, sectionIndex) => {
const sectionKey = section.label ?? `section-${sectionIndex}`;
const sectionOpened = openMap[sectionKey] ?? true;
return (
<Stack gap={2} key={sectionKey}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<UnstyledButton
onClick={() => toggleSection(sectionKey)}
aria-expanded={sectionOpened}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: `0 ${rem(12)}`,
marginTop: sectionIndex === 0 ? 0 : rem(14),
}}
>
<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>
{sectionOpened ? (
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
) : (
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
)}
</UnstyledButton>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
@@ -332,17 +421,21 @@ export function AppSidebar({
}}
/>
)}
{section.items.map((item) => (
{(!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>
</AppShell.Section>