6881d31650
When displayValue:true, the input shows the selected option's value (e.g. a
state code "TX") while the dropdown stays label-searchable ("Texas") — for a
field searched by name but displayed compactly. Backward-compatible
(default false). 20/20 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.3 KiB
JavaScript
171 lines
6.3 KiB
JavaScript
// SEV-392: a reusable typeable, fuzzy-matching combobox for the SwitchEV
|
|
// frontends — replaces native <select> chrome with an input that matches our
|
|
// text fields and is keyboard- + screen-reader-friendly.
|
|
//
|
|
// comboboxData(config) returns an Alpine data object. Bind the committed value
|
|
// to a parent form field with x-modelable:
|
|
//
|
|
// <div class="relative"
|
|
// x-data="combobox({ options: () => vpicMakes, allowFree: true, placeholder: 'Make' })"
|
|
// x-modelable="value" x-model="form.make"
|
|
// @keydown.escape.stop="close()">
|
|
// <input type="text" role="combobox" :aria-expanded="isOpen"
|
|
// x-model="query" @input="onInput()" @focus="open()" @blur="onBlur()"
|
|
// @keydown="onKeydown($event)" :placeholder="placeholder"
|
|
// class="w-full px-4 py-3 border rounded-xl focus:ring-2 outline-none transition" />
|
|
// <ul x-show="isOpen" x-cloak role="listbox"
|
|
// class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-xl shadow-lg max-h-60 overflow-auto">
|
|
// <template x-for="(opt, i) in filtered" :key="opt.value">
|
|
// <li role="option" :aria-selected="i === highlighted"
|
|
// @mousedown.prevent="choose(opt)" @mouseenter="highlighted = i"
|
|
// :class="i === highlighted ? 'bg-brand-50' : ''"
|
|
// class="px-4 py-2.5 cursor-pointer text-sm text-gray-700"
|
|
// x-text="opt.label"></li>
|
|
// </template>
|
|
// <li x-show="!filtered.length" class="px-4 py-2.5 text-sm text-gray-400">No matches</li>
|
|
// </ul>
|
|
// </div>
|
|
//
|
|
// 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();
|
|
}
|
|
},
|
|
};
|
|
}
|