SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0 #4
@@ -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`
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
Generated
+56
@@ -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
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@switchev/web-shared",
|
||||
"version": "0.3.0",
|
||||
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
|
||||
"version": "0.4.0",
|
||||
"description": "Shared browser helpers (formatters, markdown, image polling, combobox) for the SwitchEV frontends",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./format": "./src/format.js",
|
||||
"./markdown": "./src/markdown.js",
|
||||
"./wait-image": "./src/wait-image.js"
|
||||
"./wait-image": "./src/wait-image.js",
|
||||
"./combobox": "./src/combobox.js"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
@@ -15,6 +16,9 @@
|
||||
"sideEffects": [
|
||||
"./src/markdown.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"fuzzysort": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dompurify": ">=3",
|
||||
"marked": ">=12"
|
||||
|
||||
+153
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -7,3 +7,4 @@ export {
|
||||
} from './format.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { waitForImage } from './wait-image.js';
|
||||
export { comboboxData } from './combobox.js';
|
||||
|
||||
Reference in New Issue
Block a user