a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Surge plugin for enter/leave transitions
3 * Provides smooth animations when elements appear or disappear
4 */
5
6import { sleep } from "$core/shared";
7import { applyOverrides, getEasing, parseTransitionValue, prefersReducedMotion } from "$core/transitions";
8import { withViewTransition } from "$core/view-transitions";
9import type { Optional } from "$types/helpers";
10import type { PluginContext, Signal, TransitionPhase } from "$types/volt";
11
12type SurgeElement = HTMLElement & {
13 _vxSurgeConf?: SurgeConfig;
14 _vxSurgeEnter?: TransitionPhase;
15 _vxSurgeLeave?: TransitionPhase;
16};
17
18type SurgeConfig = {
19 enterPreset?: TransitionPhase;
20 leavePreset?: TransitionPhase;
21 signalPath?: string;
22 useViewTransitions: boolean;
23};
24
25function applyStyles(element: HTMLElement, styles: Record<string, string | number>): void {
26 for (const [property, value] of Object.entries(styles)) {
27 const cssProperty = property.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
28 element.style.setProperty(cssProperty, String(value));
29 }
30}
31
32function applyClasses(el: HTMLElement, classes: string[]): void {
33 for (const cls of classes) {
34 el.classList.add(cls);
35 }
36}
37
38function rmClasses(el: HTMLElement, classes: string[]): void {
39 for (const cls of classes) {
40 el.classList.remove(cls);
41 }
42}
43
44async function execEnter(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> {
45 const duration = phase.duration ?? 300;
46 const delay = phase.delay ?? 0;
47 const easing = getEasing(phase.easing ?? "ease");
48
49 if (prefersReducedMotion()) {
50 if (phase.to) {
51 applyStyles(element, phase.to);
52 }
53 if (phase.classes) {
54 applyClasses(element, phase.classes);
55 }
56 return;
57 }
58
59 if (phase.from) {
60 applyStyles(element, phase.from);
61 }
62
63 if (phase.classes) {
64 applyClasses(element, phase.classes);
65 }
66
67 void element.offsetHeight;
68
69 element.style.transition = `all ${duration}ms ${easing} ${delay}ms`;
70
71 if (delay > 0) {
72 await sleep(delay);
73 }
74
75 const transitionPromise = new Promise<void>((resolve) => {
76 const handleTransitionEnd = (event: TransitionEvent) => {
77 if (event.target === element) {
78 element.removeEventListener("transitionend", handleTransitionEnd);
79 resolve();
80 }
81 };
82
83 element.addEventListener("transitionend", handleTransitionEnd);
84
85 setTimeout(() => {
86 element.removeEventListener("transitionend", handleTransitionEnd);
87 resolve();
88 }, duration + delay + 50);
89 });
90
91 if (useViewTransitions) {
92 withViewTransition(() => {
93 if (phase.to) {
94 applyStyles(element, phase.to);
95 }
96 }, false);
97 } else {
98 if (phase.to) {
99 applyStyles(element, phase.to);
100 }
101 }
102
103 await transitionPromise;
104
105 element.style.transition = "";
106
107 if (phase.classes) {
108 rmClasses(element, phase.classes);
109 }
110}
111
112async function execLeave(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> {
113 const duration = phase.duration ?? 300;
114 const delay = phase.delay ?? 0;
115 const easing = getEasing(phase.easing ?? "ease");
116
117 if (prefersReducedMotion()) {
118 if (phase.to) {
119 applyStyles(element, phase.to);
120 }
121 if (phase.classes) {
122 applyClasses(element, phase.classes);
123 }
124 return;
125 }
126
127 if (phase.from) {
128 applyStyles(element, phase.from);
129 }
130
131 if (phase.classes) {
132 applyClasses(element, phase.classes);
133 }
134
135 void element.offsetHeight;
136
137 element.style.transition = `all ${duration}ms ${easing} ${delay}ms`;
138
139 if (delay > 0) {
140 await sleep(delay);
141 }
142
143 const transitionPromise = new Promise<void>((resolve) => {
144 const handleTransitionEnd = (event: TransitionEvent) => {
145 if (event.target === element) {
146 element.removeEventListener("transitionend", handleTransitionEnd);
147 resolve();
148 }
149 };
150
151 element.addEventListener("transitionend", handleTransitionEnd);
152
153 setTimeout(() => {
154 element.removeEventListener("transitionend", handleTransitionEnd);
155 resolve();
156 }, duration + delay + 50);
157 });
158
159 if (useViewTransitions) {
160 withViewTransition(() => {
161 if (phase.to) {
162 applyStyles(element, phase.to);
163 }
164 }, false);
165 } else {
166 if (phase.to) {
167 applyStyles(element, phase.to);
168 }
169 }
170
171 await transitionPromise;
172
173 element.style.transition = "";
174
175 if (phase.classes) {
176 rmClasses(element, phase.classes);
177 }
178}
179
180/**
181 * Parse surge plugin value to extract configuration
182 * Supports:
183 * - "presetName" - default preset
184 * - "signalPath:presetName" - watch signal with preset
185 * - "signalPath" - watch signal with default fade
186 */
187function parseSurgeValue(value: string): Optional<SurgeConfig> {
188 const parts = value.split(":");
189
190 if (parts.length === 2) {
191 const [signalPath, presetValue] = parts;
192 const parsed = parseTransitionValue(presetValue.trim());
193
194 if (!parsed) {
195 return undefined;
196 }
197
198 return {
199 enterPreset: parsed.preset.enter,
200 leavePreset: parsed.preset.leave,
201 signalPath: signalPath.trim(),
202 useViewTransitions: true,
203 };
204 }
205
206 const parsed = parseTransitionValue(value.trim());
207 if (!parsed) {
208 return undefined;
209 }
210
211 return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true };
212}
213
214function ensureInlineSurgeState(element: SurgeElement): void {
215 if (!element._vxSurgeConf) {
216 const attr = element.dataset.voltSurge;
217 if (attr) {
218 const parsed = parseSurgeValue(attr);
219 if (parsed) {
220 element._vxSurgeConf = parsed;
221 }
222 }
223 }
224
225 if (!element._vxSurgeEnter) {
226 const enterAttr = element.dataset["voltSurge:enter"];
227 if (enterAttr) {
228 const enterPhase = parsePhaseValue(enterAttr, "enter");
229 if (enterPhase) {
230 element._vxSurgeEnter = enterPhase;
231 }
232 }
233 }
234
235 if (!element._vxSurgeLeave) {
236 const leaveAttr = element.dataset["voltSurge:leave"];
237 if (leaveAttr) {
238 const leavePhase = parsePhaseValue(leaveAttr, "leave");
239 if (leavePhase) {
240 element._vxSurgeLeave = leavePhase;
241 }
242 }
243 }
244}
245
246function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional<TransitionPhase> {
247 const parsed = parseTransitionValue(value.trim());
248 if (!parsed) {
249 return undefined;
250 }
251 const presetPhase = phase === "enter" ? parsed.preset.enter : parsed.preset.leave;
252 return applyOverrides(presetPhase, parsed.duration, parsed.delay);
253}
254
255/**
256 * Surge plugin handler.
257 * Provides enter/leave transitions for elements.
258 *
259 * Syntax:
260 * - data-volt-surge="presetName" - Default transition preset
261 * - data-volt-surge="signalPath:presetName" - Watch signal for transitions
262 * - data-volt-surge:enter="presetName" - Specific enter transition
263 * - data-volt-surge:leave="presetName" - Specific leave transition
264 *
265 * @example
266 * ```html
267 * <!-- Explicit signal watching -->
268 * <div data-volt-surge="show:fade">Content</div>
269 *
270 * <!-- Granular control -->
271 * <div
272 * data-volt-surge:enter="slide-down.500"
273 * data-volt-surge:leave="fade.300">
274 * Content
275 * </div>
276 * ```
277 */
278export function surgePlugin(ctx: PluginContext, value: string): void {
279 const el = ctx.element as SurgeElement;
280
281 if (value.includes(":")) {
282 const [phase, presetValue] = value.split(":", 2);
283
284 if (phase === "enter") {
285 const enterPhase = parsePhaseValue(presetValue, "enter");
286 if (!enterPhase) {
287 console.error(`[Volt] Invalid surge enter value: "${value}"`);
288 return;
289 }
290
291 el._vxSurgeEnter = enterPhase;
292 return;
293 }
294
295 if (phase === "leave") {
296 const leavePhase = parsePhaseValue(presetValue, "leave");
297 if (!leavePhase) {
298 console.error(`[Volt] Invalid surge leave value: "${value}"`);
299 return;
300 }
301
302 el._vxSurgeLeave = leavePhase;
303 return;
304 }
305 }
306
307 const config = parseSurgeValue(value);
308 if (!config) {
309 console.error(`[Volt] Invalid surge value: "${value}"`);
310 return;
311 }
312
313 if (!config.signalPath) {
314 el._vxSurgeConf = config;
315 return;
316 }
317
318 const signal = ctx.findSignal(config.signalPath) as Optional<Signal<unknown>>;
319 if (!signal) {
320 console.error(`[Volt] Signal "${config.signalPath}" not found for surge binding`);
321 return;
322 }
323
324 let isVisible = Boolean(signal.get());
325 let isTransitioning = false;
326
327 if (!isVisible) {
328 el.style.display = "none";
329 }
330
331 const handleTransition = async (shouldShow: boolean) => {
332 if (isTransitioning || shouldShow === isVisible) {
333 return;
334 }
335
336 isTransitioning = true;
337
338 if (shouldShow && config.enterPreset) {
339 el.style.display = "";
340 await execEnter(el, config.enterPreset, config.useViewTransitions);
341 isVisible = true;
342 } else if (!shouldShow && config.leavePreset) {
343 await execLeave(el, config.leavePreset, config.useViewTransitions);
344 el.style.display = "none";
345 isVisible = false;
346 }
347
348 isTransitioning = false;
349 };
350
351 const unsubscribe = signal.subscribe((value) => {
352 const shouldShow = Boolean(value);
353 void handleTransition(shouldShow);
354 });
355
356 ctx.addCleanup(unsubscribe);
357}
358
359/**
360 * @internal
361 */
362export async function executeSurgeEnter(element: HTMLElement): Promise<void> {
363 const surgeEl = element as SurgeElement;
364 ensureInlineSurgeState(surgeEl);
365
366 const config = surgeEl._vxSurgeConf;
367 const customEnter = surgeEl._vxSurgeEnter;
368
369 const enterPhase = customEnter ?? config?.enterPreset;
370 if (!enterPhase) {
371 return;
372 }
373
374 const useViewTransitions = config?.useViewTransitions ?? true;
375 await execEnter(element, enterPhase, useViewTransitions);
376}
377
378/**
379 * @internal
380 */
381export async function executeSurgeLeave(element: HTMLElement): Promise<void> {
382 const surgeEl = element as SurgeElement;
383 ensureInlineSurgeState(surgeEl);
384
385 const config = surgeEl._vxSurgeConf;
386 const customLeave = surgeEl._vxSurgeLeave;
387
388 const leavePhase = customLeave ?? config?.leavePreset;
389 if (!leavePhase) {
390 return;
391 }
392
393 const useViewTransitions = config?.useViewTransitions ?? true;
394 await execLeave(element, leavePhase, useViewTransitions);
395}
396
397/**
398 * @internal
399 */
400export function hasSurge(element: HTMLElement): boolean {
401 const surgeEl = element as SurgeElement;
402 ensureInlineSurgeState(surgeEl);
403
404 return Boolean(surgeEl._vxSurgeConf || surgeEl._vxSurgeEnter || surgeEl._vxSurgeLeave);
405}