114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
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 (
|
|
<div class="search-select">
|
|
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
|
|
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
|
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
|
</div>
|
|
{open && (
|
|
<div class="search-select-dropdown">
|
|
<input
|
|
class="prop-input"
|
|
autoFocus
|
|
placeholder="Type to search… (ds:name)"
|
|
value={filter}
|
|
onInput={(e) => 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); }
|
|
}}
|
|
/>
|
|
<div class="search-select-list">
|
|
{freeform && (
|
|
<div class="search-select-item" onClick={() => commit(filter.trim())}>
|
|
Use “{filter.trim()}”
|
|
</div>
|
|
)}
|
|
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
|
|
{filtered.map(({ o, label }) => (
|
|
<div
|
|
key={label}
|
|
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
|
|
onClick={() => commit(`${o.ds}:${o.name}`)}
|
|
>
|
|
{label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|