a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 10 kB view raw
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}