Make the sidebar an animated accordion with a softer hover

This commit is contained in:
Steven RYDELL
2026-07-29 07:32:36 +02:00
parent 956212031a
commit 149524376b
3 changed files with 125 additions and 57 deletions
+54 -23
View File
@@ -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, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useLayoutEffect, 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;
@@ -75,27 +75,52 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false; return false;
} }
interface AutoOpenCollapsibleProps { interface AccordionLevelContextValue {
containsActive: boolean; openId: string | null;
children: React.ReactNode; setOpenId: (id: string | null) => void;
} }
// A collapsible that opens itself whenever the active page lands inside it, const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
// while still allowing the user to toggle it manually afterwards.
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) { // Sibling collapsibles share a single open id, so expanding one collapses the
const [open, setOpen] = useState(containsActive); // others at the same level (accordion behavior).
const [prevContainsActive, setPrevContainsActive] = useState(containsActive); function AccordionLevel({ children }: { children: React.ReactNode }) {
if (containsActive !== prevContainsActive) { const [openId, setOpenId] = useState<string | null>(null);
setPrevContainsActive(containsActive); const value = useMemo(() => ({ openId, setOpenId }), [openId]);
if (containsActive) setOpen(true); return <AccordionLevelContext.Provider value={value}>{children}</AccordionLevelContext.Provider>;
} }
// A collapsible wired to its accordion level. It opens itself whenever the
// active page lands inside it, while still allowing manual toggling.
function AccordionCollapsible({
id,
containsActive,
children,
}: {
id: string;
containsActive: boolean;
children: React.ReactNode;
}) {
const level = useContext(AccordionLevelContext);
if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel');
const { openId, setOpenId } = level;
// Layout effect so the branch containing the active page is already open on
// the first paint after a navigation.
useLayoutEffect(() => {
if (containsActive) setOpenId(id);
}, [containsActive, id, setOpenId]);
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
{children} {children}
</Collapsible> </Collapsible>
); );
} }
// Softer than the default ghost hover, closer to documentation sidebars:
// muted text that brightens with a faint background instead of a strong fill.
const sidebarItemClass =
'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground';
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;
@@ -144,8 +169,8 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
variant="ghost" variant="ghost"
data-sidebar-active={isActive || undefined} data-sidebar-active={isActive || undefined}
className={cn( className={cn(
'w-full justify-start gap-2 font-normal', sidebarItemClass,
isActive && 'bg-accent text-accent-foreground', isActive && 'bg-accent text-accent-foreground hover:bg-accent',
depth > 0 && 'text-sm', depth > 0 && 'text-sm',
)} )}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
@@ -168,11 +193,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return ( return (
<AutoOpenCollapsible containsActive={containsActive}> <AccordionCollapsible id={item.name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start gap-2 font-normal text-sm" className={cn(sidebarItemClass, 'text-sm')}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
> >
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
@@ -180,6 +205,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<AccordionLevel>
{item.items.map((sub) => ( {item.items.map((sub) => (
<SidebarSubItem <SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name} key={sub.type === 'link' ? sub.viewName : sub.name}
@@ -192,8 +218,9 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
onUpsell={onUpsell} onUpsell={onUpsell}
/> />
))} ))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</AutoOpenCollapsible> </AccordionCollapsible>
); );
} }
@@ -227,7 +254,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
<Button <Button
variant="ghost" variant="ghost"
data-sidebar-active={isActive || undefined} data-sidebar-active={isActive || undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')} className={cn(sidebarItemClass, isActive && 'bg-accent text-accent-foreground hover:bg-accent')}
onClick={() => { onClick={() => {
if (isLocked) { if (isLocked) {
onUpsell(); onUpsell();
@@ -250,15 +277,16 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName); const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return ( return (
<AutoOpenCollapsible containsActive={containsActive}> <AccordionCollapsible id={name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal"> <Button variant="ghost" className={sidebarItemClass}>
<LucideIcon name={icon} className="h-4 w-4 shrink-0" /> <LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<AccordionLevel>
{items.map((sub) => ( {items.map((sub) => (
<SidebarSubItem <SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name} key={sub.type === 'link' ? sub.viewName : sub.name}
@@ -271,8 +299,9 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
onUpsell={onUpsell} onUpsell={onUpsell}
/> />
))} ))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</AutoOpenCollapsible> </AccordionCollapsible>
); );
} }
@@ -340,6 +369,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">
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<nav ref={navRef} className="flex flex-col gap-0.5 px-2 py-2"> <nav ref={navRef} className="flex flex-col gap-0.5 px-2 py-2">
<AccordionLevel>
{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}
@@ -351,6 +381,7 @@ export function Sidebar() {
onUpsell={() => setUpsellOpen(true)} onUpsell={() => setUpsellOpen(true)}
/> />
))} ))}
</AccordionLevel>
</nav> </nav>
</ScrollArea> </ScrollArea>
+17 -1
View File
@@ -5,11 +5,27 @@
*/ */
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Collapsible = CollapsiblePrimitive.Root; const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; const CollapsibleContent = React.forwardRef<
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
>(({ className, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
className,
)}
{...props}
/>
));
CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }; export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+21
View File
@@ -131,6 +131,27 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--animate-collapsible-down: collapsible-down 0.2s ease-out;
--animate-collapsible-up: collapsible-up 0.2s ease-out;
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}
} }
@layer base { @layer base {