refactor: replace flyout popups with full-height resizable drawer

Sentry-style drawer slides out from rail edge, fills viewport height.
Drag handle on right edge to resize (180-400px range).
No more tooltip-style popups that cause layout jitter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-22 01:26:25 -04:00
parent 00ab9f1832
commit 812a22b2b1

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
@@ -185,6 +185,33 @@ export function Sidebar() {
if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current) if (flyoutTimeout.current) clearTimeout(flyoutTimeout.current)
} }
/* ── Drawer resize ───────────────────────────────── */
const [drawerWidth, setDrawerWidth] = useState(240)
const isResizing = useRef(false)
const handleResizeStart = (e: ReactPointerEvent) => {
e.preventDefault()
isResizing.current = true
const startX = e.clientX
const startWidth = drawerWidth
const onMove = (ev: globalThis.PointerEvent) => {
if (!isResizing.current) return
const newWidth = Math.max(180, Math.min(400, startWidth + (ev.clientX - startX)))
setDrawerWidth(newWidth)
}
const onUp = () => {
isResizing.current = false
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}
/* Close flyout on Escape */ /* Close flyout on Escape */
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -247,45 +274,7 @@ export function Sidebar() {
</span> </span>
</Link> </Link>
{/* Flyout panel (icon rail only) */} {/* Flyout rendered as drawer below */}
{hasChildren && !sidebarPinned && flyoutIndex === key && (
<div
className="absolute left-full top-0 z-50 ml-1"
style={{
maxHeight: '70vh',
overflowY: 'auto',
}}
onMouseEnter={keepFlyout}
onMouseLeave={closeFlyout}
>
<div
className="w-[220px] rounded-lg p-2 animate-fade-in"
style={{
background: '#14161d',
border: '1px solid #1e2130',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}
>
{item.children!.map(child => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center justify-between rounded-md px-3 py-2 text-[0.8125rem] transition-colors',
isChildActive(child)
? 'bg-[rgba(34,211,238,0.10)] text-[#67e8f9]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<span>{child.label}</span>
{child.count !== undefined && (
<span className="text-[0.6875rem] font-mono text-[#4f5666]">{child.count}</span>
)}
</Link>
))}
</div>
</div>
)}
</div> </div>
) )
} }
@@ -364,15 +353,12 @@ export function Sidebar() {
) )
} }
/* ── Flyout positioning: use data attribute for lookup ── */ /* ── Find active flyout group for drawer ── */
const renderRailItemWithRef = (item: NavEntry, key: string) => { const activeFlyoutGroup = flyoutIndex && !sidebarPinned
return ( ? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) ||
<div key={key} data-flyout-key={key}> footerItems.find((_, i) => `footer-${i}` === flyoutIndex)
{renderRailItem(item, key + '-inner')} : null
</div>
)
}
/* ── Main render ──────────────────────────────────── */ /* ── Main render ──────────────────────────────────── */
@@ -419,40 +405,91 @@ export function Sidebar() {
/* Icon Rail (default) — 5 grouped icons, Sentry-style */ /* Icon Rail (default) — 5 grouped icons, Sentry-style */
return ( return (
<nav <div
ref={sidebarRef} className="flex h-full"
className="sidebar flex flex-col items-center h-full" onMouseLeave={closeFlyout}
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
onWheel={handleWheel}
> >
{/* Grouped nav items */} {/* Rail */}
<div className="flex flex-col items-center w-full px-1.5 pt-3 space-y-1"> <nav
{railGroups.map((item, i) => ( ref={sidebarRef}
<div key={`rail-${i}`} data-flyout-key={`rail-${i}`}> className="sidebar flex flex-col items-center h-full shrink-0"
{renderRailItem(item, `rail-${i}-inner`)} style={{ background: '#0f1118', borderRight: '1px solid #1e2130', width: '72px' }}
</div> onWheel={handleWheel}
))} >
</div> {/* Grouped nav items */}
<div className="flex flex-col items-center w-full px-1.5 pt-3 space-y-1">
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))}
</div>
<div className="flex-1" /> <div className="flex-1" />
{/* Footer: Account + Pin */} {/* Footer: Account + Pin */}
<div className="flex flex-col items-center w-full px-1.5 pb-5 pt-2 space-y-1" style={{ borderTop: '1px solid #1e2130' }}> <div className="flex flex-col items-center w-full px-1.5 pb-5 pt-2 space-y-1" style={{ borderTop: '1px solid #1e2130' }}>
{footerItems.map((item, i) => ( {footerItems.map((item, i) => renderRailItem(item, `footer-${i}`))}
<div key={`footer-${i}`} data-flyout-key={`footer-${i}`}> <button
{renderRailItem(item, `footer-${i}-inner`)} type="button"
</div> onClick={toggleSidebarPinned}
))} className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
<button title="Pin sidebar"
type="button" >
onClick={toggleSidebarPinned} <Pin size={18} className="opacity-60 hover:opacity-85" />
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors" <span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
title="Pin sidebar" </button>
</div>
</nav>
{/* Drawer panel — full height, resizable */}
{activeFlyoutGroup && activeFlyoutGroup.children && (
<div
className="flex h-full shrink-0"
onMouseEnter={keepFlyout}
style={{ width: drawerWidth }}
> >
<Pin size={18} className="opacity-60 hover:opacity-85" /> <div
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span> className="flex flex-col h-full flex-1 overflow-y-auto py-4 px-2"
</button> style={{
</div> background: '#0f1118',
</nav> borderRight: '1px solid #1e2130',
boxShadow: '4px 0 12px rgba(0,0,0,0.2)',
}}
>
{/* Drawer header */}
<div className="px-3 mb-3">
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#4f5666]">
{activeFlyoutGroup.label}
</h3>
</div>
{/* Drawer items */}
<div className="space-y-0.5 px-1">
{activeFlyoutGroup.children.map(child => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center justify-between rounded-md px-3 py-2 text-[0.8125rem] transition-colors',
isChildActive(child)
? 'bg-[rgba(34,211,238,0.10)] text-[#67e8f9]'
: 'text-[#848b9b] hover:bg-[#191c25] hover:text-[#e2e5eb]'
)}
>
<span>{child.label}</span>
{child.count !== undefined && (
<span className="text-[0.6875rem] font-mono text-[#4f5666]">{child.count}</span>
)}
</Link>
))}
</div>
</div>
{/* Resize handle */}
<div
className="w-1 cursor-col-resize hover:bg-[#22d3ee]/20 active:bg-[#22d3ee]/30 transition-colors shrink-0"
onPointerDown={handleResizeStart}
title="Drag to resize"
/>
</div>
)}
</div>
) )
} }