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:
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* 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 { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { ChevronDown, Lock } = LucideIcons;
|
const { ChevronDown, Lock } = LucideIcons;
|
||||||
@@ -74,6 +74,27 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
|||||||
return false;
|
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 {
|
function checkLinkVisible(viewName: string): boolean {
|
||||||
const schema = useSchemaStore.getState().schema;
|
const schema = useSchemaStore.getState().schema;
|
||||||
if (!schema) return true;
|
if (!schema) return true;
|
||||||
@@ -120,6 +141,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
data-sidebar-active={isActive || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start gap-2 font-normal',
|
'w-full justify-start gap-2 font-normal',
|
||||||
isActive && 'bg-accent text-accent-foreground',
|
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);
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -168,9 +190,9 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +225,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
data-sidebar-active={isActive || undefined}
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
@@ -226,7 +249,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={containsActive}>
|
<AutoOpenCollapsible containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
@@ -246,9 +269,9 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AutoOpenCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +290,7 @@ export function Sidebar() {
|
|||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
const layouts = useMemo(
|
const layouts = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -289,6 +313,13 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname, setSidebarOpen]);
|
}, [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;
|
if (!sidebarOpen || !schema) return null;
|
||||||
|
|
||||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
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">
|
<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]">
|
<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) => (
|
{layout.items.map((item) => (
|
||||||
<SidebarTopItem
|
<SidebarTopItem
|
||||||
key={'link' in item ? item.link.viewName : item.container.name}
|
key={'link' in item ? item.link.viewName : item.container.name}
|
||||||
|
|||||||
Reference in New Issue
Block a user