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)}
+ />
+ ))}
+
+ );
+ })}