import { h } from 'preact'; import { useState, useMemo } from 'preact/hooks'; // A single fuzzy-finder input for picking a signal as one "ds:name" reference, // replacing the old stacked "Data source" + "Signal" select pair. The caller // supplies a flattened option list (every ds:name it knows about) and, via // onOpen, a hook to lazily load signals for all data sources when the dropdown // is first opened. When allowFreeform is set, typing a value that matches no // option (e.g. a new srv:/local: variable) can still be committed. export interface SignalOption { ds: string; name: string; } export default function SignalPicker({ value, options, onChange, onOpen, placeholder = 'Search signals…', allowFreeform = false, }: { value: string; // current "ds:name" (or "") options: SignalOption[]; onChange: (ref: string) => void; onOpen?: () => void; placeholder?: string; allowFreeform?: boolean; }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(''); const filtered = useMemo(() => { const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean); const scored = options .map(o => ({ o, label: `${o.ds}:${o.name}` })) .filter(({ label }) => { const l = label.toLowerCase(); return tokens.every(t => l.includes(t)); }); // Prefer matches where the signal name (not the ds prefix) leads. const f = filter.toLowerCase(); scored.sort((a, b) => { const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1; const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1; if (an !== bn) return an - bn; return a.label.localeCompare(b.label); }); return scored.slice(0, 200); }, [options, filter]); function openDropdown() { setOpen(true); setFilter(''); onOpen?.(); } function commit(ref: string) { onChange(ref); setOpen(false); setFilter(''); } // A freeform entry is offered when the typed text looks like a usable // reference and is not already an exact option. const freeform = allowFreeform && filter.trim() !== '' && !filtered.some(({ label }) => label === filter.trim()); return (
(open ? setOpen(false) : openDropdown())}> {value || {placeholder}} {open ? '▴' : '▾'}
{open && (
setFilter((e.target as HTMLInputElement).value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`); else if (freeform) commit(filter.trim()); } if (e.key === 'Escape') { e.preventDefault(); setOpen(false); } }} />
{freeform && (
commit(filter.trim())}> Use “{filter.trim()}”
)} {filtered.length === 0 && !freeform &&
No matches
} {filtered.map(({ o, label }) => (
commit(`${o.ds}:${o.name}`)} > {label}
))}
)} {open &&
setOpen(false)} />}
); }