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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user