SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0 #4

Merged
bastian merged 1 commits from sev-392/combobox into main 2026-06-10 19:07:18 -04:00
6 changed files with 301 additions and 4 deletions
Showing only changes of commit a676a3e99a - Show all commits
+28 -1
View File
@@ -9,7 +9,7 @@ All work in this repo should reference the relevant Jira ticket (project: `SEV`)
**JQL shortcut:** `project = SEV AND parent in (SEV-7, SEV-6) ORDER BY status` **JQL shortcut:** `project = SEV AND parent in (SEV-7, SEV-6) ORDER BY status`
Full TODO-to-Jira mapping: `/Users/bastian/src/switchev/TODO.md` Use the Jira MCP tools (`mcp__atlassian-switchev__jira_*`) to search, create, and update tickets directly.
## Project ## Project
SwitchEV is an EV conversion marketplace. This repo contains shared constants, validation schemas, and type definitions used across the api/ and frontend sites. SwitchEV is an EV conversion marketplace. This repo contains shared constants, validation schemas, and type definitions used across the api/ and frontend sites.
@@ -21,3 +21,30 @@ SwitchEV is an EV conversion marketplace. This repo contains shared constants, v
## Usage ## Usage
Keep this package minimal. Only add code here if it is genuinely shared between multiple repos. Keep this package minimal. Only add code here if it is genuinely shared between multiple repos.
## Git Hosting & CI/CD
- **Gitea:** `git.debyl.io/SwitchEV/shared` — PRs, code review
- **MCP:** Use the `gitea-personal` MCP server at `git.debyl.io` for PRs and all Gitea operations; fallback is `tea` CLI
- **CI:** No Gitea Actions workflow — shared library; tests run in consuming repos (api)
### Git Workflow
1. Create a working branch off main, make changes, commit, and push
2. Create a PR via the `gitea-personal` MCP (or `tea` CLI as fallback)
3. Wait for CI to pass (if applicable)
4. Ask the user for code review before merging
5. Merge the PR (remote branch is auto-deleted)
6. Locally: `git checkout main && git pull && git fetch --prune`
### tea CLI Reference (fallback)
```bash
tea pr create --repo SwitchEV/shared --login git.debyl.io --head <branch> --base main --title "Title" --description "Body"
tea pr list --repo SwitchEV/shared --login git.debyl.io
tea pr merge --repo SwitchEV/shared --login git.debyl.io <PR#> --style merge
```
### Branch Protection
Main branch is protected via Gitea:
- Direct push to main is blocked — all changes go through PRs
- No required approvals (single-maintainer repo)
- **Auto-delete branch after merge** is enabled — remote branches are cleaned up automatically
- Locally, `git fetch --prune` cleans up stale remote-tracking branches
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@switchev/web-shared",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@switchev/web-shared",
"version": "0.4.0",
"dependencies": {
"fuzzysort": "^3.1.0"
},
"peerDependencies": {
"dompurify": ">=3",
"marked": ">=12"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
}
}
}
+7 -3
View File
@@ -1,13 +1,14 @@
{ {
"name": "@switchev/web-shared", "name": "@switchev/web-shared",
"version": "0.3.0", "version": "0.4.0",
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends", "description": "Shared browser helpers (formatters, markdown, image polling, combobox) for the SwitchEV frontends",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.js", ".": "./src/index.js",
"./format": "./src/format.js", "./format": "./src/format.js",
"./markdown": "./src/markdown.js", "./markdown": "./src/markdown.js",
"./wait-image": "./src/wait-image.js" "./wait-image": "./src/wait-image.js",
"./combobox": "./src/combobox.js"
}, },
"files": [ "files": [
"src" "src"
@@ -15,6 +16,9 @@
"sideEffects": [ "sideEffects": [
"./src/markdown.js" "./src/markdown.js"
], ],
"dependencies": {
"fuzzysort": "^3.1.0"
},
"peerDependencies": { "peerDependencies": {
"dompurify": ">=3", "dompurify": ">=3",
"marked": ">=12" "marked": ">=12"
+153
View File
@@ -0,0 +1,153 @@
// 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;
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();
}
},
};
}
+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);
});
+1
View File
@@ -7,3 +7,4 @@ export {
} from './format.js'; } from './format.js';
export { renderMarkdown } from './markdown.js'; export { renderMarkdown } from './markdown.js';
export { waitForImage } from './wait-image.js'; export { waitForImage } from './wait-image.js';
export { comboboxData } from './combobox.js';