@llui/transitions 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Franco Ponticelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @llui/transitions
2
+
3
+ Animation helpers for [LLui](https://github.com/fponticelli/llui).
4
+
5
+ `transition()` core + `fade`, `slide`, `scale`, `collapse` presets. Works with `branch`/`show`/`each` enter/leave hooks. Includes `flip()` for FLIP reorder animations and `mergeTransitions()` for combining.
6
+
7
+ ```bash
8
+ pnpm add @llui/transitions
9
+ ```
10
+
11
+ ## License
12
+
13
+ MIT
package/dist/flip.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { TransitionOptions } from '@llui/dom';
2
+ export interface FlipOptions {
3
+ duration?: number;
4
+ easing?: string;
5
+ }
6
+ /**
7
+ * FLIP (First-Last-Invert-Play) reorder animation for `each()` lists.
8
+ *
9
+ * Attach to an `each()` alongside item enter/leave transitions. After each
10
+ * reconcile, items whose positions changed animate smoothly from their
11
+ * previous position to the new one.
12
+ *
13
+ * ```ts
14
+ * each({
15
+ * items: s => s.items,
16
+ * key: i => i.id,
17
+ * render,
18
+ * ...fade(), // animates appear/disappear
19
+ * ...flip(), // animates reorders
20
+ * })
21
+ * ```
22
+ *
23
+ * Spreading two transition helpers merges their hooks: `fade()` provides
24
+ * `enter`/`leave`, `flip()` provides `enter` (position capture) and
25
+ * `onTransition` (apply inverse + play). The `enter` from `flip()` overrides
26
+ * `fade()`'s only if spread after — put `flip()` last.
27
+ *
28
+ * Actually, to combine both, use `mergeTransitions(fade(), flip())` which
29
+ * chains `enter` handlers.
30
+ *
31
+ * Requires WAAPI (`element.animate()`). In environments without it (old
32
+ * browsers, minimal jsdom) the transforms are applied without animation.
33
+ */
34
+ export declare function flip(opts?: FlipOptions): TransitionOptions;
35
+ /**
36
+ * Merge multiple TransitionOptions into one, chaining their `enter`,
37
+ * `leave`, and `onTransition` handlers in order.
38
+ *
39
+ * Useful for combining an item-level animation (fade/slide/...) with flip():
40
+ *
41
+ * ```ts
42
+ * each({ items, key, render, ...mergeTransitions(fade(), flip()) })
43
+ * ```
44
+ */
45
+ export declare function mergeTransitions(...parts: TransitionOptions[]): TransitionOptions;
46
+ //# sourceMappingURL=flip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flip.d.ts","sourceRoot":"","sources":["../src/flip.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAGlD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,IAAI,CAAC,IAAI,GAAE,WAAgB,GAAG,iBAAiB,CAoD9D;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,KAAK,EAAE,iBAAiB,EAAE,GAAG,iBAAiB,CA4BjF"}
package/dist/flip.js ADDED
@@ -0,0 +1,120 @@
1
+ import { asElements } from './style-utils';
2
+ /**
3
+ * FLIP (First-Last-Invert-Play) reorder animation for `each()` lists.
4
+ *
5
+ * Attach to an `each()` alongside item enter/leave transitions. After each
6
+ * reconcile, items whose positions changed animate smoothly from their
7
+ * previous position to the new one.
8
+ *
9
+ * ```ts
10
+ * each({
11
+ * items: s => s.items,
12
+ * key: i => i.id,
13
+ * render,
14
+ * ...fade(), // animates appear/disappear
15
+ * ...flip(), // animates reorders
16
+ * })
17
+ * ```
18
+ *
19
+ * Spreading two transition helpers merges their hooks: `fade()` provides
20
+ * `enter`/`leave`, `flip()` provides `enter` (position capture) and
21
+ * `onTransition` (apply inverse + play). The `enter` from `flip()` overrides
22
+ * `fade()`'s only if spread after — put `flip()` last.
23
+ *
24
+ * Actually, to combine both, use `mergeTransitions(fade(), flip())` which
25
+ * chains `enter` handlers.
26
+ *
27
+ * Requires WAAPI (`element.animate()`). In environments without it (old
28
+ * browsers, minimal jsdom) the transforms are applied without animation.
29
+ */
30
+ export function flip(opts = {}) {
31
+ const duration = opts.duration ?? 300;
32
+ const easing = opts.easing ?? 'ease-out';
33
+ const positions = new WeakMap();
34
+ const tracked = new Set();
35
+ const captureAfterFrame = (els) => {
36
+ const run = () => {
37
+ for (const el of els) {
38
+ if (el.isConnected)
39
+ positions.set(el, el.getBoundingClientRect());
40
+ }
41
+ };
42
+ if (typeof requestAnimationFrame === 'function') {
43
+ requestAnimationFrame(run);
44
+ }
45
+ else {
46
+ run();
47
+ }
48
+ };
49
+ return {
50
+ enter: (nodes) => {
51
+ const els = asElements(nodes);
52
+ for (const el of els)
53
+ tracked.add(el);
54
+ captureAfterFrame(els);
55
+ },
56
+ leave: (nodes) => {
57
+ for (const el of asElements(nodes))
58
+ tracked.delete(el);
59
+ },
60
+ onTransition: () => {
61
+ // Snapshot current set (tracked may mutate during iteration).
62
+ const current = Array.from(tracked);
63
+ for (const el of current) {
64
+ if (!el.isConnected) {
65
+ tracked.delete(el);
66
+ continue;
67
+ }
68
+ const prev = positions.get(el);
69
+ const next = el.getBoundingClientRect();
70
+ if (prev && (prev.left !== next.left || prev.top !== next.top)) {
71
+ const dx = prev.left - next.left;
72
+ const dy = prev.top - next.top;
73
+ if (typeof el.animate === 'function') {
74
+ el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }], { duration, easing, fill: 'backwards' });
75
+ }
76
+ }
77
+ positions.set(el, next);
78
+ }
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * Merge multiple TransitionOptions into one, chaining their `enter`,
84
+ * `leave`, and `onTransition` handlers in order.
85
+ *
86
+ * Useful for combining an item-level animation (fade/slide/...) with flip():
87
+ *
88
+ * ```ts
89
+ * each({ items, key, render, ...mergeTransitions(fade(), flip()) })
90
+ * ```
91
+ */
92
+ export function mergeTransitions(...parts) {
93
+ const enters = parts.map((p) => p.enter).filter((f) => !!f);
94
+ const leaves = parts.map((p) => p.leave).filter((f) => !!f);
95
+ const onTs = parts.map((p) => p.onTransition).filter((f) => !!f);
96
+ const out = {};
97
+ if (enters.length > 0) {
98
+ out.enter = (nodes) => {
99
+ for (const fn of enters)
100
+ void fn(nodes);
101
+ };
102
+ }
103
+ if (leaves.length > 0) {
104
+ out.leave = (nodes) => {
105
+ // Wait for all leaves to resolve.
106
+ const results = leaves.map((fn) => fn(nodes));
107
+ const promises = results.filter((r) => !!r && typeof r.then === 'function');
108
+ if (promises.length === 0)
109
+ return;
110
+ return Promise.all(promises).then(() => undefined);
111
+ };
112
+ }
113
+ if (onTs.length > 0) {
114
+ out.onTransition = (ctx) => {
115
+ for (const fn of onTs)
116
+ void fn(ctx);
117
+ };
118
+ }
119
+ return out;
120
+ }
@@ -0,0 +1,7 @@
1
+ export { transition } from './transition';
2
+ export { fade, slide, scale, collapse } from './presets';
3
+ export type { FadeOptions, SlideOptions, SlideDirection, ScaleOptions, CollapseOptions, } from './presets';
4
+ export { flip, mergeTransitions } from './flip';
5
+ export type { FlipOptions } from './flip';
6
+ export type { TransitionSpec, TransitionValue, Styles } from './types';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAGzC,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACxD,YAAY,EACV,WAAW,EACX,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,eAAe,GAChB,MAAM,WAAW,CAAA;AAGlB,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAA;AAC/C,YAAY,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAA;AAGzC,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // Core primitive
2
+ export { transition } from './transition';
3
+ // Presets
4
+ export { fade, slide, scale, collapse } from './presets';
5
+ // Reorder animation + composition
6
+ export { flip, mergeTransitions } from './flip';
@@ -0,0 +1,48 @@
1
+ import type { TransitionOptions } from '@llui/dom';
2
+ export interface FadeOptions {
3
+ duration?: number;
4
+ easing?: string;
5
+ appear?: boolean;
6
+ }
7
+ export declare function fade(opts?: FadeOptions): TransitionOptions;
8
+ export type SlideDirection = 'up' | 'down' | 'left' | 'right';
9
+ export interface SlideOptions {
10
+ /** The direction the element slides IN from (default: 'down' — enters from below). */
11
+ direction?: SlideDirection;
12
+ /** Pixel distance to slide (default: 20). */
13
+ distance?: number;
14
+ duration?: number;
15
+ easing?: string;
16
+ /** Also animate opacity (default: true). */
17
+ fade?: boolean;
18
+ appear?: boolean;
19
+ }
20
+ export declare function slide(opts?: SlideOptions): TransitionOptions;
21
+ export interface ScaleOptions {
22
+ /** Starting scale factor (default: 0.95). */
23
+ from?: number;
24
+ duration?: number;
25
+ easing?: string;
26
+ /** Also animate opacity (default: true). */
27
+ fade?: boolean;
28
+ /** Transform origin (default: 'center'). */
29
+ origin?: string;
30
+ appear?: boolean;
31
+ }
32
+ export declare function scale(opts?: ScaleOptions): TransitionOptions;
33
+ export interface CollapseOptions {
34
+ /** Axis to collapse: 'y' = height, 'x' = width (default: 'y'). */
35
+ axis?: 'x' | 'y';
36
+ duration?: number;
37
+ easing?: string;
38
+ appear?: boolean;
39
+ }
40
+ /**
41
+ * Animate an element open/closed along the y-axis (height) or x-axis (width).
42
+ *
43
+ * Unlike CSS-only presets, `collapse()` measures the element's natural size
44
+ * at runtime — the animation works regardless of content size. Only the
45
+ * first element in each `nodes` group is animated.
46
+ */
47
+ export declare function collapse(opts?: CollapseOptions): TransitionOptions;
48
+ //# sourceMappingURL=presets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../src/presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAKlD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,IAAI,CAAC,IAAI,GAAE,WAAgB,GAAG,iBAAiB,CAc9D;AAED,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;AAE7D,MAAM,WAAW,YAAY;IAC3B,sFAAsF;IACtF,SAAS,CAAC,EAAE,cAAc,CAAA;IAC1B,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4CAA4C;IAC5C,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,KAAK,CAAC,IAAI,GAAE,YAAiB,GAAG,iBAAiB,CA4BhE;AAeD,MAAM,WAAW,YAAY;IAC3B,6CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4CAA4C;IAC5C,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,KAAK,CAAC,IAAI,GAAE,YAAiB,GAAG,iBAAiB,CA8BhE;AAED,MAAM,WAAW,eAAe;IAC9B,kEAAkE;IAClE,IAAI,CAAC,EAAE,GAAG,GAAG,GAAG,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,iBAAiB,CAyDtE"}
@@ -0,0 +1,146 @@
1
+ import { transition } from './transition';
2
+ import { asElements, forceReflow } from './style-utils';
3
+ export function fade(opts = {}) {
4
+ const duration = opts.duration ?? 200;
5
+ const easing = opts.easing ?? 'ease-out';
6
+ const active = { transition: `opacity ${duration}ms ${easing}` };
7
+ return transition({
8
+ appear: opts.appear,
9
+ duration,
10
+ enterActive: active,
11
+ enterFrom: { opacity: 0 },
12
+ enterTo: { opacity: 1 },
13
+ leaveActive: active,
14
+ leaveFrom: { opacity: 1 },
15
+ leaveTo: { opacity: 0 },
16
+ });
17
+ }
18
+ export function slide(opts = {}) {
19
+ const direction = opts.direction ?? 'down';
20
+ const distance = opts.distance ?? 20;
21
+ const duration = opts.duration ?? 250;
22
+ const easing = opts.easing ?? 'ease-out';
23
+ const withFade = opts.fade !== false;
24
+ const offset = slideOffset(direction, distance);
25
+ const props = withFade ? 'transform, opacity' : 'transform';
26
+ const active = { transition: `${props} ${duration}ms ${easing}` };
27
+ const hidden = { transform: offset };
28
+ const visible = { transform: 'translate(0, 0)' };
29
+ if (withFade) {
30
+ hidden.opacity = 0;
31
+ visible.opacity = 1;
32
+ }
33
+ return transition({
34
+ appear: opts.appear,
35
+ duration,
36
+ enterActive: active,
37
+ enterFrom: hidden,
38
+ enterTo: visible,
39
+ leaveActive: active,
40
+ leaveFrom: visible,
41
+ leaveTo: hidden,
42
+ });
43
+ }
44
+ function slideOffset(direction, distance) {
45
+ switch (direction) {
46
+ case 'down':
47
+ return `translate(0, -${distance}px)`;
48
+ case 'up':
49
+ return `translate(0, ${distance}px)`;
50
+ case 'right':
51
+ return `translate(-${distance}px, 0)`;
52
+ case 'left':
53
+ return `translate(${distance}px, 0)`;
54
+ }
55
+ }
56
+ export function scale(opts = {}) {
57
+ const from = opts.from ?? 0.95;
58
+ const duration = opts.duration ?? 200;
59
+ const easing = opts.easing ?? 'ease-out';
60
+ const withFade = opts.fade !== false;
61
+ const origin = opts.origin ?? 'center';
62
+ const props = withFade ? 'transform, opacity' : 'transform';
63
+ const active = {
64
+ transition: `${props} ${duration}ms ${easing}`,
65
+ transformOrigin: origin,
66
+ };
67
+ const hidden = { transform: `scale(${from})` };
68
+ const visible = { transform: 'scale(1)' };
69
+ if (withFade) {
70
+ hidden.opacity = 0;
71
+ visible.opacity = 1;
72
+ }
73
+ return transition({
74
+ appear: opts.appear,
75
+ duration,
76
+ enterActive: active,
77
+ enterFrom: hidden,
78
+ enterTo: visible,
79
+ leaveActive: active,
80
+ leaveFrom: visible,
81
+ leaveTo: hidden,
82
+ });
83
+ }
84
+ /**
85
+ * Animate an element open/closed along the y-axis (height) or x-axis (width).
86
+ *
87
+ * Unlike CSS-only presets, `collapse()` measures the element's natural size
88
+ * at runtime — the animation works regardless of content size. Only the
89
+ * first element in each `nodes` group is animated.
90
+ */
91
+ export function collapse(opts = {}) {
92
+ const axis = opts.axis ?? 'y';
93
+ const duration = opts.duration ?? 250;
94
+ const easing = opts.easing ?? 'ease-out';
95
+ const appear = opts.appear !== false;
96
+ const sizeProp = axis === 'y' ? 'height' : 'width';
97
+ const runEnter = (nodes) => {
98
+ const els = asElements(nodes);
99
+ if (els.length === 0)
100
+ return Promise.resolve();
101
+ const el = els[0];
102
+ // Measure natural size with content visible.
103
+ const naturalSize = axis === 'y' ? el.scrollHeight : el.scrollWidth;
104
+ const style = el.style;
105
+ // Save values to restore.
106
+ const prevOverflow = style.overflow;
107
+ const prevSize = style[sizeProp];
108
+ const prevTransition = style.transition;
109
+ style.overflow = 'hidden';
110
+ style[sizeProp] = '0px';
111
+ style.transition = `${sizeProp} ${duration}ms ${easing}`;
112
+ forceReflow(el);
113
+ style[sizeProp] = `${naturalSize}px`;
114
+ return wait(duration).then(() => {
115
+ style.overflow = prevOverflow;
116
+ style[sizeProp] = prevSize;
117
+ style.transition = prevTransition;
118
+ });
119
+ };
120
+ const runLeave = (nodes) => {
121
+ const els = asElements(nodes);
122
+ if (els.length === 0)
123
+ return Promise.resolve();
124
+ const el = els[0];
125
+ const naturalSize = axis === 'y' ? el.scrollHeight : el.scrollWidth;
126
+ const style = el.style;
127
+ style.overflow = 'hidden';
128
+ style[sizeProp] = `${naturalSize}px`;
129
+ style.transition = `${sizeProp} ${duration}ms ${easing}`;
130
+ forceReflow(el);
131
+ style[sizeProp] = '0px';
132
+ return wait(duration);
133
+ };
134
+ const out = { leave: runLeave };
135
+ if (appear) {
136
+ out.enter = (nodes) => {
137
+ void runEnter(nodes);
138
+ };
139
+ }
140
+ return out;
141
+ }
142
+ function wait(ms) {
143
+ if (ms <= 0)
144
+ return Promise.resolve();
145
+ return new Promise((resolve) => setTimeout(resolve, ms + 16));
146
+ }
@@ -0,0 +1,19 @@
1
+ import type { TransitionValue } from './types';
2
+ /**
3
+ * Filter an array of nodes down to HTMLElements — transition animations
4
+ * only apply to elements, not comment anchors or text nodes.
5
+ */
6
+ export declare function asElements(nodes: Node[]): HTMLElement[];
7
+ /** Apply a TransitionValue (classes, styles, or a mix) to an element. */
8
+ export declare function applyValue(el: HTMLElement, value: TransitionValue | undefined): void;
9
+ /** Remove a TransitionValue from an element. */
10
+ export declare function removeValue(el: HTMLElement, value: TransitionValue | undefined): void;
11
+ /**
12
+ * Detect total transition duration from computed styles.
13
+ * Returns the maximum of (transition-duration + transition-delay) and
14
+ * (animation-duration + animation-delay), in milliseconds.
15
+ */
16
+ export declare function detectDuration(el: HTMLElement): number;
17
+ /** Force a style recalculation so subsequent class/style changes animate. */
18
+ export declare function forceReflow(el: HTMLElement): void;
19
+ //# sourceMappingURL=style-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"style-utils.d.ts","sourceRoot":"","sources":["../src/style-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,eAAe,EAAE,MAAM,SAAS,CAAA;AA0CtD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,WAAW,EAAE,CAMvD;AAED,yEAAyE;AACzE,wBAAgB,UAAU,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,eAAe,GAAG,SAAS,GAAG,IAAI,CAcpF;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,eAAe,GAAG,SAAS,GAAG,IAAI,CAcrF;AA0BD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,WAAW,GAAG,MAAM,CAKtD;AAiBD,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,CAGjD"}
@@ -0,0 +1,145 @@
1
+ // CSS properties that are unitless when given a numeric value.
2
+ // All other numeric values are suffixed with `px`.
3
+ const UNITLESS = new Set([
4
+ 'opacity',
5
+ 'flex',
6
+ 'flexGrow',
7
+ 'flexShrink',
8
+ 'flexNegative',
9
+ 'order',
10
+ 'zIndex',
11
+ 'fontWeight',
12
+ 'lineHeight',
13
+ 'zoom',
14
+ 'gridRow',
15
+ 'gridRowStart',
16
+ 'gridRowEnd',
17
+ 'gridColumn',
18
+ 'gridColumnStart',
19
+ 'gridColumnEnd',
20
+ 'columnCount',
21
+ 'tabSize',
22
+ 'scale',
23
+ 'aspectRatio',
24
+ ]);
25
+ function formatStyleValue(prop, value) {
26
+ if (typeof value === 'string')
27
+ return value;
28
+ if (UNITLESS.has(prop))
29
+ return String(value);
30
+ return `${value}px`;
31
+ }
32
+ function splitClasses(raw) {
33
+ const out = [];
34
+ const parts = raw.split(/\s+/);
35
+ for (const p of parts) {
36
+ if (p.length > 0)
37
+ out.push(p);
38
+ }
39
+ return out;
40
+ }
41
+ /**
42
+ * Filter an array of nodes down to HTMLElements — transition animations
43
+ * only apply to elements, not comment anchors or text nodes.
44
+ */
45
+ export function asElements(nodes) {
46
+ const out = [];
47
+ for (const n of nodes) {
48
+ if (n.nodeType === 1)
49
+ out.push(n);
50
+ }
51
+ return out;
52
+ }
53
+ /** Apply a TransitionValue (classes, styles, or a mix) to an element. */
54
+ export function applyValue(el, value) {
55
+ if (value == null)
56
+ return;
57
+ if (typeof value === 'string') {
58
+ applyClasses(el, value);
59
+ return;
60
+ }
61
+ if (Array.isArray(value)) {
62
+ for (const part of value) {
63
+ if (typeof part === 'string')
64
+ applyClasses(el, part);
65
+ else
66
+ applyStyles(el, part);
67
+ }
68
+ return;
69
+ }
70
+ applyStyles(el, value);
71
+ }
72
+ /** Remove a TransitionValue from an element. */
73
+ export function removeValue(el, value) {
74
+ if (value == null)
75
+ return;
76
+ if (typeof value === 'string') {
77
+ removeClasses(el, value);
78
+ return;
79
+ }
80
+ if (Array.isArray(value)) {
81
+ for (const part of value) {
82
+ if (typeof part === 'string')
83
+ removeClasses(el, part);
84
+ else
85
+ removeStyles(el, part);
86
+ }
87
+ return;
88
+ }
89
+ removeStyles(el, value);
90
+ }
91
+ function applyClasses(el, raw) {
92
+ const classes = splitClasses(raw);
93
+ if (classes.length > 0)
94
+ el.classList.add(...classes);
95
+ }
96
+ function removeClasses(el, raw) {
97
+ const classes = splitClasses(raw);
98
+ if (classes.length > 0)
99
+ el.classList.remove(...classes);
100
+ }
101
+ function applyStyles(el, styles) {
102
+ const decl = el.style;
103
+ for (const key in styles) {
104
+ decl[key] = formatStyleValue(key, styles[key]);
105
+ }
106
+ }
107
+ function removeStyles(el, styles) {
108
+ const decl = el.style;
109
+ for (const key in styles) {
110
+ decl[key] = '';
111
+ }
112
+ }
113
+ /**
114
+ * Detect total transition duration from computed styles.
115
+ * Returns the maximum of (transition-duration + transition-delay) and
116
+ * (animation-duration + animation-delay), in milliseconds.
117
+ */
118
+ export function detectDuration(el) {
119
+ const cs = getComputedStyle(el);
120
+ const t = parseTime(cs.transitionDuration) + parseTime(cs.transitionDelay);
121
+ const a = parseTime(cs.animationDuration) + parseTime(cs.animationDelay);
122
+ return Math.max(t, a);
123
+ }
124
+ function parseTime(raw) {
125
+ if (!raw)
126
+ return 0;
127
+ let max = 0;
128
+ const parts = raw.split(',');
129
+ for (const part of parts) {
130
+ const trimmed = part.trim();
131
+ const m = trimmed.match(/^([\d.]+)(m?s)$/);
132
+ if (!m)
133
+ continue;
134
+ const n = parseFloat(m[1]);
135
+ const ms = m[2] === 's' ? n * 1000 : n;
136
+ if (ms > max)
137
+ max = ms;
138
+ }
139
+ return max;
140
+ }
141
+ /** Force a style recalculation so subsequent class/style changes animate. */
142
+ export function forceReflow(el) {
143
+ // Reading an offset property forces synchronous layout.
144
+ void el.offsetHeight;
145
+ }
@@ -0,0 +1,21 @@
1
+ import type { TransitionOptions } from '@llui/dom';
2
+ import type { TransitionSpec } from './types';
3
+ /**
4
+ * Build a `TransitionOptions` bundle ({ enter, leave }) from a spec.
5
+ *
6
+ * Pass the result into `branch`, `show`, or `each` to animate the enter/leave
7
+ * of that structural block.
8
+ *
9
+ * Lifecycle:
10
+ * - **enter**: apply `enterFrom` + `enterActive` → reflow → swap `enterFrom` → `enterTo`
11
+ * → wait for duration → remove all transient values (element rests on its base styles).
12
+ * - **leave**: apply `leaveFrom` + `leaveActive` → reflow → swap `leaveFrom` → `leaveTo`
13
+ * → wait for duration (Promise-resolved so DOM removal is deferred).
14
+ *
15
+ * Duration:
16
+ * - If `duration` is given, it is used verbatim.
17
+ * - Otherwise, computed `transition-duration + transition-delay` is read after
18
+ * the active/from classes are applied, taking the max across properties.
19
+ */
20
+ export declare function transition(spec: TransitionSpec): TransitionOptions;
21
+ //# sourceMappingURL=transition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transition.d.ts","sourceRoot":"","sources":["../src/transition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAClD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAM7C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,iBAAiB,CA+DlE"}
@@ -0,0 +1,79 @@
1
+ import { applyValue, removeValue, asElements, detectDuration, forceReflow } from './style-utils';
2
+ // Buffer added to setTimeout so styles have settled before resolution.
3
+ const TIMING_BUFFER_MS = 16;
4
+ /**
5
+ * Build a `TransitionOptions` bundle ({ enter, leave }) from a spec.
6
+ *
7
+ * Pass the result into `branch`, `show`, or `each` to animate the enter/leave
8
+ * of that structural block.
9
+ *
10
+ * Lifecycle:
11
+ * - **enter**: apply `enterFrom` + `enterActive` → reflow → swap `enterFrom` → `enterTo`
12
+ * → wait for duration → remove all transient values (element rests on its base styles).
13
+ * - **leave**: apply `leaveFrom` + `leaveActive` → reflow → swap `leaveFrom` → `leaveTo`
14
+ * → wait for duration (Promise-resolved so DOM removal is deferred).
15
+ *
16
+ * Duration:
17
+ * - If `duration` is given, it is used verbatim.
18
+ * - Otherwise, computed `transition-duration + transition-delay` is read after
19
+ * the active/from classes are applied, taking the max across properties.
20
+ */
21
+ export function transition(spec) {
22
+ const appear = spec.appear !== false;
23
+ const runEnter = (nodes) => {
24
+ const els = asElements(nodes);
25
+ if (els.length === 0)
26
+ return Promise.resolve();
27
+ // Apply from + active
28
+ for (const el of els) {
29
+ applyValue(el, spec.enterFrom);
30
+ applyValue(el, spec.enterActive);
31
+ }
32
+ // Force reflow so the next class change triggers a transition.
33
+ forceReflow(els[0]);
34
+ // Move to target state
35
+ for (const el of els) {
36
+ removeValue(el, spec.enterFrom);
37
+ applyValue(el, spec.enterTo);
38
+ }
39
+ const duration = spec.duration ?? detectDuration(els[0]);
40
+ return wait(duration).then(() => {
41
+ for (const el of els) {
42
+ removeValue(el, spec.enterActive);
43
+ removeValue(el, spec.enterTo);
44
+ }
45
+ });
46
+ };
47
+ const runLeave = (nodes) => {
48
+ const els = asElements(nodes);
49
+ if (els.length === 0)
50
+ return Promise.resolve();
51
+ for (const el of els) {
52
+ applyValue(el, spec.leaveFrom);
53
+ applyValue(el, spec.leaveActive);
54
+ }
55
+ forceReflow(els[0]);
56
+ for (const el of els) {
57
+ removeValue(el, spec.leaveFrom);
58
+ applyValue(el, spec.leaveTo);
59
+ }
60
+ const duration = spec.duration ?? detectDuration(els[0]);
61
+ return wait(duration);
62
+ };
63
+ const out = {
64
+ leave: runLeave,
65
+ };
66
+ if (appear) {
67
+ out.enter = (nodes) => {
68
+ void runEnter(nodes);
69
+ };
70
+ }
71
+ return out;
72
+ }
73
+ function wait(ms) {
74
+ if (ms <= 0)
75
+ return Promise.resolve();
76
+ return new Promise((resolve) => {
77
+ setTimeout(resolve, ms + TIMING_BUFFER_MS);
78
+ });
79
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CSS style properties as a plain object. Numeric values are automatically
3
+ * suffixed with `px` for known dimensional properties.
4
+ *
5
+ * Example: `{ opacity: 0, transform: 'scale(0.95)', width: 200 }`
6
+ */
7
+ export type Styles = Record<string, string | number>;
8
+ /**
9
+ * One "state" in a transition.
10
+ *
11
+ * - `string` — space-separated class names (applied via classList)
12
+ * - `Styles` — inline style object (applied via element.style)
13
+ * - `Array<string | Styles>` — mix both (useful for utility classes + dynamic styles)
14
+ */
15
+ export type TransitionValue = string | Styles | Array<string | Styles>;
16
+ export interface TransitionSpec {
17
+ /** Initial state before enter animation (removed once enter completes). */
18
+ enterFrom?: TransitionValue;
19
+ /** Final state during enter animation (removed once enter completes). */
20
+ enterTo?: TransitionValue;
21
+ /** Applied throughout enter (typically the `transition-*` / `animation` properties). */
22
+ enterActive?: TransitionValue;
23
+ /** Initial state before leave animation. */
24
+ leaveFrom?: TransitionValue;
25
+ /** Final state during leave animation. */
26
+ leaveTo?: TransitionValue;
27
+ /** Applied throughout leave. */
28
+ leaveActive?: TransitionValue;
29
+ /**
30
+ * Explicit duration in milliseconds. When omitted, the duration is read from
31
+ * the element's computed `transition-duration` / `transition-delay` after the
32
+ * active classes are applied.
33
+ */
34
+ duration?: number;
35
+ /** If true, run the enter transition on initial mount (default: true). */
36
+ appear?: boolean;
37
+ }
38
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;AAEtE,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,SAAS,CAAC,EAAE,eAAe,CAAA;IAC3B,yEAAyE;IACzE,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,wFAAwF;IACxF,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,eAAe,CAAA;IAC3B,0CAA0C;IAC1C,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,gCAAgC;IAChC,WAAW,CAAC,EAAE,eAAe,CAAA;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@llui/transitions",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "check": "tsc --noEmit -p tsconfig.check.json",
16
+ "lint": "eslint src",
17
+ "test": "vitest run"
18
+ },
19
+ "peerDependencies": {
20
+ "@llui/dom": "^0.0.1"
21
+ },
22
+ "devDependencies": {
23
+ "@llui/dom": "workspace:*",
24
+ "typescript": "^6.0.0",
25
+ "vitest": "^4.1.2"
26
+ },
27
+ "sideEffects": false,
28
+ "description": "LLui animation helpers — transition(), fade, slide, scale, collapse for branch/show/each",
29
+ "keywords": [
30
+ "llui",
31
+ "transitions",
32
+ "animation",
33
+ "fade",
34
+ "slide"
35
+ ],
36
+ "author": "Franco Ponticelli <franco.ponticelli@gmail.com>",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/fponticelli/llui.git",
41
+ "directory": "packages/transitions"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/fponticelli/llui/issues"
45
+ },
46
+ "homepage": "https://github.com/fponticelli/llui/tree/main/packages/transitions#readme",
47
+ "files": [
48
+ "dist"
49
+ ]
50
+ }