Skip to content

Commit ca423ed

Browse files
authored
fix: menu for closed sidebar items on hover (#526)
1 parent 7cc95d7 commit ca423ed

File tree

2 files changed

+167
-41
lines changed

2 files changed

+167
-41
lines changed

view/components/layout/nav-main.tsx

Lines changed: 66 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
SidebarMenuItem,
1212
SidebarMenuSub,
1313
SidebarMenuSubButton,
14-
SidebarMenuSubItem
14+
SidebarMenuSubItem,
15+
useSidebar
1516
} from '@/components/ui/sidebar';
17+
import { SidebarHoverMenu } from '@/components/ui/sidebar-hover-menu';
1618
import Link from 'next/link';
1719
import { useCollapsibleState } from '@/hooks/use-collapsible-state';
1820

@@ -32,6 +34,7 @@ interface NavMainProps {
3234
export function NavMain({ items, onItemClick }: NavMainProps) {
3335
const router = useRouter();
3436
const { isItemCollapsed, toggleItem } = useCollapsibleState();
37+
const { state } = useSidebar();
3538

3639
const handleClick = (url: string) => {
3740
onItemClick?.(url);
@@ -41,46 +44,68 @@ export function NavMain({ items, onItemClick }: NavMainProps) {
4144
return (
4245
<SidebarGroup>
4346
<SidebarMenu>
44-
{items.map((item) => (
45-
<Collapsible
46-
key={item.title}
47-
asChild
48-
open={!isItemCollapsed(item.title)}
49-
onOpenChange={() => toggleItem(item.title)}
50-
className="group/collapsible"
51-
>
52-
<SidebarMenuItem>
53-
<CollapsibleTrigger asChild>
54-
<SidebarMenuButton
55-
className="cursor-pointer"
56-
tooltip={item.title}
57-
onClick={() => handleClick(item.url)}
58-
>
59-
{item.icon && <item.icon />}
60-
<span>{item.title}</span>
61-
{(item.items?.length || 0) > 0 && (
62-
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
63-
)}
64-
</SidebarMenuButton>
65-
</CollapsibleTrigger>
66-
{(item.items?.length || 0) > 0 && (
67-
<CollapsibleContent>
68-
<SidebarMenuSub>
69-
{item.items?.map((subItem) => (
70-
<SidebarMenuSubItem key={subItem.title}>
71-
<SidebarMenuSubButton asChild>
72-
<Link href={subItem.url}>
73-
<span>{subItem.title}</span>
74-
</Link>
75-
</SidebarMenuSubButton>
76-
</SidebarMenuSubItem>
77-
))}
78-
</SidebarMenuSub>
79-
</CollapsibleContent>
80-
)}
81-
</SidebarMenuItem>
82-
</Collapsible>
83-
))}
47+
{items.map((item) => {
48+
const hasNestedItems = (item.items?.length || 0) > 0;
49+
const isCollapsed = state === 'collapsed';
50+
51+
if (hasNestedItems && isCollapsed) {
52+
return (
53+
<SidebarMenuItem key={item.title}>
54+
<SidebarHoverMenu items={item.items || []}>
55+
<SidebarMenuButton
56+
className="cursor-pointer"
57+
tooltip={item.title}
58+
onClick={() => handleClick(item.url)}
59+
>
60+
{item.icon && <item.icon />}
61+
<span>{item.title}</span>
62+
</SidebarMenuButton>
63+
</SidebarHoverMenu>
64+
</SidebarMenuItem>
65+
);
66+
}
67+
68+
return (
69+
<Collapsible
70+
key={item.title}
71+
asChild
72+
open={!isItemCollapsed(item.title)}
73+
onOpenChange={() => toggleItem(item.title)}
74+
className="group/collapsible"
75+
>
76+
<SidebarMenuItem>
77+
<CollapsibleTrigger asChild>
78+
<SidebarMenuButton
79+
className="cursor-pointer"
80+
tooltip={item.title}
81+
onClick={() => handleClick(item.url)}
82+
>
83+
{item.icon && <item.icon />}
84+
<span>{item.title}</span>
85+
{hasNestedItems && (
86+
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
87+
)}
88+
</SidebarMenuButton>
89+
</CollapsibleTrigger>
90+
{hasNestedItems && (
91+
<CollapsibleContent>
92+
<SidebarMenuSub>
93+
{item.items?.map((subItem) => (
94+
<SidebarMenuSubItem key={subItem.title}>
95+
<SidebarMenuSubButton asChild>
96+
<Link href={subItem.url}>
97+
<span>{subItem.title}</span>
98+
</Link>
99+
</SidebarMenuSubButton>
100+
</SidebarMenuSubItem>
101+
))}
102+
</SidebarMenuSub>
103+
</CollapsibleContent>
104+
)}
105+
</SidebarMenuItem>
106+
</Collapsible>
107+
);
108+
})}
84109
</SidebarMenu>
85110
</SidebarGroup>
86111
);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { createPortal } from 'react-dom';
5+
import { cn } from '@/lib/utils';
6+
import { useSidebar } from '@/components/ui/sidebar';
7+
import Link from 'next/link';
8+
9+
interface HoverMenuItem {
10+
title: string;
11+
url: string;
12+
}
13+
14+
interface SidebarHoverMenuProps {
15+
items: HoverMenuItem[];
16+
children: React.ReactNode;
17+
className?: string;
18+
}
19+
20+
export function SidebarHoverMenu({ items, children, className }: SidebarHoverMenuProps) {
21+
const { state } = useSidebar();
22+
const [isHovered, setIsHovered] = React.useState(false);
23+
const [buttonRect, setButtonRect] = React.useState<DOMRect | null>(null);
24+
const timeoutRef = React.useRef<number | undefined>(undefined);
25+
const buttonRef = React.useRef<HTMLDivElement>(null);
26+
27+
const handleMouseEnter = () => {
28+
if (timeoutRef.current) {
29+
clearTimeout(timeoutRef.current);
30+
}
31+
if (buttonRef.current) {
32+
setButtonRect(buttonRef.current.getBoundingClientRect());
33+
}
34+
setIsHovered(true);
35+
};
36+
37+
const handleMouseLeave = () => {
38+
timeoutRef.current = window.setTimeout(() => {
39+
setIsHovered(false);
40+
}, 150);
41+
};
42+
43+
React.useEffect(() => {
44+
return () => {
45+
if (timeoutRef.current) {
46+
clearTimeout(timeoutRef.current);
47+
}
48+
};
49+
}, []);
50+
51+
if (state !== 'collapsed') {
52+
return <>{children}</>;
53+
}
54+
55+
const menuContent = isHovered && items.length > 0 && buttonRect && (
56+
<div
57+
className={cn(
58+
'fixed z-50 min-w-[200px] rounded-md border bg-secondary p-2 shadow-lg',
59+
'animate-in fade-in-0 zoom-in-95 duration-200',
60+
className
61+
)}
62+
onMouseEnter={handleMouseEnter}
63+
onMouseLeave={handleMouseLeave}
64+
style={{
65+
position: 'fixed',
66+
left: `${buttonRect.right + 8}px`,
67+
top: `${buttonRect.top}px`,
68+
zIndex: 9999
69+
}}
70+
>
71+
<div className="space-y-1">
72+
{items.map((item, index) => (
73+
<React.Fragment key={item.title}>
74+
<div className="px-2 py-1.5 rounded-sm hover:bg-background hover:text-muted-foreground transition-colors cursor-pointer">
75+
<Link href={item.url} className="block text-sm font-medium">
76+
{item.title}
77+
</Link>
78+
</div>
79+
{index < items.length - 1 && (
80+
<div className="border-t border-muted my-1"></div>
81+
)}
82+
</React.Fragment>
83+
))}
84+
</div>
85+
</div>
86+
);
87+
88+
return (
89+
<>
90+
<div
91+
ref={buttonRef}
92+
className="relative"
93+
onMouseEnter={handleMouseEnter}
94+
onMouseLeave={handleMouseLeave}
95+
>
96+
{children}
97+
</div>
98+
{typeof window !== 'undefined' && menuContent && createPortal(menuContent, document.body)}
99+
</>
100+
);
101+
}

0 commit comments

Comments
 (0)