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