Skip to content

Commit 73b8caf

Browse files
authored
fix: app layout sidebar hover intent (#277)
* feat: app layout sidebar improvements * chore: undo lock * fix: proper hover intent * chore: delay * chore: public prop
1 parent 946b6ad commit 73b8caf

File tree

3 files changed

+105
-5
lines changed

3 files changed

+105
-5
lines changed

src/components/AppLayout/context.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export interface AppLayoutContextType {
1717
keybinds: {
1818
toggle: KeybindConfig
1919
}
20+
21+
/**
22+
* If the sidebar is collapsed, should it expand when hovered?
23+
*/
24+
hoverExpandsSidebar: boolean
25+
26+
// track if sidebar was expanded by hover vs manual toggle
27+
_expandedByHover: boolean
28+
_setExpandedByHover: (expandedByHover: boolean) => void
2029
}
2130

2231
export const AppLayoutContext = createContext<AppLayoutContextType>({
@@ -28,4 +37,7 @@ export const AppLayoutContext = createContext<AppLayoutContextType>({
2837
description: 'Toggle',
2938
},
3039
},
40+
hoverExpandsSidebar: true,
41+
_expandedByHover: false,
42+
_setExpandedByHover: () => {},
3143
})

src/components/AppLayout/index.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import React, {
44
isValidElement,
55
PropsWithChildren,
66
HTMLAttributes,
7+
useRef,
8+
useEffect,
79
} from 'react'
810
import { Slot } from '@radix-ui/react-slot'
911
import { Icon } from '../Icon'
@@ -125,19 +127,85 @@ interface AppLayoutSidebarProps {
125127
}
126128

127129
const AppLayoutSidebar = ({ children, className }: AppLayoutSidebarProps) => {
128-
const { collapsed } = useAppLayout()
130+
const {
131+
collapsed,
132+
setCollapsed,
133+
hoverExpandsSidebar,
134+
_expandedByHover,
135+
_setExpandedByHover,
136+
} = useAppLayout()
129137

130138
const [nav, rest] = partitionBy(Children.toArray(children), (child) => {
131139
if (!isValidElement(child)) return false
132140
const type = child.type as { displayName?: string }
133141
return type.displayName === 'AppLayout.Nav'
134142
})
135143

144+
const sidebarRef = useRef<HTMLDivElement>(null)
145+
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
146+
const expandedByHoverRef = useRef(_expandedByHover)
147+
148+
// Keep ref in sync with state
149+
useEffect(() => {
150+
expandedByHoverRef.current = _expandedByHover
151+
}, [_expandedByHover])
152+
153+
// Handle hover intent when the sidebar is collapsed
154+
useEffect(() => {
155+
const sidebar = sidebarRef.current
156+
if (!sidebar || !hoverExpandsSidebar) return
157+
158+
const handleMouseEnter = () => {
159+
// Only expand if currently collapsed
160+
if (!collapsed) return
161+
// Clear any existing timeout
162+
if (hoverTimeoutRef.current) {
163+
clearTimeout(hoverTimeoutRef.current)
164+
}
165+
166+
// Set a delay before expanding
167+
hoverTimeoutRef.current = setTimeout(() => {
168+
setCollapsed(false)
169+
_setExpandedByHover(true) // Mark as expanded by hover
170+
hoverTimeoutRef.current = null
171+
}, 250) // 250ms delay
172+
}
173+
174+
const handleMouseLeave = () => {
175+
// Clear the timeout if mouse leaves before delay completes
176+
if (hoverTimeoutRef.current) {
177+
clearTimeout(hoverTimeoutRef.current)
178+
hoverTimeoutRef.current = null
179+
}
180+
181+
// Only collapse if it was expanded by hover, not manually
182+
if (expandedByHoverRef.current) {
183+
setCollapsed(true)
184+
_setExpandedByHover(false)
185+
}
186+
}
187+
188+
// Add listeners when hover expansion is enabled
189+
sidebar.addEventListener('mouseenter', handleMouseEnter)
190+
sidebar.addEventListener('mouseleave', handleMouseLeave)
191+
192+
// Cleanup function - always runs when effect re-runs or component unmounts
193+
return () => {
194+
if (hoverTimeoutRef.current) {
195+
clearTimeout(hoverTimeoutRef.current)
196+
hoverTimeoutRef.current = null
197+
}
198+
sidebar.removeEventListener('mouseenter', handleMouseEnter)
199+
sidebar.removeEventListener('mouseleave', handleMouseLeave)
200+
}
201+
}, [hoverExpandsSidebar, collapsed, setCollapsed, _setExpandedByHover])
202+
136203
return (
137204
<motion.div
138205
initial={false}
139206
layout="position"
140207
className={cn('mt-4 flex w-fit flex-col items-start', className)}
208+
ref={sidebarRef}
141209
transition={{ duration: 0.25, type: 'spring', bounce: 0 }}
142210
>
143211
<div className="flex flex-col gap-4 px-2">
@@ -276,7 +344,8 @@ interface AppLayoutCollapseButtonProps extends PropsWithChildren {
276344
const AppLayoutCollapseButton = ({
277345
className,
278346
}: AppLayoutCollapseButtonProps) => {
279-
const { collapsed, setCollapsed, keybinds } = useAppLayout()
347+
const { collapsed, setCollapsed, keybinds, _setExpandedByHover } =
348+
useAppLayout()
280349
useAppLayoutKeys()
281350
return (
282351
<div className={cn('flex items-center gap-2', className)}>
@@ -285,7 +354,10 @@ const AppLayoutCollapseButton = ({
285354
<TooltipTrigger asChild>
286355
<button
287356
className="group typography-body-md hover:bg-accent hover:text-primary rounded-md p-1.5"
288-
onClick={() => setCollapsed(!collapsed)}
357+
onClick={() => {
358+
setCollapsed(!collapsed)
359+
_setExpandedByHover(false) // Reset hover state on manual toggle
360+
}}
289361
aria-label="Toggle sidebar"
290362
>
291363
<Icon

src/components/AppLayout/provider.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { PropsWithChildren, useState } from 'react'
1+
import { PropsWithChildren, useState, useEffect } from 'react'
22
import { AppLayoutContext, AppLayoutContextType } from './context'
33

44
interface AppLayoutProviderProps extends PropsWithChildren {
55
defaultCollapsed?: boolean
66
keybinds?: AppLayoutContextType['keybinds']
7+
hoverExpandsSidebar?: boolean
78
}
89

910
const defaultKeybinds = {
@@ -17,13 +18,28 @@ export const AppLayoutProvider = ({
1718
children,
1819
defaultCollapsed = false,
1920
keybinds,
21+
hoverExpandsSidebar = true,
2022
}: AppLayoutProviderProps) => {
2123
const finalKeybinds = keybinds ?? defaultKeybinds
2224
const [collapsed, setCollapsed] = useState(defaultCollapsed)
25+
const [expandedByHover, setExpandedByHover] = useState(false)
26+
27+
// respond to defaultCollapsed changes
28+
useEffect(() => {
29+
setCollapsed(defaultCollapsed)
30+
setExpandedByHover(false) // Reset hover state when default changes
31+
}, [defaultCollapsed])
2332

2433
return (
2534
<AppLayoutContext.Provider
26-
value={{ collapsed, setCollapsed, keybinds: finalKeybinds }}
35+
value={{
36+
collapsed,
37+
setCollapsed,
38+
keybinds: finalKeybinds,
39+
hoverExpandsSidebar,
40+
_expandedByHover: expandedByHover,
41+
_setExpandedByHover: setExpandedByHover,
42+
}}
2743
>
2844
{children}
2945
</AppLayoutContext.Provider>

0 commit comments

Comments
 (0)