This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/sw/primitives/menu.js
Jeffrey Smith 6931b125a4
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m28s
CI/CD / test-sqlite (push) Successful in 2m42s
CI/CD / build-and-deploy (push) Successful in 1m8s
Feat v0.5.2 chat surface (#32)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-30 15:25:33 +00:00

169 lines
6.9 KiB
JavaScript

/**
* 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`
<div class="sw-menu" ref=${menuRef} role="menu" tabindex="-1"
style=${pos ? `position:fixed;top:${pos.top}px;left:${pos.left}px` : 'position:fixed;top:-9999px;left:-9999px'}
onKeyDown=${onKeyDown}>
${items.map((item) => {
if (item.divider) return html`<div class="sw-menu__divider" role="separator" />`;
const mySelIdx = selIdx++;
const focused = mySelIdx === focusIdx;
return html`
<div class="sw-menu__item ${item.disabled ? 'sw-menu__item--disabled' : ''} ${focused ? 'sw-menu__item--focused' : ''}"
role="menuitem"
ref=${el => { if (el) itemRefs.current[mySelIdx] = el; }}
aria-disabled=${item.disabled || false}
onMouseEnter=${(e) => { setFocusIdx(mySelIdx); if (item.children) setSubmenu({ item, anchorEl: e.currentTarget }); }}
onClick=${() => {
if (item.disabled) return;
if (item.children) { setSubmenu({ item, anchorEl: itemRefs.current[mySelIdx] }); return; }
if (onSelect) onSelect(item.action || item.label);
if (onClose) onClose();
}}>
${item.icon && html`<span class="sw-menu__icon">${item.icon}</span>`}
<span class="sw-menu__label">${item.label}</span>
${item.children && html`<span class="sw-menu__arrow">\u25b6</span>`}
</div>
`;
})}
${submenu && html`
<${Menu}
items=${submenu.item.children}
open
anchor=${submenu.anchorEl}
direction="down-right"
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
onClose=${() => setSubmenu(null)}
/>
`}
</div>
`;
}