Changeset 0.37.2 (#214)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
159
src/js/sw/primitives/menu.js
Normal file
159
src/js/sw/primitives/menu.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
||||
|
||||
// Position relative to anchor
|
||||
useEffect(() => {
|
||||
if (!open || !anchor) return;
|
||||
const rect = typeof anchor.getBoundingClientRect === 'function'
|
||||
? anchor.getBoundingClientRect()
|
||||
: anchor;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
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);
|
||||
else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (focusIdx >= 0 && selectableItems[focusIdx]?.children) {
|
||||
setSubmenu(selectableItems[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"
|
||||
aria-disabled=${item.disabled || false}
|
||||
onMouseEnter=${() => { setFocusIdx(mySelIdx); if (item.children) setSubmenu(item); }}
|
||||
onClick=${() => {
|
||||
if (item.disabled) return;
|
||||
if (item.children) { setSubmenu(item); 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.children}
|
||||
open
|
||||
anchor=${menuRef.current}
|
||||
direction="down-right"
|
||||
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
|
||||
onClose=${() => setSubmenu(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user