+6
-1
.github/workflows/test.yml
+6
-1
.github/workflows/test.yml
+2
-2
ROADMAP.md
+2
-2
ROADMAP.md
···
80
80
**Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
81
81
**Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs.
82
82
83
-
## To-Do
84
-
85
83
### Global State
86
84
87
85
**Goal:** Implement store/context pattern
···
101
99
- Example: `data-volt-on-click="$pins.username.focus()"`
102
100
- `$arc(event, detail?)` - Dispatches a native CustomEvent from the current element.
103
101
- Example: `data-volt-on-click="$arc('user:save', { id })"`
102
+
103
+
## To-Do
104
104
105
105
### Animation & Transitions
106
106
+7
-4
docs/.vitepress/config.ts
+7
-4
docs/.vitepress/config.ts
···
18
18
},
19
19
{
20
20
text: "Core Concepts",
21
-
items: [{ text: "State Management", link: "/state" }, { text: "Bindings", link: "/bindings" }, {
22
-
text: "Expressions",
23
-
link: "/expressions",
24
-
}, { text: "SSR & Lifecycle", link: "/lifecycle" }],
21
+
items: [
22
+
{ text: "State Management", link: "/state" },
23
+
{ text: "Bindings", link: "/bindings" },
24
+
{ text: "Expressions", link: "/expressions" },
25
+
{ text: "SSR & Lifecycle", link: "/lifecycle" },
26
+
{ text: "Animations & Transitions", link: "/animations" },
27
+
],
25
28
},
26
29
{ text: "Tutorials", items: [{ text: "Counter", link: "/usage/counter" }] },
27
30
{
+1
docs/animations.md
+1
docs/animations.md
···
1
+
# Animations & Transitions
+30
-41
lib/src/core/http.ts
+30
-41
lib/src/core/http.ts
···
4
4
* Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses.
5
5
*/
6
6
7
-
import type { Optional } from "$types/helpers";
7
+
import type { Nullable, Optional } from "$types/helpers";
8
8
import type {
9
9
BindingContext,
10
10
HttpMethod,
···
18
18
} from "$types/volt";
19
19
import { evaluate } from "./evaluator";
20
20
import { sleep } from "./shared";
21
+
22
+
type IndicatorStrategy = "display" | "class";
23
+
24
+
type CapturedState = {
25
+
focusPath: number[] | null;
26
+
scrollPositions: Map<number[], { top: number; left: number }>;
27
+
inputValues: Map<number[], string | boolean>;
28
+
};
29
+
30
+
const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>();
21
31
22
32
/**
23
33
* Make an HTTP request and return the parsed response
···
61
71
throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`);
62
72
}
63
73
}
64
-
65
-
type CapturedState = {
66
-
focusPath: number[] | null;
67
-
scrollPositions: Map<number[], { top: number; left: number }>;
68
-
inputValues: Map<number[], string | boolean>;
69
-
};
70
74
71
75
/**
72
76
* Capture state that should be preserved during DOM swap
···
80
84
}
81
85
82
86
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
83
-
let currentNode: Node | null = walker.currentNode;
87
+
let currentNode: Nullable<Node> = walker.currentNode;
84
88
85
89
while (currentNode) {
86
90
const el = currentNode as Element;
···
111
115
*/
112
116
function getElementPath(el: Element, root: Element): number[] {
113
117
const path: number[] = [];
114
-
let current: Element | null = el;
118
+
let current: Nullable<Element> = el;
115
119
116
120
while (current && current !== root) {
117
-
const parent: Element | null = current.parentElement;
121
+
const parent: Nullable<Element> = current.parentElement;
118
122
if (!parent) break;
119
123
120
-
const index = Array.from(parent.children).indexOf(current);
124
+
const index = [...parent.children].indexOf(current);
121
125
if (index === -1) break;
122
126
123
127
path.unshift(index);
···
127
131
return path;
128
132
}
129
133
130
-
/**
131
-
* Get element by path from root
132
-
*/
133
-
function getElementByPath(path: number[], root: Element): Element | null {
134
+
function getElementByPath(path: number[], root: Element): Nullable<Element> {
134
135
let current: Element = root;
135
136
136
137
for (const index of path) {
137
-
const children = Array.from(current.children);
138
+
const children = [...current.children];
138
139
if (index >= children.length) return null;
139
140
current = children[index];
140
141
}
···
142
143
return current;
143
144
}
144
145
145
-
/**
146
-
* Restore preserved state after DOM swap
147
-
*/
148
146
function restoreState(root: Element, state: CapturedState): void {
149
147
if (state.focusPath) {
150
148
const element = getElementByPath(state.focusPath, root);
···
328
326
return { trigger, target, swap, headers, retry, indicator };
329
327
}
330
328
331
-
/**
332
-
* Get the default trigger event for an element
333
-
*/
334
329
function getDefaultTrigger(el: Element): string {
335
330
if (el instanceof HTMLFormElement) {
336
331
return "submit";
···
348
343
* @param indicator - Optional indicator selector
349
344
*/
350
345
export function setLoadingState(el: Element, indicator?: string): void {
351
-
el.setAttribute("data-volt-loading", "true");
346
+
(el as HTMLElement).dataset.voltLoading = "true";
352
347
353
348
if (indicator) {
354
349
showIndicator(indicator);
···
368
363
* @param indicator - Optional indicator selector
369
364
*/
370
365
export function setErrorState(el: Element, msg: string, indicator?: string): void {
371
-
el.setAttribute("data-volt-error", msg);
366
+
(el as HTMLElement).dataset.voltError = msg;
372
367
373
368
if (indicator) {
374
369
hideIndicator(indicator);
···
389
384
* @param indicator - Optional indicator selector
390
385
*/
391
386
export function clearStates(el: Element, indicator?: string): void {
392
-
el.removeAttribute("data-volt-loading");
393
-
el.removeAttribute("data-volt-error");
394
-
el.removeAttribute("data-volt-retry-attempt");
387
+
delete (el as HTMLElement).dataset.voltLoading;
388
+
delete (el as HTMLElement).dataset.voltError;
389
+
delete (el as HTMLElement).dataset.voltRetryAttempt;
395
390
396
391
if (indicator) {
397
392
hideIndicator(indicator);
···
399
394
400
395
el.dispatchEvent(new CustomEvent("volt:success", { detail: { element: el }, bubbles: true, cancelable: false }));
401
396
}
402
-
403
-
type IndicatorStrategy = "display" | "class";
404
-
405
-
const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>();
406
397
407
398
/**
408
399
* Detect the appropriate visibility strategy for an indicator element
···
418
409
419
410
const htmlElement = el as HTMLElement;
420
411
const inlineDisplay = htmlElement.style.display;
421
-
const computedDisplay = window.getComputedStyle(htmlElement).display;
412
+
const computedDisplay = globalThis.getComputedStyle(htmlElement).display;
422
413
423
414
if (inlineDisplay === "none" || computedDisplay === "none") {
424
415
indicatorStrategies.set(el, "display");
425
416
return "display";
426
417
}
427
418
428
-
const hasHiddenClass = Array.from(el.classList).some((cls) => cls.toLowerCase().includes("hidden"));
419
+
const hasHiddenClass = [...el.classList].some((cls) => cls.toLowerCase().includes("hidden"));
429
420
if (hasHiddenClass) {
430
421
indicatorStrategies.set(el, "class");
431
422
return "class";
···
445
436
if (strategy === "display") {
446
437
htmlElement.style.display = "";
447
438
} else {
448
-
const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
439
+
const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
449
440
el.classList.remove(hiddenClass);
450
441
}
451
442
}
···
460
451
if (strategy === "display") {
461
452
htmlElement.style.display = "none";
462
453
} else {
463
-
const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
454
+
const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
464
455
el.classList.add(hiddenClass);
465
456
}
466
457
}
···
597
588
for (let attempt = 0; attempt < maxAttempts; attempt++) {
598
589
try {
599
590
if (attempt > 0) {
600
-
target.setAttribute("data-volt-retry-attempt", String(attempt));
601
-
target.setAttribute("data-volt-loading", "retrying");
591
+
(target as HTMLElement).dataset.voltRetryAttempt = String(attempt);
592
+
(target as HTMLElement).dataset.voltLoading = "retrying";
602
593
target.dispatchEvent(
603
594
new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }),
604
595
);
···
678
669
679
670
let body: Optional<string | FormData>;
680
671
681
-
if (method !== "GET" && method !== "DELETE") {
682
-
if (ctx.element instanceof HTMLFormElement) {
683
-
body = serializeForm(ctx.element);
684
-
}
672
+
if (method !== "GET" && method !== "DELETE" && ctx.element instanceof HTMLFormElement) {
673
+
body = serializeForm(ctx.element);
685
674
}
686
675
687
676
await performRequest(ctx.element, method, resolvedUrl, config, body);
+311
lib/src/core/transitions.ts
+311
lib/src/core/transitions.ts
···
1
+
/**
2
+
* Transition preset system for surge plugin
3
+
* Provides built-in transition presets and custom preset registration
4
+
*/
5
+
6
+
import type { Optional } from "$types/helpers";
7
+
import type { ParsedTransition, TransitionPhase, TransitionPreset } from "$types/volt";
8
+
9
+
/**
10
+
* Registry of transition presets
11
+
*/
12
+
const transitionRegistry = new Map<string, TransitionPreset>();
13
+
14
+
/**
15
+
* Built-in transition presets
16
+
*/
17
+
const builtinPresets: Record<string, TransitionPreset> = {
18
+
fade: {
19
+
enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, easing: "ease" },
20
+
leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 300, easing: "ease" },
21
+
},
22
+
"slide-up": {
23
+
enter: {
24
+
from: { opacity: 0, transform: "translateY(20px)" },
25
+
to: { opacity: 1, transform: "translateY(0)" },
26
+
duration: 300,
27
+
easing: "ease-out",
28
+
},
29
+
leave: {
30
+
from: { opacity: 1, transform: "translateY(0)" },
31
+
to: { opacity: 0, transform: "translateY(-20px)" },
32
+
duration: 300,
33
+
easing: "ease-in",
34
+
},
35
+
},
36
+
"slide-down": {
37
+
enter: {
38
+
from: { opacity: 0, transform: "translateY(-20px)" },
39
+
to: { opacity: 1, transform: "translateY(0)" },
40
+
duration: 300,
41
+
easing: "ease-out",
42
+
},
43
+
leave: {
44
+
from: { opacity: 1, transform: "translateY(0)" },
45
+
to: { opacity: 0, transform: "translateY(20px)" },
46
+
duration: 300,
47
+
easing: "ease-in",
48
+
},
49
+
},
50
+
"slide-left": {
51
+
enter: {
52
+
from: { opacity: 0, transform: "translateX(20px)" },
53
+
to: { opacity: 1, transform: "translateX(0)" },
54
+
duration: 300,
55
+
easing: "ease-out",
56
+
},
57
+
leave: {
58
+
from: { opacity: 1, transform: "translateX(0)" },
59
+
to: { opacity: 0, transform: "translateX(-20px)" },
60
+
duration: 300,
61
+
easing: "ease-in",
62
+
},
63
+
},
64
+
"slide-right": {
65
+
enter: {
66
+
from: { opacity: 0, transform: "translateX(-20px)" },
67
+
to: { opacity: 1, transform: "translateX(0)" },
68
+
duration: 300,
69
+
easing: "ease-out",
70
+
},
71
+
leave: {
72
+
from: { opacity: 1, transform: "translateX(0)" },
73
+
to: { opacity: 0, transform: "translateX(20px)" },
74
+
duration: 300,
75
+
easing: "ease-in",
76
+
},
77
+
},
78
+
scale: {
79
+
enter: {
80
+
from: { opacity: 0, transform: "scale(0.95)" },
81
+
to: { opacity: 1, transform: "scale(1)" },
82
+
duration: 300,
83
+
easing: "ease-out",
84
+
},
85
+
leave: {
86
+
from: { opacity: 1, transform: "scale(1)" },
87
+
to: { opacity: 0, transform: "scale(0.95)" },
88
+
duration: 300,
89
+
easing: "ease-in",
90
+
},
91
+
},
92
+
blur: {
93
+
enter: {
94
+
from: { opacity: 0, filter: "blur(10px)" },
95
+
to: { opacity: 1, filter: "blur(0)" },
96
+
duration: 300,
97
+
easing: "ease",
98
+
},
99
+
leave: {
100
+
from: { opacity: 1, filter: "blur(0)" },
101
+
to: { opacity: 0, filter: "blur(10px)" },
102
+
duration: 300,
103
+
easing: "ease",
104
+
},
105
+
},
106
+
};
107
+
108
+
function initBuiltinPresets(): void {
109
+
for (const [name, preset] of Object.entries(builtinPresets)) {
110
+
transitionRegistry.set(name, preset);
111
+
}
112
+
}
113
+
114
+
initBuiltinPresets();
115
+
116
+
/**
117
+
* Register a custom transition preset.
118
+
* Allows users to define their own named transitions in programmatic mode.
119
+
*
120
+
* @param name - Preset name (used in data-volt-surge="name")
121
+
* @param preset - Transition configuration with enter/leave phases
122
+
*
123
+
* @example
124
+
* ```typescript
125
+
* registerTransition('custom-slide', {
126
+
* enter: {
127
+
* from: { opacity: 0, transform: 'translateX(-100px)' },
128
+
* to: { opacity: 1, transform: 'translateX(0)' },
129
+
* duration: 400,
130
+
* easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
131
+
* },
132
+
* leave: {
133
+
* from: { opacity: 1, transform: 'translateX(0)' },
134
+
* to: { opacity: 0, transform: 'translateX(100px)' },
135
+
* duration: 300,
136
+
* easing: 'ease-out'
137
+
* }
138
+
* });
139
+
* ```
140
+
*/
141
+
export function registerTransition(name: string, preset: TransitionPreset): void {
142
+
if (transitionRegistry.has(name) && Object.hasOwn(builtinPresets, name)) {
143
+
console.warn(`[Volt] Overriding built-in transition preset: "${name}"`);
144
+
}
145
+
transitionRegistry.set(name, preset);
146
+
}
147
+
148
+
/**
149
+
* Get a transition preset by name.
150
+
* Checks both custom and built-in presets.
151
+
*
152
+
* @param name - Preset name
153
+
* @returns Transition preset or undefined if not found
154
+
*/
155
+
export function getTransition(name: string): Optional<TransitionPreset> {
156
+
return transitionRegistry.get(name);
157
+
}
158
+
159
+
/**
160
+
* Check if a transition preset exists.
161
+
*
162
+
* @param name - Preset name
163
+
* @returns true if the preset is registered
164
+
*/
165
+
export function hasTransition(name: string): boolean {
166
+
return transitionRegistry.has(name);
167
+
}
168
+
169
+
/**
170
+
* Unregister a custom transition preset.
171
+
* Built-in presets cannot be unregistered.
172
+
*
173
+
* @param name - Preset name
174
+
* @returns true if the preset was removed, false otherwise
175
+
*/
176
+
export function unregisterTransition(name: string): boolean {
177
+
if (Object.hasOwn(builtinPresets, name)) {
178
+
console.warn(`[Volt] Cannot unregister built-in transition preset: "${name}"`);
179
+
return false;
180
+
}
181
+
return transitionRegistry.delete(name);
182
+
}
183
+
184
+
/**
185
+
* Get all registered transition preset names.
186
+
*
187
+
* @returns Array of preset names
188
+
*/
189
+
export function getRegisteredTransitions(): string[] {
190
+
return [...transitionRegistry.keys()];
191
+
}
192
+
193
+
/**
194
+
* Parse a transition value string into preset and modifiers.
195
+
* Supports syntax: "presetName", "presetName.duration", "presetName.duration.delay"
196
+
*
197
+
* @param value - Transition value string
198
+
* @returns Parsed transition with preset and optional duration/delay overrides
199
+
*
200
+
* @example
201
+
* ```typescript
202
+
* parseTransitionValue("fade") // { preset: fadePreset }
203
+
* parseTransitionValue("fade.500") // { preset: fadePreset, duration: 500 }
204
+
* parseTransitionValue("fade.500.100") // { preset: fadePreset, duration: 500, delay: 100 }
205
+
* ```
206
+
*/
207
+
export function parseTransitionValue(value: string): Optional<ParsedTransition> {
208
+
const parts = value.split(".");
209
+
const presetName = parts[0]?.trim();
210
+
211
+
if (!presetName) {
212
+
return undefined;
213
+
}
214
+
215
+
const preset = getTransition(presetName);
216
+
if (!preset) {
217
+
console.error(`[Volt] Unknown transition preset: "${presetName}"`);
218
+
return undefined;
219
+
}
220
+
221
+
const result: ParsedTransition = { preset };
222
+
223
+
if (parts.length > 1) {
224
+
const duration = Number.parseInt(parts[1], 10);
225
+
if (!Number.isNaN(duration)) {
226
+
result.duration = duration;
227
+
}
228
+
}
229
+
230
+
if (parts.length > 2) {
231
+
const delay = Number.parseInt(parts[2], 10);
232
+
if (!Number.isNaN(delay)) {
233
+
result.delay = delay;
234
+
}
235
+
}
236
+
237
+
return result;
238
+
}
239
+
240
+
/**
241
+
* Common easing functions mapped to CSS easing values.
242
+
* Users can also provide custom cubic-bezier strings directly.
243
+
*/
244
+
export const easings = {
245
+
linear: "linear",
246
+
ease: "ease",
247
+
"ease-in": "ease-in",
248
+
"ease-out": "ease-out",
249
+
"ease-in-out": "ease-in-out",
250
+
"ease-in-sine": "cubic-bezier(0.12, 0, 0.39, 0)",
251
+
"ease-out-sine": "cubic-bezier(0.61, 1, 0.88, 1)",
252
+
"ease-in-out-sine": "cubic-bezier(0.37, 0, 0.63, 1)",
253
+
"ease-in-quad": "cubic-bezier(0.11, 0, 0.5, 0)",
254
+
"ease-out-quad": "cubic-bezier(0.5, 1, 0.89, 1)",
255
+
"ease-in-out-quad": "cubic-bezier(0.45, 0, 0.55, 1)",
256
+
"ease-in-cubic": "cubic-bezier(0.32, 0, 0.67, 0)",
257
+
"ease-out-cubic": "cubic-bezier(0.33, 1, 0.68, 1)",
258
+
"ease-in-out-cubic": "cubic-bezier(0.65, 0, 0.35, 1)",
259
+
"ease-in-quart": "cubic-bezier(0.5, 0, 0.75, 0)",
260
+
"ease-out-quart": "cubic-bezier(0.25, 1, 0.5, 1)",
261
+
"ease-in-out-quart": "cubic-bezier(0.76, 0, 0.24, 1)",
262
+
"ease-in-quint": "cubic-bezier(0.64, 0, 0.78, 0)",
263
+
"ease-out-quint": "cubic-bezier(0.22, 1, 0.36, 1)",
264
+
"ease-in-out-quint": "cubic-bezier(0.83, 0, 0.17, 1)",
265
+
"ease-in-expo": "cubic-bezier(0.7, 0, 0.84, 0)",
266
+
"ease-out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
267
+
"ease-in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)",
268
+
"ease-in-circ": "cubic-bezier(0.55, 0, 1, 0.45)",
269
+
"ease-out-circ": "cubic-bezier(0, 0.55, 0.45, 1)",
270
+
"ease-in-out-circ": "cubic-bezier(0.85, 0, 0.15, 1)",
271
+
"ease-in-back": "cubic-bezier(0.36, 0, 0.66, -0.56)",
272
+
"ease-out-back": "cubic-bezier(0.34, 1.56, 0.64, 1)",
273
+
"ease-in-out-back": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
274
+
} as const;
275
+
276
+
/**
277
+
* Get the CSS easing value for a named easing function.
278
+
* If the input is not a named easing, returns it as-is (for custom cubic-bezier).
279
+
*
280
+
* @param name - Easing name or custom cubic-bezier string
281
+
* @returns CSS easing value
282
+
*/
283
+
export function getEasing(name: string): string {
284
+
return (easings as Record<string, string>)[name] ?? name;
285
+
}
286
+
287
+
/**
288
+
* Check if reduced motion is preferred by the user.
289
+
* Respects prefers-reduced-motion media query for accessibility.
290
+
*
291
+
* @returns true if user prefers reduced motion
292
+
*/
293
+
export function prefersReducedMotion(): boolean {
294
+
if (globalThis.window === undefined || !globalThis.matchMedia) {
295
+
return false;
296
+
}
297
+
return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches;
298
+
}
299
+
300
+
/**
301
+
* Apply duration/delay overrides to a transition phase.
302
+
* Returns a new phase object with merged properties.
303
+
*
304
+
* @param phase - Original transition phase
305
+
* @param duration - Optional duration override
306
+
* @param delay - Optional delay override
307
+
* @returns New phase with overrides applied
308
+
*/
309
+
export function applyOverrides(phase: TransitionPhase, duration?: number, delay?: number): TransitionPhase {
310
+
return { ...phase, ...(duration !== undefined && { duration }), ...(delay !== undefined && { delay }) };
311
+
}
+16
lib/src/index.ts
+16
lib/src/index.ts
···
23
23
export { computed, effect, signal } from "$core/signal";
24
24
export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr";
25
25
export { getStore, registerStore } from "$core/store";
26
+
export {
27
+
applyOverrides,
28
+
easings,
29
+
getEasing,
30
+
getRegisteredTransitions,
31
+
getTransition,
32
+
hasTransition,
33
+
parseTransitionValue,
34
+
prefersReducedMotion,
35
+
registerTransition,
36
+
unregisterTransition,
37
+
} from "$core/transitions";
26
38
export { persistPlugin, registerStorageAdapter } from "$plugins/persist";
27
39
export { scrollPlugin } from "$plugins/scroll";
40
+
export { surgePlugin } from "$plugins/surge";
28
41
export { urlPlugin } from "$plugins/url";
29
42
export type {
30
43
ArcFunction,
···
39
52
HydrateResult,
40
53
IsReactive,
41
54
ParsedHttpConfig,
55
+
ParsedTransition,
42
56
PinRegistry,
43
57
PluginContext,
44
58
PluginHandler,
···
49
63
ScopeMetadata,
50
64
SerializedScope,
51
65
Signal,
66
+
TransitionPhase,
67
+
TransitionPreset,
52
68
UidFunction,
53
69
UnwrapReactive,
54
70
} from "$types/volt";
+373
lib/src/plugins/surge.ts
+373
lib/src/plugins/surge.ts
···
1
+
/**
2
+
* Surge plugin for enter/leave transitions
3
+
* Provides smooth animations when elements appear or disappear
4
+
*/
5
+
6
+
import { sleep } from "$core/shared";
7
+
import { applyOverrides, getEasing, parseTransitionValue, prefersReducedMotion } from "$core/transitions";
8
+
import type { Optional } from "$types/helpers";
9
+
import type { PluginContext, Signal, TransitionPhase } from "$types/volt";
10
+
11
+
type SurgeConfig = {
12
+
enterPreset?: TransitionPhase;
13
+
leavePreset?: TransitionPhase;
14
+
signalPath?: string;
15
+
useViewTransitions: boolean;
16
+
};
17
+
18
+
function supportsViewTransitions(): boolean {
19
+
return typeof document !== "undefined" && "startViewTransition" in document;
20
+
}
21
+
22
+
function withViewTransition(callback: () => void): void {
23
+
if (supportsViewTransitions() && !prefersReducedMotion()) {
24
+
(document as Document & { startViewTransition: (callback: () => void) => void }).startViewTransition(callback);
25
+
} else {
26
+
callback();
27
+
}
28
+
}
29
+
30
+
function applyStyles(element: HTMLElement, styles: Record<string, string | number>): void {
31
+
for (const [property, value] of Object.entries(styles)) {
32
+
const cssProperty = property.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
33
+
element.style.setProperty(cssProperty, String(value));
34
+
}
35
+
}
36
+
37
+
function applyClasses(el: HTMLElement, classes: string[]): void {
38
+
for (const cls of classes) {
39
+
el.classList.add(cls);
40
+
}
41
+
}
42
+
43
+
function rmClasses(el: HTMLElement, classes: string[]): void {
44
+
for (const cls of classes) {
45
+
el.classList.remove(cls);
46
+
}
47
+
}
48
+
49
+
async function execEnter(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> {
50
+
const duration = phase.duration ?? 300;
51
+
const delay = phase.delay ?? 0;
52
+
const easing = getEasing(phase.easing ?? "ease");
53
+
54
+
if (prefersReducedMotion()) {
55
+
if (phase.to) {
56
+
applyStyles(element, phase.to);
57
+
}
58
+
if (phase.classes) {
59
+
applyClasses(element, phase.classes);
60
+
}
61
+
return;
62
+
}
63
+
64
+
if (phase.from) {
65
+
applyStyles(element, phase.from);
66
+
}
67
+
68
+
if (phase.classes) {
69
+
applyClasses(element, phase.classes);
70
+
}
71
+
72
+
void element.offsetHeight;
73
+
74
+
element.style.transition = `all ${duration}ms ${easing} ${delay}ms`;
75
+
76
+
if (delay > 0) {
77
+
await sleep(delay);
78
+
}
79
+
80
+
const transitionPromise = new Promise<void>((resolve) => {
81
+
const handleTransitionEnd = (event: TransitionEvent) => {
82
+
if (event.target === element) {
83
+
element.removeEventListener("transitionend", handleTransitionEnd);
84
+
resolve();
85
+
}
86
+
};
87
+
88
+
element.addEventListener("transitionend", handleTransitionEnd);
89
+
90
+
setTimeout(() => {
91
+
element.removeEventListener("transitionend", handleTransitionEnd);
92
+
resolve();
93
+
}, duration + delay + 50);
94
+
});
95
+
96
+
if (useViewTransitions) {
97
+
withViewTransition(() => {
98
+
if (phase.to) {
99
+
applyStyles(element, phase.to);
100
+
}
101
+
});
102
+
} else {
103
+
if (phase.to) {
104
+
applyStyles(element, phase.to);
105
+
}
106
+
}
107
+
108
+
await transitionPromise;
109
+
110
+
element.style.transition = "";
111
+
112
+
if (phase.classes) {
113
+
rmClasses(element, phase.classes);
114
+
}
115
+
}
116
+
117
+
async function execLeave(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> {
118
+
const duration = phase.duration ?? 300;
119
+
const delay = phase.delay ?? 0;
120
+
const easing = getEasing(phase.easing ?? "ease");
121
+
122
+
if (prefersReducedMotion()) {
123
+
if (phase.to) {
124
+
applyStyles(element, phase.to);
125
+
}
126
+
if (phase.classes) {
127
+
applyClasses(element, phase.classes);
128
+
}
129
+
return;
130
+
}
131
+
132
+
if (phase.from) {
133
+
applyStyles(element, phase.from);
134
+
}
135
+
136
+
if (phase.classes) {
137
+
applyClasses(element, phase.classes);
138
+
}
139
+
140
+
void element.offsetHeight;
141
+
142
+
element.style.transition = `all ${duration}ms ${easing} ${delay}ms`;
143
+
144
+
if (delay > 0) {
145
+
await sleep(delay);
146
+
}
147
+
148
+
const transitionPromise = new Promise<void>((resolve) => {
149
+
const handleTransitionEnd = (event: TransitionEvent) => {
150
+
if (event.target === element) {
151
+
element.removeEventListener("transitionend", handleTransitionEnd);
152
+
resolve();
153
+
}
154
+
};
155
+
156
+
element.addEventListener("transitionend", handleTransitionEnd);
157
+
158
+
setTimeout(() => {
159
+
element.removeEventListener("transitionend", handleTransitionEnd);
160
+
resolve();
161
+
}, duration + delay + 50);
162
+
});
163
+
164
+
if (useViewTransitions) {
165
+
withViewTransition(() => {
166
+
if (phase.to) {
167
+
applyStyles(element, phase.to);
168
+
}
169
+
});
170
+
} else {
171
+
if (phase.to) {
172
+
applyStyles(element, phase.to);
173
+
}
174
+
}
175
+
176
+
await transitionPromise;
177
+
178
+
element.style.transition = "";
179
+
180
+
if (phase.classes) {
181
+
rmClasses(element, phase.classes);
182
+
}
183
+
}
184
+
185
+
/**
186
+
* Parse surge plugin value to extract configuration
187
+
* Supports:
188
+
* - "presetName" - default preset
189
+
* - "signalPath:presetName" - watch signal with preset
190
+
* - "signalPath" - watch signal with default fade
191
+
*/
192
+
function parseSurgeValue(value: string): Optional<SurgeConfig> {
193
+
const parts = value.split(":");
194
+
195
+
if (parts.length === 2) {
196
+
const [signalPath, presetValue] = parts;
197
+
const parsed = parseTransitionValue(presetValue.trim());
198
+
199
+
if (!parsed) {
200
+
return undefined;
201
+
}
202
+
203
+
return {
204
+
enterPreset: parsed.preset.enter,
205
+
leavePreset: parsed.preset.leave,
206
+
signalPath: signalPath.trim(),
207
+
useViewTransitions: true,
208
+
};
209
+
}
210
+
211
+
const parsed = parseTransitionValue(value.trim());
212
+
if (!parsed) {
213
+
return undefined;
214
+
}
215
+
216
+
return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true };
217
+
}
218
+
219
+
function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional<TransitionPhase> {
220
+
const parsed = parseTransitionValue(value.trim());
221
+
if (!parsed) {
222
+
return undefined;
223
+
}
224
+
const presetPhase = phase === "enter" ? parsed.preset.enter : parsed.preset.leave;
225
+
return applyOverrides(presetPhase, parsed.duration, parsed.delay);
226
+
}
227
+
228
+
/**
229
+
* Surge plugin handler.
230
+
* Provides enter/leave transitions for elements.
231
+
*
232
+
* Syntax:
233
+
* - data-volt-surge="presetName" - Default transition preset
234
+
* - data-volt-surge="signalPath:presetName" - Watch signal for transitions
235
+
* - data-volt-surge:enter="presetName" - Specific enter transition
236
+
* - data-volt-surge:leave="presetName" - Specific leave transition
237
+
*
238
+
* @example
239
+
* ```html
240
+
* <!-- Explicit signal watching -->
241
+
* <div data-volt-surge="show:fade">Content</div>
242
+
*
243
+
* <!-- Granular control -->
244
+
* <div
245
+
* data-volt-surge:enter="slide-down.500"
246
+
* data-volt-surge:leave="fade.300">
247
+
* Content
248
+
* </div>
249
+
* ```
250
+
*/
251
+
export function surgePlugin(ctx: PluginContext, value: string): void {
252
+
const el = ctx.element as HTMLElement;
253
+
254
+
if (value.includes(":")) {
255
+
const [phase, presetValue] = value.split(":", 2);
256
+
257
+
if (phase === "enter") {
258
+
const enterPhase = parsePhaseValue(presetValue, "enter");
259
+
if (!enterPhase) {
260
+
console.error(`[Volt] Invalid surge enter value: "${value}"`);
261
+
return;
262
+
}
263
+
264
+
(el as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter = enterPhase;
265
+
return;
266
+
}
267
+
268
+
if (phase === "leave") {
269
+
const leavePhase = parsePhaseValue(presetValue, "leave");
270
+
if (!leavePhase) {
271
+
console.error(`[Volt] Invalid surge leave value: "${value}"`);
272
+
return;
273
+
}
274
+
275
+
(el as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave = leavePhase;
276
+
return;
277
+
}
278
+
}
279
+
280
+
const config = parseSurgeValue(value);
281
+
if (!config) {
282
+
console.error(`[Volt] Invalid surge value: "${value}"`);
283
+
return;
284
+
}
285
+
286
+
if (!config.signalPath) {
287
+
(el as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig = config;
288
+
return;
289
+
}
290
+
291
+
const signal = ctx.findSignal(config.signalPath) as Optional<Signal<unknown>>;
292
+
if (!signal) {
293
+
console.error(`[Volt] Signal "${config.signalPath}" not found for surge binding`);
294
+
return;
295
+
}
296
+
297
+
let isVisible = Boolean(signal.get());
298
+
let isTransitioning = false;
299
+
300
+
if (!isVisible) {
301
+
el.style.display = "none";
302
+
}
303
+
304
+
const handleTransition = async (shouldShow: boolean) => {
305
+
if (isTransitioning || shouldShow === isVisible) {
306
+
return;
307
+
}
308
+
309
+
isTransitioning = true;
310
+
311
+
if (shouldShow && config.enterPreset) {
312
+
el.style.display = "";
313
+
await execEnter(el, config.enterPreset, config.useViewTransitions);
314
+
isVisible = true;
315
+
} else if (!shouldShow && config.leavePreset) {
316
+
await execLeave(el, config.leavePreset, config.useViewTransitions);
317
+
el.style.display = "none";
318
+
isVisible = false;
319
+
}
320
+
321
+
isTransitioning = false;
322
+
};
323
+
324
+
const unsubscribe = signal.subscribe((value) => {
325
+
const shouldShow = Boolean(value);
326
+
void handleTransition(shouldShow);
327
+
});
328
+
329
+
ctx.addCleanup(unsubscribe);
330
+
}
331
+
332
+
/**
333
+
* @internal
334
+
*/
335
+
export async function executeSurgeEnter(element: HTMLElement): Promise<void> {
336
+
const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
337
+
const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter;
338
+
339
+
const enterPhase = customEnter ?? config?.enterPreset;
340
+
if (!enterPhase) {
341
+
return;
342
+
}
343
+
344
+
const useViewTransitions = config?.useViewTransitions ?? true;
345
+
await execEnter(element, enterPhase, useViewTransitions);
346
+
}
347
+
348
+
/**
349
+
* @internal
350
+
*/
351
+
export async function executeSurgeLeave(element: HTMLElement): Promise<void> {
352
+
const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
353
+
const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave;
354
+
355
+
const leavePhase = customLeave ?? config?.leavePreset;
356
+
if (!leavePhase) {
357
+
return;
358
+
}
359
+
360
+
const useViewTransitions = config?.useViewTransitions ?? true;
361
+
await execLeave(element, leavePhase, useViewTransitions);
362
+
}
363
+
364
+
/**
365
+
* @internal
366
+
*/
367
+
export function hasSurge(element: HTMLElement): boolean {
368
+
const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
369
+
const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter;
370
+
const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave;
371
+
372
+
return Boolean(config || customEnter || customLeave);
373
+
}
+140
lib/src/types/volt.d.ts
+140
lib/src/types/volt.d.ts
···
425
425
* Function signature for $probe() - reactive observer
426
426
*/
427
427
export type ProbeFunction = (expression: string, callback: (value: unknown) => void) => CleanupFunction;
428
+
429
+
/**
430
+
* Configuration for a single transition phase (enter or leave)
431
+
*/
432
+
export type TransitionPhase = {
433
+
/**
434
+
* Initial CSS properties (applied immediately)
435
+
*/
436
+
from?: Record<string, string | number>;
437
+
438
+
/**
439
+
* Target CSS properties (animated to)
440
+
*/
441
+
to?: Record<string, string | number>;
442
+
443
+
/**
444
+
* Duration in milliseconds (default: 300)
445
+
*/
446
+
duration?: number;
447
+
448
+
/**
449
+
* Delay in milliseconds (default: 0)
450
+
*/
451
+
delay?: number;
452
+
453
+
/**
454
+
* CSS easing function (default: 'ease')
455
+
*/
456
+
easing?: string;
457
+
458
+
/**
459
+
* CSS classes to apply during this phase
460
+
*/
461
+
classes?: string[];
462
+
};
463
+
464
+
/**
465
+
* Complete transition preset with enter and leave phases
466
+
*/
467
+
export type TransitionPreset = {
468
+
/**
469
+
* Configuration for enter transition
470
+
*/
471
+
enter: TransitionPhase;
472
+
473
+
/**
474
+
* Configuration for leave transition
475
+
*/
476
+
leave: TransitionPhase;
477
+
};
478
+
479
+
/**
480
+
* Parsed transition value with preset and modifiers
481
+
*/
482
+
export type ParsedTransition = {
483
+
/**
484
+
* The transition preset to use
485
+
*/
486
+
preset: TransitionPreset;
487
+
488
+
/**
489
+
* Override duration from preset syntax (e.g., "fade.500")
490
+
*/
491
+
duration?: number;
492
+
493
+
/**
494
+
* Override delay from preset syntax (e.g., "fade.500.100")
495
+
*/
496
+
delay?: number;
497
+
};
498
+
499
+
/**
500
+
* Configuration for a single transition phase (enter or leave)
501
+
*/
502
+
export type TransitionPhase = {
503
+
/**
504
+
* Initial CSS properties (applied immediately)
505
+
*/
506
+
from?: Record<string, string | number>;
507
+
508
+
/**
509
+
* Target CSS properties (animated to)
510
+
*/
511
+
to?: Record<string, string | number>;
512
+
513
+
/**
514
+
* Duration in milliseconds (default: 300)
515
+
*/
516
+
duration?: number;
517
+
518
+
/**
519
+
* Delay in milliseconds (default: 0)
520
+
*/
521
+
delay?: number;
522
+
523
+
/**
524
+
* CSS easing function (default: 'ease')
525
+
*/
526
+
easing?: string;
527
+
528
+
/**
529
+
* CSS classes to apply during this phase
530
+
*/
531
+
classes?: string[];
532
+
};
533
+
534
+
/**
535
+
* Complete transition preset with enter and leave phases
536
+
*/
537
+
export type TransitionPreset = {
538
+
/**
539
+
* Configuration for enter transition
540
+
*/
541
+
enter: TransitionPhase;
542
+
543
+
/**
544
+
* Configuration for leave transition
545
+
*/
546
+
leave: TransitionPhase;
547
+
};
548
+
549
+
/**
550
+
* Parsed transition value with preset and modifiers
551
+
*/
552
+
export type ParsedTransition = {
553
+
/**
554
+
* The transition preset to use
555
+
*/
556
+
preset: TransitionPreset;
557
+
558
+
/**
559
+
* Override duration from preset syntax (e.g., "fade.500")
560
+
*/
561
+
duration?: number;
562
+
563
+
/**
564
+
* Override delay from preset syntax (e.g., "fade.500.100")
565
+
*/
566
+
delay?: number;
567
+
};
+301
lib/test/core/transitions.test.ts
+301
lib/test/core/transitions.test.ts
···
1
+
import {
2
+
applyOverrides,
3
+
easings,
4
+
getEasing,
5
+
getRegisteredTransitions,
6
+
getTransition,
7
+
hasTransition,
8
+
parseTransitionValue,
9
+
prefersReducedMotion,
10
+
registerTransition,
11
+
unregisterTransition,
12
+
} from "$core/transitions";
13
+
import type { TransitionPreset } from "$types/volt";
14
+
import { describe, expect, it, vi } from "vitest";
15
+
16
+
describe("Transition Preset System", () => {
17
+
describe("Built-in Presets", () => {
18
+
it("should have fade preset registered", () => {
19
+
expect(hasTransition("fade")).toBe(true);
20
+
const fade = getTransition("fade");
21
+
expect(fade).toBeDefined();
22
+
expect(fade?.enter.from).toEqual({ opacity: 0 });
23
+
expect(fade?.enter.to).toEqual({ opacity: 1 });
24
+
expect(fade?.leave.from).toEqual({ opacity: 1 });
25
+
expect(fade?.leave.to).toEqual({ opacity: 0 });
26
+
});
27
+
28
+
it.each([{
29
+
name: "slide-up",
30
+
enterFrom: { opacity: 0, transform: "translateY(20px)" },
31
+
enterTo: { opacity: 1, transform: "translateY(0)" },
32
+
}, {
33
+
name: "slide-down",
34
+
enterFrom: { opacity: 0, transform: "translateY(-20px)" },
35
+
enterTo: { opacity: 1, transform: "translateY(0)" },
36
+
}, {
37
+
name: "slide-left",
38
+
enterFrom: { opacity: 0, transform: "translateX(20px)" },
39
+
enterTo: { opacity: 1, transform: "translateX(0)" },
40
+
}, {
41
+
name: "slide-right",
42
+
enterFrom: { opacity: 0, transform: "translateX(-20px)" },
43
+
enterTo: { opacity: 1, transform: "translateX(0)" },
44
+
}, {
45
+
name: "scale",
46
+
enterFrom: { opacity: 0, transform: "scale(0.95)" },
47
+
enterTo: { opacity: 1, transform: "scale(1)" },
48
+
}, { name: "blur", enterFrom: { opacity: 0, filter: "blur(10px)" }, enterTo: { opacity: 1, filter: "blur(0)" } }])(
49
+
"should have $name preset registered",
50
+
({ name, enterFrom, enterTo }) => {
51
+
expect(hasTransition(name)).toBe(true);
52
+
const preset = getTransition(name);
53
+
expect(preset).toBeDefined();
54
+
expect(preset?.enter.from).toEqual(enterFrom);
55
+
expect(preset?.enter.to).toEqual(enterTo);
56
+
},
57
+
);
58
+
59
+
it("should return all built-in preset names", () => {
60
+
const presets = getRegisteredTransitions();
61
+
62
+
for (const preset of ["fade", "slide-up", "slide-down", "slide-left", "slide-right", "scale", "blur"]) {
63
+
expect(presets).toContain(preset);
64
+
}
65
+
});
66
+
});
67
+
68
+
describe("Custom Preset Registration", () => {
69
+
it("should register a custom transition preset", () => {
70
+
const customPreset: TransitionPreset = {
71
+
enter: {
72
+
from: { opacity: 0, transform: "translateX(-100px)" },
73
+
to: { opacity: 1, transform: "translateX(0)" },
74
+
duration: 400,
75
+
easing: "ease-out",
76
+
},
77
+
leave: {
78
+
from: { opacity: 1, transform: "translateX(0)" },
79
+
to: { opacity: 0, transform: "translateX(100px)" },
80
+
duration: 300,
81
+
easing: "ease-in",
82
+
},
83
+
};
84
+
85
+
registerTransition("custom-slide", customPreset);
86
+
expect(hasTransition("custom-slide")).toBe(true);
87
+
88
+
const retrieved = getTransition("custom-slide");
89
+
expect(retrieved).toEqual(customPreset);
90
+
});
91
+
92
+
it("should unregister a custom preset", () => {
93
+
const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } };
94
+
95
+
registerTransition("temp-preset", customPreset);
96
+
expect(hasTransition("temp-preset")).toBe(true);
97
+
98
+
const result = unregisterTransition("temp-preset");
99
+
expect(result).toBe(true);
100
+
expect(hasTransition("temp-preset")).toBe(false);
101
+
});
102
+
103
+
it("should not unregister built-in presets", () => {
104
+
const result = unregisterTransition("fade");
105
+
expect(result).toBe(false);
106
+
expect(hasTransition("fade")).toBe(true);
107
+
});
108
+
109
+
it("should warn when overriding built-in preset", () => {
110
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
111
+
const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } };
112
+
113
+
registerTransition("fade", customPreset);
114
+
expect(consoleSpy).toHaveBeenCalledWith("[Volt] Overriding built-in transition preset: \"fade\"");
115
+
116
+
consoleSpy.mockRestore();
117
+
});
118
+
119
+
it("should return undefined for unknown preset", () => {
120
+
const preset = getTransition("nonexistent");
121
+
expect(preset).toBeUndefined();
122
+
});
123
+
});
124
+
125
+
describe("Parse Transition Value", () => {
126
+
it("should parse preset name only", () => {
127
+
const parsed = parseTransitionValue("fade");
128
+
expect(parsed).toBeDefined();
129
+
expect(parsed?.preset).toEqual(getTransition("fade"));
130
+
expect(parsed?.duration).toBeUndefined();
131
+
expect(parsed?.delay).toBeUndefined();
132
+
});
133
+
134
+
it("should parse preset name with duration", () => {
135
+
const parsed = parseTransitionValue("fade.500");
136
+
expect(parsed).toBeDefined();
137
+
expect(parsed?.preset).toEqual(getTransition("fade"));
138
+
expect(parsed?.duration).toBe(500);
139
+
expect(parsed?.delay).toBeUndefined();
140
+
});
141
+
142
+
it("should parse preset name with duration and delay", () => {
143
+
const parsed = parseTransitionValue("slide-down.600.100");
144
+
expect(parsed).toBeDefined();
145
+
expect(parsed?.preset).toEqual(getTransition("slide-down"));
146
+
expect(parsed?.duration).toBe(600);
147
+
expect(parsed?.delay).toBe(100);
148
+
});
149
+
150
+
it("should handle whitespace", () => {
151
+
const parsed = parseTransitionValue(" fade.500.100 ");
152
+
expect(parsed).toBeDefined();
153
+
expect(parsed?.preset).toEqual(getTransition("fade"));
154
+
expect(parsed?.duration).toBe(500);
155
+
expect(parsed?.delay).toBe(100);
156
+
});
157
+
158
+
it("should return undefined for empty string", () => {
159
+
const parsed = parseTransitionValue("");
160
+
expect(parsed).toBeUndefined();
161
+
});
162
+
163
+
it("should return undefined for unknown preset", () => {
164
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
165
+
166
+
const parsed = parseTransitionValue("nonexistent");
167
+
expect(parsed).toBeUndefined();
168
+
expect(consoleSpy).toHaveBeenCalled();
169
+
170
+
consoleSpy.mockRestore();
171
+
});
172
+
173
+
it("should ignore invalid duration values", () => {
174
+
const parsed = parseTransitionValue("fade.abc");
175
+
expect(parsed).toBeDefined();
176
+
expect(parsed?.duration).toBeUndefined();
177
+
});
178
+
179
+
it("should ignore invalid delay values", () => {
180
+
const parsed = parseTransitionValue("fade.500.xyz");
181
+
expect(parsed).toBeDefined();
182
+
expect(parsed?.duration).toBe(500);
183
+
expect(parsed?.delay).toBeUndefined();
184
+
});
185
+
});
186
+
187
+
describe("Easing Functions", () => {
188
+
it("should return CSS easing for named easings", () => {
189
+
for (const e of ["linear", "ease", "ease-in", "ease-out", "ease-in-out"]) {
190
+
const res = getEasing(e);
191
+
expect(res).toEqual(e);
192
+
}
193
+
});
194
+
195
+
it("should return cubic-bezier for named easing curves", () => {
196
+
expect(getEasing("ease-in-sine")).toBe("cubic-bezier(0.12, 0, 0.39, 0)");
197
+
expect(getEasing("ease-out-sine")).toBe("cubic-bezier(0.61, 1, 0.88, 1)");
198
+
expect(getEasing("ease-in-quad")).toBe("cubic-bezier(0.11, 0, 0.5, 0)");
199
+
});
200
+
201
+
it("should return custom cubic-bezier as-is", () => {
202
+
const custom = "cubic-bezier(0.25, 0.1, 0.25, 1)";
203
+
expect(getEasing(custom)).toBe(custom);
204
+
});
205
+
206
+
it("should have all easing constants defined", () => {
207
+
for (
208
+
const prop of [
209
+
"linear",
210
+
"ease",
211
+
"ease-in",
212
+
"ease-out",
213
+
"ease-in-out",
214
+
"ease-in-back",
215
+
"ease-out-back",
216
+
"ease-in-out-back",
217
+
]
218
+
) {
219
+
expect(easings).toHaveProperty(prop);
220
+
}
221
+
});
222
+
});
223
+
224
+
describe("Apply Overrides", () => {
225
+
it("should apply duration override", () => {
226
+
const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
227
+
const overridden = applyOverrides(phase, 500);
228
+
expect(overridden.duration).toBe(500);
229
+
expect(overridden.delay).toBe(0);
230
+
expect(overridden.from).toEqual({ opacity: 0 });
231
+
expect(overridden.to).toEqual({ opacity: 1 });
232
+
expect(overridden.easing).toBe("ease");
233
+
});
234
+
235
+
it("should apply delay override", () => {
236
+
const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
237
+
const overridden = applyOverrides(phase, undefined, 100);
238
+
expect(overridden.duration).toBe(300);
239
+
expect(overridden.delay).toBe(100);
240
+
});
241
+
242
+
it("should apply both duration and delay overrides", () => {
243
+
const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
244
+
const overridden = applyOverrides(phase, 600, 200);
245
+
expect(overridden.duration).toBe(600);
246
+
expect(overridden.delay).toBe(200);
247
+
});
248
+
249
+
it("should not mutate original phase", () => {
250
+
const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" };
251
+
const overridden = applyOverrides(phase, 500, 100);
252
+
expect(phase.duration).toBe(300);
253
+
expect(phase.delay).toBe(0);
254
+
expect(overridden).not.toBe(phase);
255
+
});
256
+
257
+
it("should preserve all properties when no overrides", () => {
258
+
const phase = {
259
+
from: { opacity: 0, transform: "translateY(20px)" },
260
+
to: { opacity: 1, transform: "translateY(0)" },
261
+
duration: 300,
262
+
delay: 50,
263
+
easing: "ease-out",
264
+
classes: ["entering"],
265
+
};
266
+
267
+
const overridden = applyOverrides(phase);
268
+
expect(overridden).toEqual(phase);
269
+
expect(overridden).not.toBe(phase);
270
+
});
271
+
});
272
+
273
+
describe("Prefers Reduced Motion", () => {
274
+
it("should return false when matchMedia is not available", () => {
275
+
const originalMatchMedia = globalThis.matchMedia;
276
+
// @ts-expect-error - Testing undefined case
277
+
delete globalThis.matchMedia;
278
+
279
+
expect(prefersReducedMotion()).toBe(false);
280
+
281
+
globalThis.matchMedia = originalMatchMedia;
282
+
});
283
+
284
+
it("should check prefers-reduced-motion media query", () => {
285
+
const mockMatchMedia = vi.fn().mockReturnValue({ matches: true });
286
+
globalThis.matchMedia = mockMatchMedia;
287
+
288
+
const result = prefersReducedMotion();
289
+
290
+
expect(mockMatchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
291
+
expect(result).toBe(true);
292
+
});
293
+
294
+
it("should return false when user does not prefer reduced motion", () => {
295
+
const mockMatchMedia = vi.fn().mockReturnValue({ matches: false });
296
+
globalThis.matchMedia = mockMatchMedia;
297
+
const result = prefersReducedMotion();
298
+
expect(result).toBe(false);
299
+
});
300
+
});
301
+
});
+395
lib/test/plugins/surge.test.ts
+395
lib/test/plugins/surge.test.ts
···
1
+
import { signal } from "$core/signal";
2
+
import { registerTransition } from "$core/transitions";
3
+
import { executeSurgeEnter, executeSurgeLeave, hasSurge, surgePlugin } from "$plugins/surge";
4
+
import type { TransitionPreset } from "$types/volt";
5
+
import type { PluginContext } from "$types/volt";
6
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+
describe("Surge Plugin", () => {
9
+
let container: HTMLDivElement;
10
+
let element: HTMLElement;
11
+
let mockContext: PluginContext;
12
+
let cleanups: Array<() => void>;
13
+
14
+
beforeEach(() => {
15
+
container = document.createElement("div");
16
+
element = document.createElement("div");
17
+
element.textContent = "Test Content";
18
+
container.append(element);
19
+
document.body.append(container);
20
+
21
+
cleanups = [];
22
+
23
+
mockContext = {
24
+
element,
25
+
scope: {},
26
+
addCleanup: (fn) => {
27
+
cleanups.push(fn);
28
+
},
29
+
findSignal: vi.fn(),
30
+
evaluate: vi.fn(),
31
+
lifecycle: { onMount: vi.fn(), onUnmount: vi.fn(), beforeBinding: vi.fn(), afterBinding: vi.fn() },
32
+
};
33
+
34
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
35
+
});
36
+
37
+
afterEach(() => {
38
+
for (const cleanup of cleanups) {
39
+
cleanup();
40
+
}
41
+
cleanups = [];
42
+
container.remove();
43
+
vi.restoreAllMocks();
44
+
});
45
+
46
+
describe("Configuration Storage", () => {
47
+
it("should store config when no signal path provided", () => {
48
+
surgePlugin(mockContext, "fade");
49
+
expect(hasSurge(element as HTMLElement)).toBe(true);
50
+
});
51
+
52
+
it("should store enter-specific config", () => {
53
+
surgePlugin(mockContext, "enter:slide-down");
54
+
const stored = (element as HTMLElement & { _voltSurgeEnter?: unknown })._voltSurgeEnter;
55
+
expect(stored).toBeDefined();
56
+
});
57
+
58
+
it("should store leave-specific config", () => {
59
+
surgePlugin(mockContext, "leave:fade.300");
60
+
const stored = (element as HTMLElement & { _voltSurgeLeave?: unknown })._voltSurgeLeave;
61
+
expect(stored).toBeDefined();
62
+
});
63
+
});
64
+
65
+
describe("Signal Watching (Explicit Mode)", () => {
66
+
it("should watch signal and show/hide element", async () => {
67
+
vi.useFakeTimers();
68
+
69
+
const showSignal = signal(false);
70
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
71
+
mockContext.scope = { show: showSignal };
72
+
73
+
surgePlugin(mockContext, "show:fade");
74
+
75
+
expect(element.style.display).toBe("none");
76
+
77
+
showSignal.set(true);
78
+
await vi.advanceTimersByTimeAsync(400);
79
+
expect(element.style.display).not.toBe("none");
80
+
81
+
showSignal.set(false);
82
+
await vi.advanceTimersByTimeAsync(400);
83
+
expect(element.style.display).toBe("none");
84
+
85
+
vi.useRealTimers();
86
+
});
87
+
88
+
it("should apply transitions when showing element", async () => {
89
+
const showSignal = signal(false);
90
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
91
+
92
+
surgePlugin(mockContext, "show:fade");
93
+
94
+
showSignal.set(true);
95
+
96
+
await new Promise((resolve) => {
97
+
setTimeout(resolve, 50);
98
+
});
99
+
100
+
expect(element.style.display).not.toBe("none");
101
+
});
102
+
103
+
it("should cleanup subscription on unmount", () => {
104
+
const showSignal = signal(true);
105
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
106
+
107
+
surgePlugin(mockContext, "show:fade");
108
+
109
+
expect(cleanups.length).toBeGreaterThan(0);
110
+
111
+
for (const cleanup of cleanups) {
112
+
cleanup();
113
+
}
114
+
115
+
const initialDisplay = element.style.display;
116
+
showSignal.set(false);
117
+
118
+
expect(element.style.display).toBe(initialDisplay);
119
+
});
120
+
121
+
it("should error when signal not found", () => {
122
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
123
+
mockContext.findSignal = vi.fn().mockReturnValue(void 0);
124
+
125
+
surgePlugin(mockContext, "nonexistent:fade");
126
+
expect(consoleSpy).toHaveBeenCalledWith("[Volt] Signal \"nonexistent\" not found for surge binding");
127
+
128
+
consoleSpy.mockRestore();
129
+
});
130
+
131
+
it("should not transition if already in target state", async () => {
132
+
const showSignal = signal(true);
133
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
134
+
135
+
surgePlugin(mockContext, "show:fade");
136
+
expect(element.style.display).not.toBe("none");
137
+
138
+
const initialStyles = element.style.cssText;
139
+
showSignal.set(true);
140
+
141
+
await new Promise((resolve) => {
142
+
setTimeout(resolve, 50);
143
+
});
144
+
145
+
expect(element.style.cssText).toBe(initialStyles);
146
+
});
147
+
});
148
+
149
+
describe("Custom Presets", () => {
150
+
it("should use custom registered preset", async () => {
151
+
const customPreset: TransitionPreset = {
152
+
enter: {
153
+
from: { opacity: 0, transform: "scale(0.5)" },
154
+
to: { opacity: 1, transform: "scale(1)" },
155
+
duration: 200,
156
+
easing: "ease-out",
157
+
},
158
+
leave: {
159
+
from: { opacity: 1, transform: "scale(1)" },
160
+
to: { opacity: 0, transform: "scale(0.5)" },
161
+
duration: 200,
162
+
easing: "ease-in",
163
+
},
164
+
};
165
+
166
+
registerTransition("custom-scale", customPreset);
167
+
168
+
const showSignal = signal(false);
169
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
170
+
171
+
surgePlugin(mockContext, "show:custom-scale");
172
+
173
+
showSignal.set(true);
174
+
175
+
await new Promise((resolve) => {
176
+
setTimeout(resolve, 50);
177
+
});
178
+
179
+
expect(element.style.display).not.toBe("none");
180
+
});
181
+
});
182
+
183
+
describe("Duration and Delay Overrides", () => {
184
+
it("should parse duration override", () => {
185
+
const showSignal = signal(false);
186
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
187
+
188
+
surgePlugin(mockContext, "show:fade.500");
189
+
190
+
expect(mockContext.findSignal).toHaveBeenCalledWith("show");
191
+
});
192
+
193
+
it("should parse duration and delay overrides", () => {
194
+
const showSignal = signal(false);
195
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
196
+
197
+
surgePlugin(mockContext, "show:slide-down.600.100");
198
+
199
+
expect(mockContext.findSignal).toHaveBeenCalledWith("show");
200
+
});
201
+
});
202
+
203
+
describe("Error Handling", () => {
204
+
it("should error on invalid surge value", () => {
205
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
206
+
surgePlugin(mockContext, "nonexistent-preset");
207
+
expect(consoleSpy).toHaveBeenCalledWith("[Volt] Unknown transition preset: \"nonexistent-preset\"");
208
+
consoleSpy.mockRestore();
209
+
});
210
+
211
+
it("should error on invalid enter value", () => {
212
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
213
+
surgePlugin(mockContext, "enter:nonexistent");
214
+
expect(consoleSpy).toHaveBeenCalled();
215
+
consoleSpy.mockRestore();
216
+
});
217
+
218
+
it("should error on invalid leave value", () => {
219
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
220
+
surgePlugin(mockContext, "leave:nonexistent");
221
+
expect(consoleSpy).toHaveBeenCalled();
222
+
consoleSpy.mockRestore();
223
+
});
224
+
});
225
+
226
+
describe("Helper Functions", () => {
227
+
describe("hasSurge", () => {
228
+
it("should return true when surge config exists", () => {
229
+
surgePlugin(mockContext, "fade");
230
+
expect(hasSurge(element as HTMLElement)).toBe(true);
231
+
});
232
+
233
+
it("should return true when custom enter exists", () => {
234
+
surgePlugin(mockContext, "enter:slide-down");
235
+
expect(hasSurge(element as HTMLElement)).toBe(true);
236
+
});
237
+
238
+
it("should return true when custom leave exists", () => {
239
+
surgePlugin(mockContext, "leave:fade");
240
+
expect(hasSurge(element as HTMLElement)).toBe(true);
241
+
});
242
+
243
+
it("should return false when no surge config exists", () => {
244
+
expect(hasSurge(element as HTMLElement)).toBe(false);
245
+
});
246
+
});
247
+
248
+
describe("executeSurgeEnter", () => {
249
+
it("should execute enter transition", async () => {
250
+
surgePlugin(mockContext, "fade");
251
+
await executeSurgeEnter(element as HTMLElement);
252
+
expect(element).toBeDefined();
253
+
});
254
+
255
+
it("should use custom enter if available", async () => {
256
+
surgePlugin(mockContext, "enter:slide-down");
257
+
surgePlugin(mockContext, "leave:fade");
258
+
259
+
await executeSurgeEnter(element as HTMLElement);
260
+
261
+
expect(element).toBeDefined();
262
+
});
263
+
264
+
it("should do nothing if no enter config", async () => {
265
+
await executeSurgeEnter(element as HTMLElement);
266
+
expect(element).toBeDefined();
267
+
});
268
+
});
269
+
270
+
describe("executeSurgeLeave", () => {
271
+
it("should execute leave transition", async () => {
272
+
surgePlugin(mockContext, "fade");
273
+
await executeSurgeLeave(element as HTMLElement);
274
+
expect(element).toBeDefined();
275
+
});
276
+
277
+
it("should use custom leave if available", async () => {
278
+
surgePlugin(mockContext, "enter:fade");
279
+
surgePlugin(mockContext, "leave:slide-up");
280
+
281
+
await executeSurgeLeave(element as HTMLElement);
282
+
283
+
expect(element).toBeDefined();
284
+
});
285
+
286
+
it("should do nothing if no leave config", async () => {
287
+
await executeSurgeLeave(element as HTMLElement);
288
+
expect(element).toBeDefined();
289
+
});
290
+
});
291
+
});
292
+
293
+
describe("Accessibility", () => {
294
+
it("should skip animations when prefers-reduced-motion is enabled", async () => {
295
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
296
+
297
+
const showSignal = signal(false);
298
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
299
+
300
+
surgePlugin(mockContext, "show:fade");
301
+
302
+
showSignal.set(true);
303
+
304
+
await new Promise((resolve) => {
305
+
setTimeout(resolve, 50);
306
+
});
307
+
308
+
expect(element.style.display).not.toBe("none");
309
+
});
310
+
});
311
+
312
+
describe("Transition Lifecycle", () => {
313
+
it("should not start overlapping transitions", async () => {
314
+
const showSignal = signal(false);
315
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
316
+
317
+
surgePlugin(mockContext, "show:fade");
318
+
319
+
showSignal.set(true);
320
+
showSignal.set(false);
321
+
showSignal.set(true);
322
+
323
+
await new Promise((resolve) => {
324
+
setTimeout(resolve, 100);
325
+
});
326
+
327
+
expect(element).toBeDefined();
328
+
});
329
+
330
+
it("should cleanup transition styles after completion", async () => {
331
+
const showSignal = signal(false);
332
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
333
+
334
+
registerTransition("test-fast", {
335
+
enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 10 },
336
+
leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 10 },
337
+
});
338
+
339
+
surgePlugin(mockContext, "show:test-fast");
340
+
341
+
showSignal.set(true);
342
+
343
+
await new Promise((resolve) => {
344
+
setTimeout(resolve, 100);
345
+
});
346
+
347
+
expect(element.style.transition).toBe("");
348
+
});
349
+
});
350
+
351
+
describe("View Transitions API", () => {
352
+
it("should use View Transitions API when available", async () => {
353
+
const mockStartViewTransition = vi.fn((callback) => {
354
+
callback();
355
+
});
356
+
357
+
// @ts-expect-error - Adding View Transitions API mock
358
+
document.startViewTransition = mockStartViewTransition;
359
+
360
+
const showSignal = signal(false);
361
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
362
+
363
+
surgePlugin(mockContext, "show:fade");
364
+
365
+
showSignal.set(true);
366
+
367
+
await new Promise((resolve) => {
368
+
setTimeout(resolve, 50);
369
+
});
370
+
371
+
expect(mockStartViewTransition).toHaveBeenCalled();
372
+
373
+
// @ts-expect-error - Cleanup mock
374
+
delete document.startViewTransition;
375
+
});
376
+
377
+
it("should fallback to CSS when View Transitions API not available", async () => {
378
+
// @ts-expect-error - Ensure View Transitions API is not available
379
+
delete document.startViewTransition;
380
+
381
+
const showSignal = signal(false);
382
+
mockContext.findSignal = vi.fn().mockReturnValue(showSignal);
383
+
384
+
surgePlugin(mockContext, "show:fade");
385
+
386
+
showSignal.set(true);
387
+
388
+
await new Promise((resolve) => {
389
+
setTimeout(resolve, 50);
390
+
});
391
+
392
+
expect(element.style.display).not.toBe("none");
393
+
});
394
+
});
395
+
});