fix(radial): use backdrop element for click-outside dismiss

Replace unreliable window event listener with transparent full-screen
backdrop element. Clicking anywhere outside the radial menu now properly
dismisses it. Also handles right-click on backdrop for dismiss.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-26 06:12:46 +00:00
commit 741d760760

View file

@ -44,24 +44,6 @@ export default function RadialMenu({
return () => window.removeEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey)
}, [open, onDismiss]) }, [open, onDismiss])
// Handle click outside
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
onDismiss?.()
}
}
// Delay to avoid triggering on the same click that opened the menu
const timer = setTimeout(() => {
window.addEventListener('click', handleClick)
}, 50)
return () => {
clearTimeout(timer)
window.removeEventListener('click', handleClick)
}
}, [open, onDismiss])
// Calculate which wedge the pointer is over // Calculate which wedge the pointer is over
const getWedgeAtPoint = useCallback((clientX, clientY) => { const getWedgeAtPoint = useCallback((clientX, clientY) => {
const dx = clientX - x const dx = clientX - x
@ -109,6 +91,17 @@ export default function RadialMenu({
onDismiss?.() onDismiss?.()
}, [getWedgeAtPoint, lat, lon, onDismiss]) }, [getWedgeAtPoint, lat, lon, onDismiss])
// Handle backdrop click (dismiss menu)
const handleBackdropClick = useCallback((e) => {
e.stopPropagation()
onDismiss?.()
}, [onDismiss])
// Prevent menu container clicks from reaching backdrop
const handleContainerClick = useCallback((e) => {
e.stopPropagation()
}, [])
// Generate wedge paths // Generate wedge paths
const generateWedgePath = (index) => { const generateWedgePath = (index) => {
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180) const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
@ -144,126 +137,143 @@ export default function RadialMenu({
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y)) const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
const content = ( const content = (
<div <>
ref={containerRef} {/* Full-screen transparent backdrop for dismiss */}
className="radial-menu-container" <div
style={{ onClick={handleBackdropClick}
position: 'fixed', onContextMenu={handleBackdropClick}
left: clampedX, style={{
top: clampedY, position: 'fixed',
zIndex: 9999, inset: 0,
transform: 'translate(-50%, -50%)', zIndex: 9998,
animation: 'radialFadeIn 100ms ease-out', background: 'transparent',
}} cursor: 'default',
onMouseMove={handlePointerMove} }}
onMouseUp={handlePointerUp} />
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp} {/* Radial menu container */}
> <div
<svg ref={containerRef}
width={outerRadius * 2 + 40} className="radial-menu-container"
height={outerRadius * 2 + 40} onClick={handleContainerClick}
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`} style={{
style={{ overflow: 'visible' }} position: 'fixed',
left: clampedX,
top: clampedY,
zIndex: 9999,
transform: 'translate(-50%, -50%)',
animation: 'radialFadeIn 100ms ease-out',
}}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
> >
{/* Wedges */} <svg
{wedges.map((wedge, i) => { width={outerRadius * 2 + 40}
const iconPos = getIconPosition(i) height={outerRadius * 2 + 40}
const Icon = wedge.icon viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
return ( style={{ overflow: 'visible' }}
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}> >
<path {/* Wedges */}
d={generateWedgePath(i)} {wedges.map((wedge, i) => {
fill="rgba(30, 28, 26, 0.85)" const iconPos = getIconPosition(i)
stroke="rgba(180, 160, 140, 0.3)" const Icon = wedge.icon
strokeWidth="1" return (
style={{ transition: 'fill 100ms ease' }} <g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
className="wedge-path" <path
/> d={generateWedgePath(i)}
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}> fill="rgba(30, 28, 26, 0.85)"
{Icon && ( stroke="rgba(180, 160, 140, 0.3)"
<Icon strokeWidth="1"
size={18} style={{ transition: 'fill 100ms ease' }}
stroke="rgba(230, 220, 210, 0.9)" className="wedge-path"
strokeWidth={1.5} />
style={{ transform: 'translate(-9px, -12px)' }} <g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
/> {Icon && (
)} <Icon
{wedge.requiresAuth && ( size={18}
<Lock stroke="rgba(230, 220, 210, 0.9)"
size={10} strokeWidth={1.5}
stroke="rgba(230, 220, 210, 0.6)" style={{ transform: 'translate(-9px, -12px)' }}
strokeWidth={1.5} />
style={{ transform: 'translate(4px, -14px)' }} )}
/> {wedge.requiresAuth && (
)} <Lock
<text size={10}
y={10} stroke="rgba(230, 220, 210, 0.6)"
textAnchor="middle" strokeWidth={1.5}
fontSize="9" style={{ transform: 'translate(4px, -14px)' }}
fill="rgba(230, 220, 210, 0.8)" />
style={{ pointerEvents: 'none', userSelect: 'none' }} )}
> <text
{wedge.label} y={10}
</text> textAnchor="middle"
fontSize="9"
fill="rgba(230, 220, 210, 0.8)"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{wedge.label}
</text>
</g>
</g> </g>
</g> )
) })}
})}
{/* Center disc */} {/* Center disc */}
<circle <circle
cx={0} cx={0}
cy={0} cy={0}
r={innerRadius - 2} r={innerRadius - 2}
fill="rgba(50, 45, 40, 0.95)" fill="rgba(50, 45, 40, 0.95)"
stroke="rgba(180, 160, 140, 0.4)" stroke="rgba(180, 160, 140, 0.4)"
strokeWidth="1" strokeWidth="1"
/> />
<text
y={-4}
textAnchor="middle"
fontSize="10"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
>
{lat?.toFixed(4)}
</text>
<text
y={8}
textAnchor="middle"
fontSize="10"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
>
{lon?.toFixed(4)}
</text>
{centerLabel && (
<text <text
y={20} y={-4}
textAnchor="middle" textAnchor="middle"
fontSize="9" fontSize="10"
fill="rgba(200, 180, 160, 0.9)" fontFamily="monospace"
style={{ fontStyle: 'italic' }} fill="rgba(230, 220, 210, 0.9)"
> >
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel} {lat?.toFixed(4)}
</text> </text>
)} <text
</svg> y={8}
textAnchor="middle"
fontSize="10"
fontFamily="monospace"
fill="rgba(230, 220, 210, 0.9)"
>
{lon?.toFixed(4)}
</text>
{centerLabel && (
<text
y={20}
textAnchor="middle"
fontSize="9"
fill="rgba(200, 180, 160, 0.9)"
style={{ fontStyle: 'italic' }}
>
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
</text>
)}
</svg>
<style>{` <style>{`
.radial-wedge.active .wedge-path { .radial-wedge.active .wedge-path {
fill: rgba(180, 160, 140, 0.4) !important; fill: rgba(180, 160, 140, 0.4) !important;
} }
.radial-wedge:hover .wedge-path { .radial-wedge:hover .wedge-path {
fill: rgba(180, 160, 140, 0.3); fill: rgba(180, 160, 140, 0.3);
} }
@keyframes radialFadeIn { @keyframes radialFadeIn {
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
} }
`}</style> `}</style>
</div> </div>
</>
) )
return createPortal(content, document.body) return createPortal(content, document.body)