From 444c3111ab4d3aee8836e352fdc792addc851a26 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 12 Aug 2026 06:40:43 +0000 Subject: [PATCH] UI --- libs/ui/src/lib/layout/AppSidebar.tsx | 173 ++++++++++++++++++++------ 1 file changed, 133 insertions(+), 40 deletions(-) diff --git a/libs/ui/src/lib/layout/AppSidebar.tsx b/libs/ui/src/lib/layout/AppSidebar.tsx index 3969b1850..4ef9cd12a 100644 --- a/libs/ui/src/lib/layout/AppSidebar.tsx +++ b/libs/ui/src/lib/layout/AppSidebar.tsx @@ -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 ); - 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 ? ( + + ) : ( + + ) + ) : null; + + const soonBadge = ( {t('nav.soon', 'Soon')} + ); + + const rightSection = hasChildren ? ( + + {item.soon ? soonBadge : badgeNode} + {chevron} + + ) : item.soon ? ( + soonBadge ) : ( badgeNode || undefined ); @@ -185,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro label={t(item.label)} leftSection={} 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>({}); + + // 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,41 +378,64 @@ export function AppSidebar({ {/* Navigation items */} - {toSections(navItems).map((section, sectionIndex) => ( - - {/* Headings are noise when only icons are visible. */} - {section.label && !collapsed && ( - - {t(section.label)} - - )} - {section.label && collapsed && sectionIndex > 0 && ( -
- )} - {section.items.map((item) => ( - - ))} - - ))} + {sections.map((section, sectionIndex) => { + const sectionKey = section.label ?? `section-${sectionIndex}`; + const sectionOpened = openMap[sectionKey] ?? true; + return ( + + {/* Headings are noise when only icons are visible. */} + {section.label && !collapsed && ( + 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), + }} + > + + {t(section.label)} + + {sectionOpened ? ( + + ) : ( + + )} + + )} + {section.label && collapsed && sectionIndex > 0 && ( +
+ )} + {(!section.label || sectionOpened || collapsed) && + section.items.map((item) => ( + setItemOpened(item.label, next)} + /> + ))} + + ); + })}