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:
@@ -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 type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
@@ -185,6 +185,33 @@ export function Sidebar() {
|
||||
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 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -247,45 +274,7 @@ export function Sidebar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Flyout panel (icon rail only) */}
|
||||
{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>
|
||||
)}
|
||||
{/* Flyout rendered as drawer below */}
|
||||
</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) => {
|
||||
return (
|
||||
<div key={key} data-flyout-key={key}>
|
||||
{renderRailItem(item, key + '-inner')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
|
||||
? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) ||
|
||||
footerItems.find((_, i) => `footer-${i}` === flyoutIndex)
|
||||
: null
|
||||
|
||||
/* ── Main render ──────────────────────────────────── */
|
||||
|
||||
@@ -419,40 +405,91 @@ export function Sidebar() {
|
||||
|
||||
/* Icon Rail (default) — 5 grouped icons, Sentry-style */
|
||||
return (
|
||||
<nav
|
||||
ref={sidebarRef}
|
||||
className="sidebar flex flex-col items-center h-full"
|
||||
style={{ background: '#0f1118', borderRight: '1px solid #1e2130' }}
|
||||
onWheel={handleWheel}
|
||||
<div
|
||||
className="flex h-full"
|
||||
onMouseLeave={closeFlyout}
|
||||
>
|
||||
{/* Grouped nav items */}
|
||||
<div className="flex flex-col items-center w-full px-1.5 pt-3 space-y-1">
|
||||
{railGroups.map((item, i) => (
|
||||
<div key={`rail-${i}`} data-flyout-key={`rail-${i}`}>
|
||||
{renderRailItem(item, `rail-${i}-inner`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rail */}
|
||||
<nav
|
||||
ref={sidebarRef}
|
||||
className="sidebar flex flex-col items-center h-full shrink-0"
|
||||
style={{ background: '#0f1118', borderRight: '1px solid #1e2130', width: '72px' }}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* 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 */}
|
||||
<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) => (
|
||||
<div key={`footer-${i}`} data-flyout-key={`footer-${i}`}>
|
||||
{renderRailItem(item, `footer-${i}-inner`)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarPinned}
|
||||
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
|
||||
title="Pin sidebar"
|
||||
{/* 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' }}>
|
||||
{footerItems.map((item, i) => renderRailItem(item, `footer-${i}`))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarPinned}
|
||||
className="flex flex-col items-center justify-center rounded-lg px-1 py-2 text-[#6b7280] hover:text-[#848b9b] transition-colors"
|
||||
title="Pin sidebar"
|
||||
>
|
||||
<Pin size={18} className="opacity-60 hover:opacity-85" />
|
||||
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
|
||||
</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" />
|
||||
<span className="mt-1 text-[0.5625rem] font-mono leading-tight">Pin</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
className="flex flex-col h-full flex-1 overflow-y-auto py-4 px-2"
|
||||
style={{
|
||||
background: '#0f1118',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user