From a676a3e99a83749a0303e95bdc53e65f2b91c324 Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Wed, 10 Jun 2026 19:06:38 -0400 Subject: [PATCH] SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0 comboboxData(config) Alpine factory: typeable input + fuzzysort-ranked dropdown (prefix/substring/abbreviation subsequence matching), keyboard nav (Arrow/Enter/Escape/Tab), a11y roles, x-modelable value binding. allowFree (default) accepts off-list typed values so classics/kit cars are never blocked; allowFree:false for closed lists (US states) snaps back to a valid option on blur. Foundation for replacing native 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: +// +//
+// +// +//
+// +// 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; + + return { + value: config.value ?? '', + query: '', + isOpen: false, + highlighted: -1, + placeholder: config.placeholder || '', + + 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(); + }); + } + }, + + get _options() { + return 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)); + this.query = match ? match.label : 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 = 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(); + } + }, + }; +} diff --git a/src/combobox.test.js b/src/combobox.test.js new file mode 100644 index 0000000..2dceb0d --- /dev/null +++ b/src/combobox.test.js @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { comboboxData } from './combobox.js'; + +test('free-typing combobox commits typed text as the value (SEV-392)', () => { + const c = comboboxData({ options: ['BMW', 'Porsche', 'Tesla'] }); + c.init(); + c.query = 'Devin Sportscar'; // off-list classic + c.onInput(); + assert.equal(c.value, 'Devin Sportscar'); // accepted as-is, never blocked +}); + +test('fuzzy filter matches prefix, substring + abbreviation subsequences (SEV-392)', () => { + const c = comboboxData({ options: ['BMW', 'Mercedes-Benz', 'Porsche'] }); + // prefix + c.query = 'merc'; + assert.ok(c.filtered.map((o) => o.label).includes('Mercedes-Benz')); + // abbreviation subsequence (M…B) + c.query = 'mb'; + assert.ok(c.filtered.map((o) => o.label).includes('Mercedes-Benz')); + // and it excludes non-matches + c.query = 'xyz'; + assert.equal(c.filtered.length, 0); +}); + +test('choose() commits an option value + label; supports {value,label} (SEV-392)', () => { + const c = comboboxData({ + options: [{ value: 'CA', label: 'California' }, { value: 'OR', label: 'Oregon' }], + allowFree: false, + }); + c.init(); + c.choose({ value: 'OR', label: 'Oregon' }); + assert.equal(c.value, 'OR'); + assert.equal(c.query, 'Oregon'); + assert.equal(c.isOpen, false); +}); + +test('closed-list blur snaps back to the committed label (SEV-392)', () => { + const c = comboboxData({ + options: [{ value: 'CA', label: 'California' }], + allowFree: false, + value: 'CA', + }); + c.init(); + assert.equal(c.query, 'California'); // init syncs query from value + c.query = 'typing garbage'; + c.onBlur(); + assert.equal(c.value, 'CA'); // unchanged + assert.equal(c.query, 'California'); // reverted +}); + +test('empty query lists all options (capped) (SEV-392)', () => { + const c = comboboxData({ options: ['a', 'b', 'c'] }); + c.query = ''; + assert.equal(c.filtered.length, 3); +}); diff --git a/src/index.js b/src/index.js index 3c42fc6..0b052d9 100644 --- a/src/index.js +++ b/src/index.js @@ -7,3 +7,4 @@ export { } from './format.js'; export { renderMarkdown } from './markdown.js'; export { waitForImage } from './wait-image.js'; +export { comboboxData } from './combobox.js';