a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 13 kB view raw
1/** 2 * Shift plugin for CSS keyframe animations 3 * Provides reusable animation presets that can be triggered on demand 4 */ 5 6import { prefersReducedMotion } from "$core/transitions"; 7import type { Optional } from "$types/helpers"; 8import type { AnimationPreset, PluginContext, Signal } from "$types/volt"; 9 10/** 11 * Registry of animation presets 12 */ 13const animationRegistry = new Map<string, AnimationPreset>(); 14const keyframeRegistry = new Map<string, string>(); 15 16let keyframeSheet: Optional<CSSStyleSheet>; 17let keyframeCounter = 0; 18 19/** 20 * Built-in animation presets with CSS keyframes 21 */ 22const builtinAnimations: Record<string, AnimationPreset> = { 23 bounce: { 24 keyframes: [ 25 { offset: 0, transform: "translateY(0)" }, 26 { offset: 0.25, transform: "translateY(-20px)" }, 27 { offset: 0.5, transform: "translateY(0)" }, 28 { offset: 0.75, transform: "translateY(-10px)" }, 29 { offset: 1, transform: "translateY(0)" }, 30 ], 31 duration: 100, 32 iterations: 1, 33 timing: "ease", 34 }, 35 shake: { 36 keyframes: [ 37 { offset: 0, transform: "translateX(0)" }, 38 { offset: 0.1, transform: "translateX(-10px)" }, 39 { offset: 0.2, transform: "translateX(10px)" }, 40 { offset: 0.3, transform: "translateX(-10px)" }, 41 { offset: 0.4, transform: "translateX(10px)" }, 42 { offset: 0.5, transform: "translateX(-10px)" }, 43 { offset: 0.6, transform: "translateX(10px)" }, 44 { offset: 0.7, transform: "translateX(-10px)" }, 45 { offset: 0.8, transform: "translateX(10px)" }, 46 { offset: 0.9, transform: "translateX(-10px)" }, 47 { offset: 1, transform: "translateX(0)" }, 48 ], 49 duration: 500, 50 iterations: 1, 51 timing: "ease", 52 }, 53 pulse: { 54 keyframes: [{ offset: 0, transform: "scale(1)", opacity: "1" }, { 55 offset: 0.5, 56 transform: "scale(1.05)", 57 opacity: "0.9", 58 }, { offset: 1, transform: "scale(1)", opacity: "1" }], 59 duration: 1000, 60 iterations: Number.POSITIVE_INFINITY, 61 timing: "ease-in-out", 62 }, 63 spin: { 64 keyframes: [{ offset: 0, transform: "rotate(0deg)" }, { offset: 1, transform: "rotate(360deg)" }], 65 duration: 1000, 66 iterations: Number.POSITIVE_INFINITY, 67 timing: "linear", 68 }, 69 flash: { 70 keyframes: [{ offset: 0, opacity: "1" }, { offset: 0.25, opacity: "0" }, { offset: 0.5, opacity: "1" }, { 71 offset: 0.75, 72 opacity: "0", 73 }, { offset: 1, opacity: "1" }], 74 duration: 1000, 75 iterations: 1, 76 timing: "linear", 77 }, 78}; 79 80function initBuiltinAnimations(): void { 81 for (const [name, preset] of Object.entries(builtinAnimations)) { 82 animationRegistry.set(name, preset); 83 } 84} 85 86initBuiltinAnimations(); 87 88/** 89 * Register a custom animation preset. 90 * Allows users to define their own named animations in programmatic mode. 91 * 92 * @param name - Animation name (used in data-volt-shift="name") 93 * @param preset - Animation configuration with keyframes and timing 94 * 95 * @example 96 * ```typescript 97 * registerAnimation('wiggle', { 98 * keyframes: [ 99 * { offset: 0, transform: 'rotate(0deg)' }, 100 * { offset: 0.25, transform: 'rotate(-5deg)' }, 101 * { offset: 0.75, transform: 'rotate(5deg)' }, 102 * { offset: 1, transform: 'rotate(0deg)' } 103 * ], 104 * duration: 300, 105 * iterations: 2, 106 * timing: 'ease-in-out' 107 * }); 108 * ``` 109 */ 110export function registerAnimation(name: string, preset: AnimationPreset): void { 111 if (animationRegistry.has(name) && Object.hasOwn(builtinAnimations, name)) { 112 console.warn(`[Volt] Overriding built-in animation preset: "${name}"`); 113 } 114 animationRegistry.set(name, preset); 115} 116 117/** 118 * Get an animation preset by name. 119 * Checks both custom and built-in presets. 120 * 121 * @param name - Preset name 122 * @returns Animation preset or undefined if not found 123 */ 124export function getAnimation(name: string): Optional<AnimationPreset> { 125 return animationRegistry.get(name); 126} 127 128/** 129 * Check if an animation preset exists. 130 * 131 * @param name - Preset name 132 * @returns true if the preset is registered 133 */ 134export function hasAnimation(name: string): boolean { 135 return animationRegistry.has(name); 136} 137 138/** 139 * Unregister a custom animation preset. 140 * Built-in presets cannot be unregistered. 141 * 142 * @param name - Preset name 143 * @returns true if the preset was removed, false otherwise 144 */ 145export function unregisterAnimation(name: string): boolean { 146 if (Object.hasOwn(builtinAnimations, name)) { 147 console.warn(`[Volt] Cannot unregister built-in animation preset: "${name}"`); 148 return false; 149 } 150 return animationRegistry.delete(name); 151} 152 153/** 154 * Get all registered animation preset names. 155 * 156 * @returns Array of preset names 157 */ 158export function getRegisteredAnimations(): string[] { 159 return [...animationRegistry.keys()]; 160} 161 162type ParsedShiftValue = { animationName: string; duration?: number; iterations?: number; signalPath?: string }; 163 164/** 165 * Parse shift plugin value to extract configuration. 166 * Supports: 167 * - "animationName" - default animation 168 * - "animationName.duration" - custom duration 169 * - "animationName.duration.iterations" - custom duration and iterations 170 * - "signalPath:animationName" - watch signal with animation 171 * - "signalPath:animationName.duration.iterations" - watch signal with custom settings 172 */ 173function parseShiftValue(value: string): Optional<ParsedShiftValue> { 174 const colonIndex = value.indexOf(":"); 175 176 if (colonIndex !== -1) { 177 const signalPath = value.slice(0, colonIndex).trim(); 178 const animationPart = value.slice(colonIndex + 1).trim(); 179 const parsed = parseAnimationValue(animationPart); 180 181 if (!parsed) { 182 return undefined; 183 } 184 185 return { ...parsed, signalPath }; 186 } 187 188 return parseAnimationValue(value); 189} 190 191function parseAnimationValue(value: string): Optional<ParsedShiftValue> { 192 const parts = value.split("."); 193 const animationName = parts[0]?.trim(); 194 195 if (!animationName) { 196 return undefined; 197 } 198 199 const result: ParsedShiftValue = { animationName }; 200 201 if (parts.length > 1) { 202 const duration = Number.parseInt(parts[1], 10); 203 if (!Number.isNaN(duration)) { 204 result.duration = duration; 205 } 206 } 207 208 if (parts.length > 2) { 209 const iterations = Number.parseInt(parts[2], 10); 210 if (!Number.isNaN(iterations)) { 211 result.iterations = iterations; 212 } 213 } 214 215 return result; 216} 217 218function stopAnimation(el: HTMLElement): void { 219 el.style.animation = ""; 220 el.style.animationName = ""; 221 el.style.animationDuration = ""; 222 el.style.animationTimingFunction = ""; 223 el.style.animationIterationCount = ""; 224 el.style.animationFillMode = ""; 225 restoreOriginalDisplay(el); 226} 227 228function applyAnimation(el: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void { 229 if (prefersReducedMotion()) { 230 return; 231 } 232 233 const effectiveDuration = duration ?? preset.duration; 234 const effectiveIterations = iterations ?? preset.iterations; 235 const animationName = getOrCreateKeyframes(preset); 236 if (!animationName) { 237 return; 238 } 239 240 ensureInlineBlockForTransforms(el, effectiveIterations === Number.POSITIVE_INFINITY); 241 resetCssAnimation(el); 242 243 el.style.animationName = animationName; 244 el.style.animationDuration = `${effectiveDuration}ms`; 245 el.style.animationTimingFunction = preset.timing; 246 el.style.animationIterationCount = effectiveIterations === Number.POSITIVE_INFINITY 247 ? "infinite" 248 : String(effectiveIterations); 249 el.style.animationFillMode = "forwards"; 250 251 const runs = Number.parseInt(el.dataset.voltShiftRuns ?? "0", 10) + 1; 252 el.dataset.voltShiftRuns = String(runs); 253 254 if (effectiveIterations !== Number.POSITIVE_INFINITY) { 255 const totalDuration = effectiveDuration * effectiveIterations; 256 setTimeout(() => { 257 if (el.style.animationName === animationName) { 258 stopAnimation(el); 259 } 260 }, totalDuration); 261 } 262} 263 264function resetCssAnimation(el: HTMLElement): void { 265 const previousName = el.style.animationName; 266 if (!previousName) { 267 return; 268 } 269 el.style.animation = "none"; 270 void el.offsetWidth; 271 el.style.animation = ""; 272 el.style.animationName = ""; 273} 274 275function ensureKeyframeSheet(): Optional<CSSStyleSheet> { 276 if (keyframeSheet) { 277 return keyframeSheet; 278 } 279 280 if (typeof document === "undefined" || !document.head) { 281 return undefined; 282 } 283 284 const styleEl = document.createElement("style"); 285 styleEl.dataset.voltShift = "true"; 286 document.head.append(styleEl); 287 keyframeSheet = styleEl.sheet ?? undefined; 288 return keyframeSheet; 289} 290 291function toCssProperty(property: string): string { 292 return property.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); 293} 294 295function getOrCreateKeyframes(preset: AnimationPreset): Optional<string> { 296 const key = JSON.stringify(preset.keyframes) + preset.timing; 297 if (keyframeRegistry.has(key)) { 298 return keyframeRegistry.get(key); 299 } 300 301 const sheet = ensureKeyframeSheet(); 302 if (!sheet) { 303 return undefined; 304 } 305 306 const animationName = `volt-shift-${keyframeCounter += 1}`; 307 keyframeRegistry.set(key, animationName); 308 309 const frames = preset.keyframes.map((frame, index) => { 310 const offset = frame.offset ?? (preset.keyframes.length > 1 ? index / (preset.keyframes.length - 1) : 0); 311 const percent = Math.round(offset * 10_000) / 100; 312 const declarations = Object.entries(frame).filter(([prop]) => prop !== "offset").map(([prop, value]) => 313 `${toCssProperty(prop)}: ${value};` 314 ).join(" "); 315 return `${percent}% { ${declarations} }`; 316 }).join(" "); 317 318 sheet.insertRule(`@keyframes ${animationName} { ${frames} }`, sheet.cssRules.length); 319 return animationName; 320} 321 322function ensureInlineBlockForTransforms(el: HTMLElement, isInf: boolean): void { 323 if (el.dataset.voltShiftDisplayManaged) { 324 return; 325 } 326 327 if (typeof getComputedStyle !== "function") { 328 return; 329 } 330 331 if (!el.isConnected) { 332 return; 333 } 334 335 void el.offsetHeight; 336 337 const computedDisplay = getComputedStyle(el).display; 338 if (computedDisplay !== "inline") { 339 return; 340 } 341 342 el.dataset.voltShiftDisplayManaged = isInf ? "infinite" : "managed"; 343 el.dataset.voltShiftOriginalDisplay = el.style.display ?? ""; 344 345 if (!el.dataset.voltShiftOriginalTransformOrigin) { 346 el.dataset.voltShiftOriginalTransformOrigin = el.style.transformOrigin ?? ""; 347 } 348 349 el.style.display = "inline-block"; 350 if (!el.style.transformOrigin) { 351 el.style.transformOrigin = "center center"; 352 } 353} 354 355function restoreOriginalDisplay(element: HTMLElement): void { 356 const state = element.dataset.voltShiftDisplayManaged; 357 if (!state || state === "infinite") { 358 return; 359 } 360 361 const original = element.dataset.voltShiftOriginalDisplay ?? ""; 362 element.style.display = original; 363 const originalOrigin = element.dataset.voltShiftOriginalTransformOrigin ?? ""; 364 element.style.transformOrigin = originalOrigin; 365 delete element.dataset.voltShiftDisplayManaged; 366 delete element.dataset.voltShiftOriginalDisplay; 367 delete element.dataset.voltShiftOriginalTransformOrigin; 368} 369 370/** 371 * Shift plugin handler. 372 * Provides CSS keyframe animations for elements. 373 * 374 * Syntax: 375 * - data-volt-shift="animationName" - Run animation with default settings 376 * - data-volt-shift="animationName.duration.iterations" - Custom duration and iterations 377 * - data-volt-shift="signalPath:animationName" - Watch signal to trigger animation 378 * 379 * @example 380 * ```html 381 * <!-- One-time animation on mount --> 382 * <button data-volt-shift="bounce">Click Me</button> 383 * 384 * <!-- Continuous pulse animation --> 385 * <div data-volt-shift="pulse">Loading...</div> 386 * 387 * <!-- Trigger animation based on signal --> 388 * <div data-volt-shift="error:shake">Error occurred!</div> 389 * 390 * <!-- Custom duration and iterations --> 391 * <div data-volt-shift="bounce.1000.3">Triple bounce!</div> 392 * ``` 393 */ 394export function shiftPlugin(ctx: PluginContext, value: string): void { 395 const el = ctx.element as HTMLElement; 396 397 const parsed = parseShiftValue(value); 398 if (!parsed) { 399 console.error(`[Volt] Invalid shift value: "${value}"`); 400 return; 401 } 402 403 const preset = getAnimation(parsed.animationName); 404 if (!preset) { 405 console.error(`[Volt] Unknown animation preset: "${parsed.animationName}"`); 406 return; 407 } 408 409 if (parsed.signalPath) { 410 const signal = ctx.findSignal(parsed.signalPath) as Optional<Signal<unknown>>; 411 if (!signal) { 412 console.error(`[Volt] Signal "${parsed.signalPath}" not found for shift binding`); 413 return; 414 } 415 416 const effectiveIterations = parsed.iterations ?? preset.iterations; 417 const isInfinite = effectiveIterations === Number.POSITIVE_INFINITY; 418 let previousValue = signal.get(); 419 420 const unsubscribe = signal.subscribe((value) => { 421 if (value !== previousValue) { 422 if (value) { 423 applyAnimation(el, preset, parsed.duration, parsed.iterations); 424 } else if (isInfinite && el.style.animationName) { 425 stopAnimation(el); 426 } 427 } 428 previousValue = value; 429 }); 430 431 ctx.addCleanup(unsubscribe); 432 433 if (signal.get()) { 434 ctx.lifecycle.onMount(() => { 435 requestAnimationFrame(() => { 436 applyAnimation(el, preset, parsed.duration, parsed.iterations); 437 }); 438 }); 439 } 440 } else { 441 ctx.lifecycle.onMount(() => { 442 requestAnimationFrame(() => { 443 applyAnimation(el, preset, parsed.duration, parsed.iterations); 444 }); 445 }); 446 } 447}