Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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)}
|
||||
|
||||
130
src/js/sw/primitives/user-picker.js
Normal file
130
src/js/sw/primitives/user-picker.js
Normal 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user