Compare commits

...

8 Commits

Author SHA1 Message Date
bastian 6aeb4685fc Merge pull request 'SEV-337: add shared animations.css' (#7) from sev-337/shared-animations into main 2026-06-11 14:04:56 -04:00
Bastian de Byl 63b6e185d4 SEV-337: add shared animations.css (keyframes + helper classes)
Centralizes the animation primitives duplicated across public/vendor/admin
style.css — shimmer/.skeleton/.ai-shimmer, fadeIn/.fade-in, fadeInUp/.fade-in-up,
stagger delays, shake/.shake — as a plain-CSS export each app can import
(@switchev/web-shared/animations.css). Bumps to 0.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:04:41 -04:00
bastian c0a113dda7 Merge pull request 'SEV-408: combobox displayValue option (v0.5.0)' (#6) from sev-408/combobox-displayvalue into main 2026-06-10 20:21:14 -04:00
Bastian de Byl 6881d31650 SEV-408: combobox displayValue option (show code, search by label) v0.5.0
When displayValue:true, the input shows the selected option's value (e.g. a
state code "TX") while the dropdown stays label-searchable ("Texas") — for a
field searched by name but displayed compactly. Backward-compatible
(default false). 20/20 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:20:59 -04:00
bastian cab06d7c83 Merge pull request 'SEV-392: combobox setOptions() for reactive parent-fed option lists' (#5) from sev-392/combobox-setoptions into main 2026-06-10 19:09:15 -04:00
Bastian de Byl a943dd0ebd SEV-392: combobox setOptions() for reactive parent-fed option lists
Passing a parent component's reactive array into the nested combobox x-data
needs x-effect="setOptions(vpicMakes)" — a plain closure over the parent
scope doesn't track Alpine updates. Falls back to config.options for static
lists. 18/18 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:09:00 -04:00
bastian 6789da1574 Merge pull request 'SEV-392: reusable fuzzy combobox component (+fuzzysort) v0.4.0' (#4) from sev-392/combobox into main 2026-06-10 19:07:17 -04:00
Bastian de Byl a676a3e99a 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>
2026-06-10 19:06:38 -04:00
7 changed files with 428 additions and 5 deletions
+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`
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
+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"
}
}
}
}
+10 -4
View File
@@ -1,20 +1,26 @@
{
"name": "@switchev/web-shared",
"version": "0.3.0",
"description": "Shared browser helpers (formatters, markdown, image polling) for the SwitchEV frontends",
"version": "0.6.0",
"description": "Shared browser helpers (formatters, markdown, image polling, combobox, animations) 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",
"./animations.css": "./src/animations.css"
},
"files": [
"src"
],
"sideEffects": [
"./src/markdown.js"
"./src/markdown.js",
"./src/animations.css"
],
"dependencies": {
"fuzzysort": "^3.1.0"
},
"peerDependencies": {
"dompurify": ">=3",
"marked": ">=12"
+70
View File
@@ -0,0 +1,70 @@
/* @switchev/web-shared — shared animation primitives (SEV-337).
*
* Plain CSS (no Tailwind utilities), so it can be imported directly by each
* SwitchEV frontend instead of copy-pasting the same keyframes + helper classes
* into every app's style.css. Import once per app (e.g. in main.js):
* import '@switchev/web-shared/animations.css';
* App-specific, non-animation styles (brand theme, star ratings, gradient text)
* stay in each app's own stylesheet.
*/
/* Skeleton loading shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.375rem;
}
/* SEV-238: AI-working shimmer — applied to a field while an AI draft is in
flight so the disabled state reads as "AI is writing here", not broken.
Reuses the skeleton shimmer keyframes with a brand-green tint. */
.ai-shimmer {
background-image: linear-gradient(90deg,
rgba(22, 163, 74, 0.06) 25%,
rgba(22, 163, 74, 0.18) 50%,
rgba(22, 163, 74, 0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s ease-in-out infinite;
}
/* Fade-in */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.5s ease-out both;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-up {
animation: fadeInUp 0.6s ease-out both;
}
/* Staggered animation delays */
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-400 { animation-delay: 0.4s; }
/* Shake — invalid input (e.g. a wrong verification code) */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.shake {
animation: shake 0.5s ease-in-out;
}
+170
View File
@@ -0,0 +1,170 @@
// 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;
// displayValue: after selecting, show the option's VALUE (e.g. a state code
// "TX") in the input instead of its LABEL ("Texas"), while the dropdown stays
// label-searchable. Lets a field be searched by name but display the code.
const displayValue = config.displayValue === true;
return {
value: config.value ?? '',
query: '',
isOpen: false,
highlighted: -1,
placeholder: config.placeholder || '',
_opts: null, // set reactively via setOptions(); see _options getter
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();
});
}
},
// setOptions lets a parent feed reactive option lists in via x-effect —
// e.g. x-effect="setOptions(vpicMakes)" — which is the reliable way to
// pass a parent component's reactive array into this nested x-data (a
// plain closure over the parent scope would NOT track updates). Falls back
// to config.options when never called (static lists).
setOptions(raw) {
this._opts = normalizeOptions(raw);
// a shrinking list shouldn't leave a stale highlight selected
if (this.highlighted >= this._opts.length) this.highlighted = -1;
},
get _options() {
return this._opts != null ? this._opts : 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));
if (match) this.query = displayValue ? match.value : match.label;
else this.query = 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 = displayValue ? opt.value : 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();
}
},
};
}
+93
View File
@@ -0,0 +1,93 @@
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);
});
test('setOptions feeds reactive option lists (SEV-392)', () => {
const c = comboboxData({ allowFree: true });
c.setOptions([]); // async source not loaded yet
assert.equal(c.filtered.length, 0);
c.setOptions(['BMW', 'Tesla']); // makes arrive
assert.equal(c.filtered.length, 2);
c.query = 'tes';
assert.deepEqual(c.filtered.map((o) => o.value), ['Tesla']);
});
test('displayValue shows the code in the input, searches by label (SEV-408)', () => {
const c = comboboxData({
options: [{ value: 'CA', label: 'California' }, { value: 'TX', label: 'Texas' }],
allowFree: false,
displayValue: true,
});
c.init();
// search by name
c.query = 'tex';
assert.deepEqual(c.filtered.map((o) => o.value), ['TX']);
// choosing shows the CODE, not the name
c.choose({ value: 'TX', label: 'Texas' });
assert.equal(c.value, 'TX');
assert.equal(c.query, 'TX');
});
test('displayValue syncs the code from an external value (SEV-408)', () => {
const c = comboboxData({
options: [{ value: 'OR', label: 'Oregon' }],
allowFree: false,
displayValue: true,
value: 'OR',
});
c.init();
assert.equal(c.query, 'OR'); // shows code, not "Oregon"
});
+1
View File
@@ -7,3 +7,4 @@ export {
} from './format.js';
export { renderMarkdown } from './markdown.js';
export { waitForImage } from './wait-image.js';
export { comboboxData } from './combobox.js';