a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 311 lines 9.4 kB view raw
1/** 2 * Transition preset system for surge plugin 3 * Provides built-in transition presets and custom preset registration 4 */ 5 6import type { Optional } from "$types/helpers"; 7import type { ParsedTransition, TransitionPhase, TransitionPreset } from "$types/volt"; 8 9/** 10 * Registry of transition presets 11 */ 12const transitionRegistry = new Map<string, TransitionPreset>(); 13 14/** 15 * Built-in transition presets 16 */ 17const 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 108function initBuiltinPresets(): void { 109 for (const [name, preset] of Object.entries(builtinPresets)) { 110 transitionRegistry.set(name, preset); 111 } 112} 113 114initBuiltinPresets(); 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 */ 141export 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 */ 155export 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 */ 165export 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 */ 176export 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 */ 189export 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 */ 207export 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 */ 244export 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 */ 283export 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 */ 293export 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 */ 309export function applyOverrides(phase: TransitionPhase, duration?: number, delay?: number): TransitionPhase { 310 return { ...phase, ...(duration !== undefined && { duration }), ...(delay !== undefined && { delay }) }; 311}