fix(ui): the context menu answers the keyboard and closes when the page moves

The shared context menu closed only on a click elsewhere: Escape did
nothing, the arrow keys did nothing, and a scroll left the fixed menu
hanging over content that had moved on. It now takes focus on its first
item, moves with the arrow keys, and closes on Escape, scroll and
resize. rules-of-hooks is an error again; its one hit was a test helper
named like a hook.
This commit is contained in:
Maurice
2026-10-07 17:03:47 +02:00
parent 094a46a3cf
commit 0a1a5092f7
4 changed files with 53 additions and 8 deletions
@@ -114,4 +114,24 @@ describe('ContextMenu', () => {
expect(menu.style.left).toBe('192px')
expect(menu.style.top).toBe('172px')
})
it('FE-W4CTX-011: Escape, a scroll and a resize close it', () => {
const onClose = vi.fn()
render(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Open' }] }} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.scroll(window)
fireEvent(window, new Event('resize'))
expect(onClose).toHaveBeenCalledTimes(3)
})
it('FE-W4CTX-012: focus starts on the first usable item and the arrow keys move it, wrapping', () => {
render(<ContextMenu menu={{ x: 0, y: 0, items: [{ label: 'Off', disabled: true }, { label: 'Open' }, { label: 'Rename' }] }} onClose={() => {}} />)
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: 'Open' }), { key: 'ArrowDown' })
expect(screen.getByRole('button', { name: 'Rename' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: 'Rename' }), { key: 'ArrowDown' })
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
fireEvent.keyDown(screen.getByRole('button', { name: 'Open' }), { key: 'ArrowUp' })
expect(screen.getByRole('button', { name: 'Rename' })).toHaveFocus()
})
})
+26 -1
View File
@@ -44,14 +44,39 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
useEffect(() => {
if (!menu) return
const handler = () => onClose()
// A fixed menu stays where it opened, so it closes when the page under it
// moves or the window changes size, and Escape closes it like any menu.
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('click', handler)
document.addEventListener('contextmenu', handler)
document.addEventListener('keydown', onKey)
window.addEventListener('scroll', handler, true)
window.addEventListener('resize', handler)
return () => {
document.removeEventListener('click', handler)
document.removeEventListener('contextmenu', handler)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', handler, true)
window.removeEventListener('resize', handler)
}
}, [menu, onClose])
// Keyboard users land on the first item and move with the arrow keys.
useEffect(() => {
if (!menu) return
ref.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true })
}, [menu])
const moveFocus = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()
const items = [...(ref.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])]
if (items.length === 0) return
const at = items.indexOf(document.activeElement as HTMLButtonElement)
const next = e.key === 'ArrowDown' ? (at + 1) % items.length : (at - 1 + items.length) % items.length
items[next]?.focus()
}
useLayoutEffect(() => {
if (!menu || !ref.current) return
const el = ref.current
@@ -69,7 +94,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
if (!menu) return null
return createPortal(
<div ref={ref} className="trek-popover-enter" style={{
<div ref={ref} onKeyDown={moveFocus} className="trek-popover-enter" style={{
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
border: '1px solid var(--border-primary)',