Files
hermes-agent/apps/desktop/src/components/ui/fan-menu.tsx
T
brooklyn! c7580f0e06 feat(desktop): FanMenu primitive and a floating Button variant
One hub control that fans its sibling toggles out on hover — a column, a
row split around the hub, or an arc — with the discs portalled past any
overflow-hidden parent and re-anchored on scroll and resize. Hover state,
geometry and open/close timing live in the primitive so a consumer only
re-renders when its own items change.

A document-level pointer watchdog closes the fan when enter/leave is
skipped: a disc that goes disabled under the pointer (a toggle turning
pending right after its click) stops receiving pointer events, so its
pointerleave never fires and the fan stayed open.

Discs wear the new Button `floating` variant off (popover fill +
shadow-md, glyph-only hover) and `default` on, so a toggled state is an
opaque primary disc rather than a translucent tint.
2026-09-16 02:56:57 -05:00

398 lines
12 KiB
TypeScript

import {
type CSSProperties,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type FanDirection = 'horizontal' | 'vertical' | 'arc'
export interface FanMenuItem {
id: string
icon: ReactNode
label: string
/** A toggle that is ON. Solid primary disc, so it reads from across the room. */
active?: boolean
disabled?: boolean
onSelect: () => void
}
export interface FanMenuProps {
/** The always-visible control. Hovering or focusing it fans `items` out. */
hub: FanMenuItem & { className?: string }
items: readonly FanMenuItem[]
direction?: FanDirection
/** Accessible name for the fanned group. */
label: string
/** Gap between discs, px. Discs are the hub's measured size; the step
* between them is that size plus this. */
gap?: number
/** Where each disc's tooltip sits. Defaults to left, so tips hang off the
* side of the fan rather than covering the next disc up. */
tipAnchor?: 'top' | 'left' | 'right' | 'bottom'
/** The hairline caret on the hub that says "there is more". */
hint?: boolean
}
interface Point {
x: number
y: number
}
/** Quick with a touch of overshoot: the discs pop into place, not float. */
const POP = 'cubic-bezier(0.2, 1.25, 0.4, 1)'
const OPEN_MS = 160
const CLOSE_MS = 100
const STAGGER_MS = 20
/** Grace while the pointer crosses the gap between the hub and a disc. */
const HIDE_DELAY_MS = 150
/** Widest an arc will spread before it grows its radius instead. */
const ARC_MAX_SPAN_DEG = 150
/**
* Disc centres relative to the hub, in px. `step` is one disc plus one gap,
* so neighbours never touch in any direction.
*
* - vertical: a column straight up, in order.
* - horizontal: reading order, split around the hub — the first half to the
* left, the rest to the right, the extra one (odd counts) on the right.
* - arc: a semicircle above, spread so adjacent discs sit one `step` apart
* along the chord; past 150° the radius grows to fit instead.
*/
export function fanOffsets(direction: FanDirection, count: number, step: number): Point[] {
const out: Point[] = []
if (direction === 'vertical') {
for (let i = 0; i < count; i++) {
out.push({ x: 0, y: -(i + 1) * step })
}
return out
}
if (direction === 'horizontal') {
const left = Math.floor(count / 2)
for (let i = 0; i < count; i++) {
out.push({ x: i < left ? -(left - i) * step : (i - left + 1) * step, y: 0 })
}
return out
}
if (count === 1) {
return [{ x: 0, y: -step }]
}
let radius = step * 1.15
let delta = 2 * Math.asin(Math.min(1, step / (2 * radius)))
const maxSpan = (ARC_MAX_SPAN_DEG * Math.PI) / 180
if (delta * (count - 1) > maxSpan) {
delta = maxSpan / (count - 1)
radius = step / (2 * Math.sin(delta / 2))
}
const start = Math.PI / 2 + (delta * (count - 1)) / 2
for (let i = 0; i < count; i++) {
const a = start - i * delta
out.push({ x: Math.cos(a) * radius, y: -Math.sin(a) * radius })
}
return out
}
const sameRect = (a: DOMRect | null, b: DOMRect) =>
a !== null && a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height
/**
* One control that fans its siblings out on hover — a column, a row centred
* on it, or an arc. The hub stays in the flow where the consumer puts it; the
* discs portal to `body` so an `overflow-hidden` parent cannot clip them, and
* are re-anchored on scroll and resize.
*
* Hover, geometry and open/close timing live here. The consumer only
* re-renders when its own items change; pointer traffic never reaches it.
*/
export function FanMenu({
direction = 'vertical',
gap = 4,
hint = true,
hub,
items,
label,
tipAnchor = 'left'
}: FanMenuProps) {
const anchorRef = useRef<HTMLSpanElement | null>(null)
const [rect, setRect] = useState<DOMRect | null>(null)
const [open, setOpen] = useState(false)
const hideTimer = useRef<number | undefined>(undefined)
const unmountTimer = useRef<number | undefined>(undefined)
const clearTimers = useCallback(() => {
window.clearTimeout(hideTimer.current)
window.clearTimeout(unmountTimer.current)
hideTimer.current = undefined
}, [])
const measure = useCallback(() => {
const el = anchorRef.current
if (el) {
const next = el.getBoundingClientRect()
setRect(current => (sameRect(current, next) ? current : next))
}
}, [])
const show = useCallback(() => {
clearTimers()
measure()
setOpen(true)
}, [clearTimers, measure])
const hide = useCallback(() => {
clearTimers()
setOpen(false)
unmountTimer.current = window.setTimeout(() => setRect(null), CLOSE_MS + STAGGER_MS * items.length)
}, [clearTimers, items.length])
const hideSoon = useCallback(() => {
window.clearTimeout(hideTimer.current)
hideTimer.current = window.setTimeout(hide, HIDE_DELAY_MS)
}, [hide])
const stayOpen = useCallback(() => {
window.clearTimeout(hideTimer.current)
hideTimer.current = undefined
}, [])
useEffect(() => clearTimers, [clearTimers])
// The hub can move under a parked fan: its container grows, the window
// resizes, an ancestor scrolls.
const anchored = rect !== null
useEffect(() => {
if (!anchored) {
return
}
window.addEventListener('scroll', measure, true)
window.addEventListener('resize', measure)
return () => {
window.removeEventListener('scroll', measure, true)
window.removeEventListener('resize', measure)
}
}, [anchored, measure])
// Enter/leave alone can strand the fan open. A disc that goes `disabled`
// under the pointer (a toggle turning pending right after the click) stops
// receiving pointer events, so its `pointerleave` never fires; a fast
// flick can skip the leave the same way. Watch the document while open and
// close as soon as the pointer is over neither the hub nor a disc.
useEffect(() => {
if (!open) {
return
}
const onMove = (event: PointerEvent) => {
const target = event.target instanceof Element ? event.target : null
const inside =
target !== null &&
(anchorRef.current?.contains(target) === true || target.closest('[data-slot="fan-menu"]') !== null)
if (inside) {
stayOpen()
} else if (hideTimer.current === undefined) {
hideSoon()
}
}
document.addEventListener('pointermove', onMove, true)
return () => document.removeEventListener('pointermove', onMove, true)
}, [hideSoon, open, stayOpen])
const leaveUnlessWithin = (event: React.FocusEvent<HTMLElement>, then: () => void) => {
if (!(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) {
then()
}
}
return (
<>
{/* The wrapper takes the hover, not the button: a disabled hub swallows
pointer events, and the fan must still open around it. */}
<span
className="relative inline-flex shrink-0"
data-slot="fan-menu-anchor"
onBlur={event => leaveUnlessWithin(event, hideSoon)}
onFocus={show}
onPointerEnter={show}
onPointerLeave={hideSoon}
ref={anchorRef}
>
<Tip label={hub.label} side={tipAnchor}>
<Button
aria-expanded={open}
aria-haspopup="true"
aria-label={hub.label}
aria-pressed={hub.active}
className={hub.className}
disabled={hub.disabled}
onClick={hub.onSelect}
size="icon"
type="button"
variant="ghost"
>
{hub.icon}
</Button>
</Tip>
{hint ? (
<Codicon
className={cn(
'pointer-events-none absolute -top-px right-0 text-(--ui-text-quaternary) transition-opacity duration-100',
open && 'opacity-0'
)}
name="chevron-up"
size="0.5rem"
/>
) : null}
</span>
{rect
? createPortal(
<FanCluster
direction={direction}
items={items}
label={label}
onClose={hide}
onPointerEnter={stayOpen}
onPointerLeave={hideSoon}
open={open}
rect={rect}
step={rect.width + gap}
tipAnchor={tipAnchor}
/>,
document.body
)
: null}
</>
)
}
function discStyle(target: Point, index: number, entered: boolean): CSSProperties {
// Folded, a disc sits halfway to its slot rather than under the hub: one
// that mounts beneath a parked pointer picks up :hover and keeps it (a
// transform-only move never re-hit-tests), so it would pop out pre-lit.
const k = entered ? 1 : 0.5
const delay = index * STAGGER_MS
return {
opacity: entered ? 1 : 0,
scale: entered ? '1' : '0.5',
translate: `${(target.x * k).toFixed(2)}px ${(target.y * k).toFixed(2)}px`,
transition: entered
? `translate ${OPEN_MS}ms ${POP} ${delay}ms, scale ${OPEN_MS}ms ${POP} ${delay}ms, opacity 80ms ease-out ${delay}ms`
: `translate ${CLOSE_MS}ms ease-in, scale ${CLOSE_MS}ms ease-in, opacity ${CLOSE_MS}ms ease-in`
}
}
const FanCluster = memo(function FanCluster({
direction,
items,
label,
onClose,
onPointerEnter,
onPointerLeave,
open,
rect,
step,
tipAnchor
}: {
direction: FanDirection
items: readonly FanMenuItem[]
label: string
onClose: () => void
onPointerEnter: () => void
onPointerLeave: () => void
open: boolean
rect: DOMRect
step: number
tipAnchor: NonNullable<FanMenuProps['tipAnchor']>
}) {
const rootRef = useRef<HTMLDivElement | null>(null)
// Mount folded, then unfold on the next style pass so the translate
// actually transitions instead of landing in place. The layout read forces
// Chromium to resolve the folded style before the change.
const [entered, setEntered] = useState(false)
useLayoutEffect(() => {
if (open) {
void rootRef.current?.offsetWidth
}
setEntered(open)
}, [open])
const offsets = fanOffsets(direction, items.length, step)
return (
// The root is the hub's footprint and takes no pointer events itself, so
// the real hub underneath keeps its hover; only the discs are hittable.
<div
aria-label={label}
className="pointer-events-none fixed z-(--z-over-modal)"
data-direction={direction}
data-slot="fan-menu"
data-state={entered ? 'open' : 'closed'}
onKeyDown={event => {
if (event.key === 'Escape') {
event.stopPropagation()
onClose()
}
}}
ref={rootRef}
role="group"
style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }}
>
{items.map((item, index) => (
<Tip key={item.id} label={item.label} side={tipAnchor}>
<Button
aria-label={item.label}
aria-pressed={item.active}
className={cn(
'absolute inset-0 rounded-full shadow-md',
entered ? 'pointer-events-auto' : 'pointer-events-none'
)}
disabled={item.disabled}
onClick={item.onSelect}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
size="inline"
style={discStyle(offsets[index], index, entered)}
type="button"
variant={item.active ? 'default' : 'floating'}
>
{item.icon}
</Button>
</Tip>
))}
</div>
)
})