Sync sidebar with programmatic navigation

Collapsible groups now auto-open when the active page lands inside them (while remaining manually toggleable), and the sidebar scrolls the active item into view. Previously, navigating via the command palette left the sidebar collapsed on the wrong spot.
This commit is contained in:
Steven RYDELL
2026-07-29 06:11:06 +02:00
parent 7c97297ce6
commit a657ad1b93
+39 -8
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
@@ -74,6 +74,27 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false;
}
interface AutoOpenCollapsibleProps {
containsActive: boolean;
children: React.ReactNode;
}
// A collapsible that opens itself whenever the active page lands inside it,
// while still allowing the user to toggle it manually afterwards.
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
const [open, setOpen] = useState(containsActive);
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
if (containsActive !== prevContainsActive) {
setPrevContainsActive(containsActive);
if (containsActive) setOpen(true);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
{children}
</Collapsible>
);
}
function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
@@ -120,6 +141,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
return (
<Button
variant="ghost"
data-sidebar-active={isActive || undefined}
className={cn(
'w-full justify-start gap-2 font-normal',
isActive && 'bg-accent text-accent-foreground',
@@ -145,7 +167,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
@@ -168,9 +190,9 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
edition={edition}
onUpsell={onUpsell}
/>
))}
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -203,6 +225,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
return (
<Button
variant="ghost"
data-sidebar-active={isActive || undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
@@ -226,7 +249,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
@@ -246,9 +269,9 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
edition={edition}
onUpsell={onUpsell}
/>
))}
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -267,6 +290,7 @@ export function Sidebar() {
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false);
const navRef = useRef<HTMLElement>(null);
const layouts = useMemo(
() =>
@@ -289,6 +313,13 @@ export function Sidebar() {
}
}, [location.pathname, setSidebarOpen]);
// Keep the active page visible in the sidebar after any navigation
// (e.g. from the command palette or an external link).
useEffect(() => {
const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
active?.scrollIntoView({ block: 'nearest' });
}, [location.pathname, activeSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
@@ -312,7 +343,7 @@ export function Sidebar() {
/>
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
<div className="flex-1 overflow-y-auto py-2 [scrollbar-width:thin]">
<nav className="flex flex-col gap-0.5 px-2">
<nav ref={navRef} className="flex flex-col gap-0.5 px-2">
{layout.items.map((item) => (
<SidebarTopItem
key={'link' in item ? item.link.viewName : item.container.name}