/** * Context Menu — shared component for Docs and Sheets. * * Renders a custom right-click context menu with: * - Icon, label, shortcut hint per item * - Separator support * - Disabled state * - Keyboard navigation (arrow keys, Enter, Escape) * - Auto-positioning near screen edges */ export interface MenuItemConfig { label: string; action: () => void; icon?: string; shortcut?: string; disabled?: boolean; } export interface SeparatorItem { readonly type: 'separator'; } export type MenuItem = MenuItemConfig | SeparatorItem; export interface ContextMenuInstance { el: HTMLElement; show: (x: number, y: number) => void; hide: () => void; destroy: () => void; } export interface MenuPosition { x: number; y: number; } /** Sentinel value used as a separator between item groups. */ export const SEPARATOR: SeparatorItem = Object.freeze({ type: 'separator' as const }); /** * Position a context menu so it stays within the viewport. */ export function positionMenu(mouseX: number, mouseY: number, menuW: number, menuH: number, vpW: number, vpH: number): MenuPosition { let x = mouseX; let y = mouseY; if (x + menuW > vpW) x = vpW - menuW; if (y + menuH > vpH) y = vpH - menuH; if (x < 0) x = 0; if (y < 0) y = 0; return { x, y }; } function isSeparator(item: MenuItem): item is SeparatorItem { return item === SEPARATOR || ('type' in item && item.type === 'separator'); } /** * Create a context menu DOM element from a list of menu items. * * Each item is either SEPARATOR or an object: * { label: string, action: () => void, icon?: string, shortcut?: string, disabled?: boolean } */ export function createContextMenu(items: MenuItem[]): ContextMenuInstance { const menu = document.createElement('div'); menu.className = 'context-menu'; menu.setAttribute('role', 'menu'); menu.style.display = 'none'; menu.style.position = 'fixed'; menu.style.zIndex = '9999'; for (const item of items) { if (isSeparator(item)) { const sep = document.createElement('div'); sep.className = 'context-menu-separator'; sep.setAttribute('role', 'separator'); menu.appendChild(sep); continue; } const btn = document.createElement('button'); btn.className = 'context-menu-item'; btn.setAttribute('role', 'menuitem'); btn.setAttribute('tabindex', '0'); if (item.disabled) { btn.classList.add('disabled'); btn.setAttribute('aria-disabled', 'true'); } // Icon if (item.icon) { const iconSpan = document.createElement('span'); iconSpan.className = 'context-menu-icon'; iconSpan.textContent = item.icon; btn.appendChild(iconSpan); } // Label const labelSpan = document.createElement('span'); labelSpan.className = 'context-menu-label'; labelSpan.textContent = item.label; btn.appendChild(labelSpan); // Shortcut hint if (item.shortcut) { const shortcutSpan = document.createElement('span'); shortcutSpan.className = 'context-menu-shortcut'; shortcutSpan.textContent = item.shortcut; btn.appendChild(shortcutSpan); } // Click handler btn.addEventListener('click', (e: Event) => { e.stopPropagation(); if (item.disabled) return; item.action(); hide(); }); // Keyboard: Enter to activate btn.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); if (!item.disabled) { item.action(); hide(); } } }); menu.appendChild(btn); } // Keyboard navigation on the menu container menu.addEventListener('keydown', (e: KeyboardEvent) => { const focusable = Array.from(menu.querySelectorAll('.context-menu-item:not(.disabled)')); const current = document.activeElement; const idx = focusable.indexOf(current as HTMLButtonElement); if (e.key === 'ArrowDown') { e.preventDefault(); const next = idx < focusable.length - 1 ? idx + 1 : 0; focusable[next]?.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); const prev = idx > 0 ? idx - 1 : focusable.length - 1; focusable[prev]?.focus(); } else if (e.key === 'Escape') { e.preventDefault(); hide(); } }); function show(x: number, y: number): void { menu.style.display = 'block'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; // Focus first item for keyboard navigation const first = menu.querySelector('.context-menu-item:not(.disabled)'); if (first) first.focus(); } function hide(): void { menu.style.display = 'none'; } function destroy(): void { if (menu.parentNode) { menu.parentNode.removeChild(menu); } } return { el: menu, show, hide, destroy }; }