Changeset 0.37.14 (#226)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-23 16:47:48 +00:00
committed by xcaliber
parent fcb998bff9
commit b7746c3004
164 changed files with 6972 additions and 3527 deletions

View File

@@ -11,7 +11,8 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
const menuRef = useRef(null);
const [pos, setPos] = useState(null);
const [focusIdx, setFocusIdx] = useState(-1);
const [submenu, setSubmenu] = useState(null);
const [submenu, setSubmenu] = useState(null); // { item, anchorEl }
const itemRefs = useRef({}); // selIdx → DOM element
const selectableItems = items.filter(i => !i.divider && !i.disabled);
@@ -90,14 +91,14 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
e.preventDefault();
if (focusIdx >= 0 && focusIdx < count) {
const item = selectableItems[focusIdx];
if (item.children) setSubmenu(item);
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(selectableItems[focusIdx]);
setSubmenu({ item: selectableItems[focusIdx], anchorEl: itemRefs.current[focusIdx] });
}
break;
}
@@ -130,11 +131,12 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
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=${() => { setFocusIdx(mySelIdx); if (item.children) setSubmenu(item); }}
onMouseEnter=${(e) => { setFocusIdx(mySelIdx); if (item.children) setSubmenu({ item, anchorEl: e.currentTarget }); }}
onClick=${() => {
if (item.disabled) return;
if (item.children) { setSubmenu(item); return; }
if (item.children) { setSubmenu({ item, anchorEl: itemRefs.current[mySelIdx] }); return; }
if (onSelect) onSelect(item.action || item.label);
if (onClose) onClose();
}}>
@@ -146,9 +148,9 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
})}
${submenu && html`
<${Menu}
items=${submenu.children}
items=${submenu.item.children}
open
anchor=${menuRef.current}
anchor=${submenu.anchorEl}
direction="down-right"
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
onClose=${() => setSubmenu(null)}

View File

@@ -0,0 +1,130 @@
// ==========================================
// Primitive — UserPicker (autocomplete)
// ==========================================
// Debounced search input with dropdown results.
// Calls sw.api.users.search(q) and shows matching users.
//
// Usage:
// html`<${UserPicker} onSelect=${(user) => ...} placeholder="Search users..." />`
const { html } = window;
const { useState, useRef, useEffect, useCallback } = hooks;
const DEBOUNCE_MS = 250;
function _initials(name) {
if (!name) return '?';
return name.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
}
/**
* @param {{
* onSelect: (user: {id: string, username: string, display_name?: string}) => void,
* placeholder?: string,
* autoFocus?: boolean,
* className?: string,
* }} props
*/
export function UserPicker({ onSelect, placeholder = 'Search users\u2026', autoFocus, className }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const timerRef = useRef(null);
const inputRef = useRef(null);
const containerRef = useRef(null);
// Debounced search
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
const q = query.trim();
if (!q || q.length < 1) {
setResults([]);
setOpen(false);
return;
}
setLoading(true);
timerRef.current = setTimeout(async () => {
try {
const resp = await sw.api.users.search(q);
const list = resp || [];
setResults(Array.isArray(list) ? list : []);
setOpen(list.length > 0);
setActiveIdx(-1);
} catch (_) {
setResults([]);
}
setLoading(false);
}, DEBOUNCE_MS);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [query]);
// Close on outside click
useEffect(() => {
if (!open) return;
function _onClick(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', _onClick);
return () => document.removeEventListener('mousedown', _onClick);
}, [open]);
function _select(user) {
setQuery('');
setResults([]);
setOpen(false);
onSelect(user);
}
function _onKeyDown(e) {
if (!open || !results.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx(i => Math.min(i + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
_select(results[activeIdx]);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return html`
<div class=${'sw-user-picker' + (className ? ' ' + className : '')}
ref=${containerRef}>
<input class="sw-user-picker__input"
ref=${inputRef}
type="text"
value=${query}
placeholder=${placeholder}
autoFocus=${autoFocus}
onInput=${e => setQuery(e.target.value)}
onFocus=${() => results.length && setOpen(true)}
onKeyDown=${_onKeyDown}
autocomplete="off" />
${loading && html`<span class="sw-user-picker__spinner" />`}
${open && html`
<div class="sw-user-picker__dropdown" role="listbox">
${results.map((u, i) => html`
<div key=${u.id}
class=${'sw-user-picker__option' + (i === activeIdx ? ' sw-user-picker__option--active' : '')}
role="option"
aria-selected=${i === activeIdx}
onMouseDown=${() => _select(u)}
onMouseEnter=${() => setActiveIdx(i)}>
<span class="sw-user-picker__avatar">${_initials(u.display_name || u.username)}</span>
<div class="sw-user-picker__label">
<span class="sw-user-picker__name">${u.display_name || u.username}</span>
${u.display_name && u.username && html`
<span class="sw-user-picker__handle">@${u.username}</span>`}
</div>
</div>`)}
</div>`}
</div>`;
}