/** * Menu primitive — flyout menu with viewport clamping, keyboard nav, submenus * * Items: [{ label, action, icon?, disabled?, divider?, children? }] * Directions: down-right (default), down-left, up-right, up-left */ const { html } = window; const { useState, useEffect, useRef, useCallback } = hooks; export function Menu({ items = [], anchor, open, direction = 'down-right', onSelect, onClose }) { const menuRef = useRef(null); const [pos, setPos] = useState(null); const [focusIdx, setFocusIdx] = useState(-1); const [submenu, setSubmenu] = useState(null); // { item, anchorEl } const itemRefs = useRef({}); // selIdx → DOM element const selectableItems = items.filter(i => !i.divider && !i.disabled); // Position relative to anchor // When the surface uses CSS transform: scale(), getBoundingClientRect() // returns scaled coordinates but position:fixed uses viewport coordinates. // Dividing by the scale factor converts back to true viewport pixels. useEffect(() => { if (!open || !anchor) return; const scale = window.sw?.shell?.getScale?.() || 1; const raw = typeof anchor.getBoundingClientRect === 'function' ? anchor.getBoundingClientRect() : anchor; const rect = scale !== 1 ? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale } : raw; const vw = window.innerWidth / scale; const vh = window.innerHeight / scale; let top, left; const [vDir, hDir] = direction.split('-'); if (vDir === 'down') top = rect.bottom + 4; else top = rect.top - 4; if (hDir === 'right') left = rect.left; else left = rect.right; // Defer measurement to get menu size requestAnimationFrame(() => { const el = menuRef.current; if (!el) return; const mw = el.offsetWidth; const mh = el.offsetHeight; // Viewport clamp if (vDir === 'down' && top + mh > vh) top = Math.max(4, rect.top - mh - 4); if (vDir === 'up') { top = top - mh; if (top < 4) top = rect.bottom + 4; } if (hDir === 'right' && left + mw > vw) left = vw - mw - 4; if (hDir === 'left') { left = left - mw; if (left < 4) left = 4; } setPos({ top, left }); }); setPos({ top: -9999, left: -9999 }); // initial offscreen for measurement setFocusIdx(-1); }, [open, anchor, direction]); // Close on outside click useEffect(() => { if (!open) return; const handler = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) { if (onClose) onClose(); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open, onClose]); // Keyboard navigation const onKeyDown = useCallback((e) => { const count = selectableItems.length; if (!count) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); setFocusIdx(i => (i + 1) % count); break; case 'ArrowUp': e.preventDefault(); setFocusIdx(i => (i - 1 + count) % count); break; case 'Home': e.preventDefault(); setFocusIdx(0); break; case 'End': e.preventDefault(); setFocusIdx(count - 1); break; case 'Enter': { e.preventDefault(); if (focusIdx >= 0 && focusIdx < count) { const item = selectableItems[focusIdx]; if (item.children) setSubmenu({ item, anchorEl: itemRefs.current[focusIdx] }); else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); } } break; } case 'ArrowRight': { if (focusIdx >= 0 && selectableItems[focusIdx]?.children) { setSubmenu({ item: selectableItems[focusIdx], anchorEl: itemRefs.current[focusIdx] }); } break; } case 'ArrowLeft': if (submenu) setSubmenu(null); break; case 'Escape': if (submenu) setSubmenu(null); else if (onClose) onClose(); break; } }, [selectableItems, focusIdx, submenu, onSelect, onClose]); // Focus menu on open useEffect(() => { if (open && menuRef.current) menuRef.current.focus(); }, [open, pos]); if (!open) return null; let selIdx = 0; return html`
`; }