From 149524376b0bc1e93782747f042c476a4f6a07ed Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 07:32:36 +0200 Subject: [PATCH] Make the sidebar an animated accordion with a softer hover --- src/components/layout/Sidebar.tsx | 143 ++++++++++++++++++------------ src/components/ui/collapsible.tsx | 18 +++- src/index.css | 21 +++++ 3 files changed, 125 insertions(+), 57 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c512c16..e707e45 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ * 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 * as LucideIcons from 'lucide-react'; const { ChevronDown, Lock } = LucideIcons; @@ -75,27 +75,52 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean return false; } -interface AutoOpenCollapsibleProps { - containsActive: boolean; - children: React.ReactNode; +interface AccordionLevelContextValue { + openId: string | null; + setOpenId: (id: string | null) => void; } -// 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); - } +const AccordionLevelContext = createContext(null); + +// Sibling collapsibles share a single open id, so expanding one collapses the +// others at the same level (accordion behavior). +function AccordionLevel({ children }: { children: React.ReactNode }) { + const [openId, setOpenId] = useState(null); + const value = useMemo(() => ({ openId, setOpenId }), [openId]); + return {children}; +} + +// 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 ( - + setOpenId(open ? id : null)}> {children} ); } +// 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 { const schema = useSchemaStore.getState().schema; if (!schema) return true; @@ -144,8 +169,8 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi variant="ghost" data-sidebar-active={isActive || undefined} className={cn( - 'w-full justify-start gap-2 font-normal', - isActive && 'bg-accent text-accent-foreground', + sidebarItemClass, + isActive && 'bg-accent text-accent-foreground hover:bg-accent', depth > 0 && 'text-sm', )} 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); return ( - + - {item.items.map((sub) => ( - + + {item.items.map((sub) => ( + ))} + - + ); } @@ -227,7 +254,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU - {items.map((sub) => ( - + + {items.map((sub) => ( + ))} + - + ); } @@ -340,17 +369,19 @@ export function Sidebar() {