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 6fff84f40a Feat v0.6.14 visual polish
Fix v0.6.13 spacing regressions with half-step tokens (--sp-1h 6px,
--sp-2h 10px). Purge ~65 stale hex/rgba fallback values from var() calls
including all old gold #b38a4e references. Self-host DM Sans and JetBrains
Mono woff2 fonts, eliminating Google Fonts CDN dependency. Standardize
border-radius to three tokens (--radius-sm 4px, --radius 8px, --radius-lg
12px) across all kernel CSS and 12 extension packages.

Also fixes: theme settings showing resolved mode instead of stored preference,
surface-inner overflow causing notes scrollbar and chat input clipping at
high zoom, user menu drift at scale >100% from zoom-unaware fixed positioning,
and three undefined CSS variable references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:59:06 +00:00

176 lines
7.0 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, accounting for CSS zoom on #surfaceInner.
// getBoundingClientRect() returns viewport coords, but position:fixed inside
// a zoomed ancestor needs coords divided by the zoom factor.
useEffect(() => {
if (!open || !anchor) return;
const rawRect = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect()
: anchor;
// Detect CSS zoom on surface container
const zoomEl = document.getElementById('surfaceInner');
const zoom = zoomEl ? (parseFloat(zoomEl.style.zoom) || 1) : 1;
// Convert viewport coords to zoom-space
const rect = {
top: rawRect.top / zoom,
bottom: rawRect.bottom / zoom,
left: rawRect.left / zoom,
right: rawRect.right / zoom,
};
const vw = window.innerWidth / zoom;
const vh = window.innerHeight / zoom;
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>
`;
}