// SEV-392: a reusable typeable, fuzzy-matching combobox for the SwitchEV // frontends — replaces native // // // // allowFree (default true): the input accepts ANY typed value as the committed // value — so off-list entries (classic cars, kit builds) are never blocked and // no separate "enter manually" mode is needed. Set allowFree:false for closed // lists (e.g. US states) where the value must come from the options. import fuzzysort from 'fuzzysort'; function normalizeOptions(raw) { const arr = typeof raw === 'function' ? raw() : raw; if (!Array.isArray(arr)) return []; return arr.map((o) => o && typeof o === 'object' ? { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') } : { value: String(o), label: String(o) } ); } export function comboboxData(config = {}) { const allowFree = config.allowFree !== false; const fuzzy = config.fuzzy !== false; const limit = config.limit || 50; // displayValue: after selecting, show the option's VALUE (e.g. a state code // "TX") in the input instead of its LABEL ("Texas"), while the dropdown stays // label-searchable. Lets a field be searched by name but display the code. const displayValue = config.displayValue === true; return { value: config.value ?? '', query: '', isOpen: false, highlighted: -1, placeholder: config.placeholder || '', _opts: null, // set reactively via setOptions(); see _options getter init() { this.syncQueryFromValue(); // Reflect external value changes (e.g. a reset) back into the input, // but don't fight the user while the menu is open. $watch is Alpine-only; // guarded so the factory stays unit-testable without Alpine. if (typeof this.$watch === 'function') { this.$watch('value', () => { if (!this.isOpen) this.syncQueryFromValue(); }); } }, // setOptions lets a parent feed reactive option lists in via x-effect — // e.g. x-effect="setOptions(vpicMakes)" — which is the reliable way to // pass a parent component's reactive array into this nested x-data (a // plain closure over the parent scope would NOT track updates). Falls back // to config.options when never called (static lists). setOptions(raw) { this._opts = normalizeOptions(raw); // a shrinking list shouldn't leave a stale highlight selected if (this.highlighted >= this._opts.length) this.highlighted = -1; }, get _options() { return this._opts != null ? this._opts : normalizeOptions(config.options); }, syncQueryFromValue() { const v = this.value; if (v === '' || v == null) { this.query = ''; return; } const match = this._options.find((o) => o.value === String(v)); if (match) this.query = displayValue ? match.value : match.label; else this.query = allowFree ? String(v) : ''; }, get filtered() { const opts = this._options; const q = (this.query || '').trim(); if (!q) return opts.slice(0, limit); if (!fuzzy) { const lower = q.toLowerCase(); return opts.filter((o) => o.label.toLowerCase().includes(lower)).slice(0, limit); } return fuzzysort.go(q, opts, { key: 'label', limit }).map((r) => r.obj); }, open() { this.isOpen = true; this.highlighted = -1; }, close() { this.isOpen = false; this.highlighted = -1; }, onInput() { this.isOpen = true; this.highlighted = -1; if (allowFree) this.value = this.query; // capture free text live }, choose(opt) { this.value = opt.value; this.query = displayValue ? opt.value : opt.label; this.close(); }, onBlur() { // @mousedown.prevent on options keeps focus so a click commits before // this fires; this handles real focus-out (tab/click elsewhere). if (allowFree) { this.value = this.query; } else { this.syncQueryFromValue(); // snap back to the committed label } this.close(); }, onKeydown(e) { if (e.key === 'ArrowDown') { e.preventDefault(); if (!this.isOpen) this.open(); const n = this.filtered.length; if (n) this.highlighted = (this.highlighted + 1) % n; } else if (e.key === 'ArrowUp') { e.preventDefault(); const n = this.filtered.length; if (n) this.highlighted = (this.highlighted - 1 + n) % n; } else if (e.key === 'Enter') { if (this.isOpen && this.highlighted >= 0 && this.filtered[this.highlighted]) { e.preventDefault(); this.choose(this.filtered[this.highlighted]); } else if (allowFree) { this.value = this.query; this.close(); } } else if (e.key === 'Escape') { this.close(); if (!allowFree) this.syncQueryFromValue(); } else if (e.key === 'Tab') { this.close(); } }, }; }