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 <select> chrome with
inputs that match our text fields. 17/17 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-06-10 19:06:38 -04:00
parent b7abf51087
commit a676a3e99a
6 changed files with 301 additions and 4 deletions
+56
View File
@@ -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);
});