'use client'; import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Icon, type IconName } from '../icons'; export interface MenuItem { type?: 'item'; label: string; icon?: IconName; onClick: () => void; danger?: boolean; disabled?: boolean; checked?: boolean; } export interface MenuSeparator { type: 'separator'; } export interface MenuLabel { type: 'label'; label: string; } export type MenuEntry = MenuItem | MenuSeparator | MenuLabel; interface MenuState { x: number; y: number; entries: MenuEntry[]; } // Module-level emitter — typically one gallery is interactive at a time. let current: MenuState | null = null; const listeners = new Set<() => void>(); function emit() { for (const l of listeners) l(); } export function openContextMenu(x: number, y: number, entries: MenuEntry[]) { current = { x, y, entries }; emit(); } export function closeContextMenu() { if (current) { current = null; emit(); } } /** Whether a context menu is open (it owns Escape while it is). */ export function isContextMenuOpen(): boolean { return current !== null; } function subscribe(cb: () => void) { listeners.add(cb); return () => listeners.delete(cb); } function getSnapshot() { return current; } /** Renders the active context menu (mounted once near the gallery root). */ export function ContextMenuHost() { const state = useSyncExternalStore(subscribe, getSnapshot, () => null); const ref = useRef(null); const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); useLayoutEffect(() => { if (!state || !ref.current) return; const rect = ref.current.getBoundingClientRect(); const pad = 8; const left = Math.min(state.x, window.innerWidth - rect.width - pad); const top = Math.min(state.y, window.innerHeight - rect.height - pad); setPos({ left: Math.max(pad, left), top: Math.max(pad, top) }); // Move keyboard focus to the first actionable item when the menu opens. ref.current.querySelector('button.apg-menu__item:not([disabled])')?.focus(); }, [state]); const onMenuKeyDown = (e: React.KeyboardEvent) => { const items = Array.from( ref.current?.querySelectorAll('button.apg-menu__item:not([disabled])') ?? [], ); if (items.length === 0) return; const idx = items.indexOf(document.activeElement as HTMLButtonElement); if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1) % items.length]!.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length]!.focus(); } else if (e.key === 'Home') { e.preventDefault(); items[0]!.focus(); } else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]!.focus(); } }; useEffect(() => { if (!state) return; const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) closeContextMenu(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeContextMenu(); }; const onScroll = () => closeContextMenu(); window.addEventListener('mousedown', onDown, true); window.addEventListener('keydown', onKey); window.addEventListener('scroll', onScroll, true); return () => { window.removeEventListener('mousedown', onDown, true); window.removeEventListener('keydown', onKey); window.removeEventListener('scroll', onScroll, true); }; }, [state]); if (!state) return null; return (
e.preventDefault()} onKeyDown={onMenuKeyDown} > {state.entries.map((entry, i) => { if (entry.type === 'separator') return
; if (entry.type === 'label') return (
{entry.label}
); return ( ); })}
); }