Make the sidebar an animated accordion with a softer hover
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, 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,20 +205,22 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{item.items.map((sub) => (
|
<AccordionLevel>
|
||||||
<SidebarSubItem
|
{item.items.map((sub) => (
|
||||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
<SidebarSubItem
|
||||||
item={sub}
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
depth={depth + 1}
|
item={sub}
|
||||||
sectionName={sectionName}
|
depth={depth + 1}
|
||||||
currentPath={currentPath}
|
sectionName={sectionName}
|
||||||
navigate={navigate}
|
currentPath={currentPath}
|
||||||
edition={edition}
|
navigate={navigate}
|
||||||
onUpsell={onUpsell}
|
edition={edition}
|
||||||
/>
|
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,29 +277,31 @@ 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>
|
||||||
{items.map((sub) => (
|
<AccordionLevel>
|
||||||
<SidebarSubItem
|
{items.map((sub) => (
|
||||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
<SidebarSubItem
|
||||||
item={sub}
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
depth={1}
|
item={sub}
|
||||||
sectionName={sectionName}
|
depth={1}
|
||||||
currentPath={currentPath}
|
sectionName={sectionName}
|
||||||
navigate={navigate}
|
currentPath={currentPath}
|
||||||
edition={edition}
|
navigate={navigate}
|
||||||
onUpsell={onUpsell}
|
edition={edition}
|
||||||
/>
|
onUpsell={onUpsell}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</AutoOpenCollapsible>
|
</AccordionCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,17 +369,19 @@ 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">
|
||||||
{layout.items.map((item) => (
|
<AccordionLevel>
|
||||||
<SidebarTopItem
|
{layout.items.map((item) => (
|
||||||
key={'link' in item ? item.link.viewName : item.container.name}
|
<SidebarTopItem
|
||||||
item={item}
|
key={'link' in item ? item.link.viewName : item.container.name}
|
||||||
sectionName={layout.name}
|
item={item}
|
||||||
currentPath={location.pathname}
|
sectionName={layout.name}
|
||||||
navigate={navigate}
|
currentPath={location.pathname}
|
||||||
edition={edition}
|
navigate={navigate}
|
||||||
onUpsell={() => setUpsellOpen(true)}
|
edition={edition}
|
||||||
/>
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user