Compare commits
6 Commits
6789da1574
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aeb4685fc | |||
| 63b6e185d4 | |||
| c0a113dda7 | |||
| 6881d31650 | |||
| cab06d7c83 | |||
| a943dd0ebd |
+6
-4
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "@switchev/web-shared",
|
||||
"version": "0.4.0",
|
||||
"description": "Shared browser helpers (formatters, markdown, image polling, combobox) 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",
|
||||
"./combobox": "./src/combobox.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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+20
-3
@@ -46,6 +46,10 @@ 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 ?? '',
|
||||
@@ -53,6 +57,7 @@ export function comboboxData(config = {}) {
|
||||
isOpen: false,
|
||||
highlighted: -1,
|
||||
placeholder: config.placeholder || '',
|
||||
_opts: null, // set reactively via setOptions(); see _options getter
|
||||
|
||||
init() {
|
||||
this.syncQueryFromValue();
|
||||
@@ -66,8 +71,19 @@ export function comboboxData(config = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
// 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 normalizeOptions(config.options);
|
||||
return this._opts != null ? this._opts : normalizeOptions(config.options);
|
||||
},
|
||||
|
||||
syncQueryFromValue() {
|
||||
@@ -77,7 +93,8 @@ export function comboboxData(config = {}) {
|
||||
return;
|
||||
}
|
||||
const match = this._options.find((o) => o.value === String(v));
|
||||
this.query = match ? match.label : allowFree ? String(v) : '';
|
||||
if (match) this.query = displayValue ? match.value : match.label;
|
||||
else this.query = allowFree ? String(v) : '';
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
@@ -109,7 +126,7 @@ export function comboboxData(config = {}) {
|
||||
|
||||
choose(opt) {
|
||||
this.value = opt.value;
|
||||
this.query = opt.label;
|
||||
this.query = displayValue ? opt.value : opt.label;
|
||||
this.close();
|
||||
},
|
||||
|
||||
|
||||
@@ -54,3 +54,40 @@ test('empty query lists all options (capped) (SEV-392)', () => {
|
||||
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"
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user