Files
goodbuddy/src/renderer/src/AnchoredMenu.tsx
T
mesalogo d30f794184 fix: expand runtime checklists without shifting chat content
Render checklist details in an anchored floating panel with bounded scrolling, outside dismissal and Escape focus restoration. Reuse the shared overlay mechanism and update layout documentation and regression coverage.

Validation: 25 focused tests, Electron checklist layout and keyboard scenarios, and lint passed. Full-suite and typecheck blockers outside this change were reported separately.

Release note: Runtime execution checklists now expand in a floating panel without pushing conversation content down.
2026-09-30 02:36:50 +08:00

100 lines
4.7 KiB
TypeScript

import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'
import { flushSync } from 'react-dom'
import { FloatingPortal } from './FloatingPortal'
import './anchored-menu.css'
export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280, role = 'menu', className = '', onClose, children }: {
anchorRef: RefObject<HTMLButtonElement | null>
id: string
label: string
width?: number | 'anchor'
role?: 'menu' | 'group'
className?: string
onClose: () => void
children: ReactNode
}): React.JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const dismiss = useEffectEvent(onClose)
const position = useEffectEvent(() => {
const menu = menuRef.current
const anchor = anchorRef.current
if (!menu || !anchor) return
const viewport = window.visualViewport
const left = viewport?.offsetLeft ?? 0
const top = viewport?.offsetTop ?? 0
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const menuWidth = Math.max(0, Math.min(preferredWidth === 'anchor' ? rect.width : preferredWidth, width - 32))
menu.style.width = `${menuWidth}px`
const above = Math.max(0, Math.min(height - 32, rect.top - top - 24))
const below = Math.max(0, Math.min(height - 32, top + height - rect.bottom - 24))
const openBelow = menu.scrollHeight > above && below > above
const availableHeight = openBelow ? below : above
menu.style.maxHeight = `${role === 'group' ? Math.min(availableHeight, 400, height / 2) : availableHeight}px`
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
menu.style.top = `${Math.max(top + 16, openBelow ? rect.bottom + 8 : Math.min(rect.top - 8, top + height - 16) - menu.getBoundingClientRect().height)}px`
})
useLayoutEffect(() => { position() })
useEffect(() => {
const menu = menuRef.current!
const anchor = anchorRef.current
const focusFirst = (): void => {
if (document.activeElement === document.body || document.activeElement === menu) {
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
}
}
menu.focus()
focusFirst()
const outside = (event: Event): void => {
if (!menu.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
}
const keydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
flushSync(() => dismiss())
anchor?.focus()
return
}
if (!menu.contains(event.target as Node)) return
if (event.key === 'Tab') {
flushSync(() => dismiss())
anchor?.focus()
return
}
if (role !== 'menu' || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}
const reposition = (): void => { position(); focusFirst() }
const observer = new ResizeObserver(reposition)
observer.observe(menu)
if (anchor) observer.observe(anchor)
document.addEventListener('pointerdown', outside)
document.addEventListener('focusin', outside)
document.addEventListener('keydown', keydown, true)
window.addEventListener('resize', reposition)
window.addEventListener('scroll', reposition, true)
window.visualViewport?.addEventListener('resize', reposition)
window.visualViewport?.addEventListener('scroll', reposition)
return () => {
observer.disconnect()
document.removeEventListener('pointerdown', outside)
document.removeEventListener('focusin', outside)
document.removeEventListener('keydown', keydown, true)
window.removeEventListener('resize', reposition)
window.removeEventListener('scroll', reposition, true)
window.visualViewport?.removeEventListener('resize', reposition)
window.visualViewport?.removeEventListener('scroll', reposition)
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
}
}, [anchorRef, role])
return <FloatingPortal anchorRef={anchorRef}><div ref={menuRef} id={id} role={role} aria-label={label}
tabIndex={-1} className={`anchored-menu ${className}`}>{children}</div></FloatingPortal>
}