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>
176 lines
7.0 KiB
JavaScript
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>
|
|
`;
|
|
}
|