diff --git a/CLAUDE.md b/CLAUDE.md index a7c9b44..70dbdf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --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 --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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8dceebd --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json index dda766b..c5905a0 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/combobox.js b/src/combobox.js new file mode 100644 index 0000000..da0121b --- /dev/null +++ b/src/combobox.js @@ -0,0 +1,153 @@ +// SEV-392: a reusable typeable, fuzzy-matching combobox for the SwitchEV +// frontends — replaces native +// +// +// +// 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';