@udixio/ui-react 1.6.4 → 2.1.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +51 -3
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.js +1699 -1466
  4. package/dist/lib/config/config.interface.d.ts +4 -0
  5. package/dist/lib/config/config.interface.d.ts.map +1 -0
  6. package/dist/lib/config/define-config.d.ts +4 -0
  7. package/dist/lib/config/define-config.d.ts.map +1 -0
  8. package/dist/lib/config/index.d.ts +3 -0
  9. package/dist/lib/config/index.d.ts.map +1 -0
  10. package/dist/lib/effects/AnimateOnScroll.d.ts +7 -0
  11. package/dist/lib/effects/AnimateOnScroll.d.ts.map +1 -0
  12. package/dist/lib/effects/ThemeProvider.d.ts +3 -2
  13. package/dist/lib/effects/ThemeProvider.d.ts.map +1 -1
  14. package/dist/lib/effects/block-scroll.effect.d.ts +22 -0
  15. package/dist/lib/effects/block-scroll.effect.d.ts.map +1 -0
  16. package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts.map +1 -1
  17. package/dist/lib/effects/index.d.ts +1 -0
  18. package/dist/lib/effects/index.d.ts.map +1 -1
  19. package/dist/lib/effects/scrollDriven.d.ts +5 -0
  20. package/dist/lib/effects/scrollDriven.d.ts.map +1 -0
  21. package/dist/lib/effects/smooth-scroll.effect.d.ts +5 -5
  22. package/dist/lib/effects/smooth-scroll.effect.d.ts.map +1 -1
  23. package/dist/lib/index.d.ts +1 -0
  24. package/dist/lib/index.d.ts.map +1 -1
  25. package/dist/lib/styles/fab.style.d.ts.map +1 -1
  26. package/dist/scrollDriven-AP2yWhzi.js +121 -0
  27. package/dist/scrollDriven-DWAu7CR0.cjs +1 -0
  28. package/package.json +5 -3
  29. package/src/lib/config/config.interface.ts +9 -0
  30. package/src/lib/config/define-config.ts +16 -0
  31. package/src/lib/config/index.ts +2 -0
  32. package/src/lib/effects/AnimateOnScroll.ts +267 -0
  33. package/src/lib/effects/ThemeProvider.tsx +78 -52
  34. package/src/lib/effects/block-scroll.effect.tsx +174 -0
  35. package/src/lib/effects/custom-scroll/custom-scroll.effect.tsx +16 -5
  36. package/src/lib/effects/index.ts +1 -0
  37. package/src/lib/effects/scrollDriven.ts +239 -0
  38. package/src/lib/effects/smooth-scroll.effect.tsx +105 -72
  39. package/src/lib/index.ts +1 -0
  40. package/src/lib/styles/card.style.ts +1 -1
  41. package/src/lib/styles/fab.style.ts +9 -17
  42. package/src/lib/styles/slider.style.ts +2 -2
  43. package/src/lib/styles/tab.style.ts +1 -1
  44. package/src/lib/styles/tabs.style.ts +3 -3
@@ -1,4 +1,9 @@
1
- import { type API, type ConfigInterface, loader } from '@udixio/theme';
1
+ import {
2
+ type API,
3
+ type ConfigInterface,
4
+ ContextOptions,
5
+ loader,
6
+ } from '@udixio/theme';
2
7
  import { useEffect, useRef, useState } from 'react';
3
8
  import { TailwindPlugin } from '@udixio/tailwind';
4
9
 
@@ -11,78 +16,98 @@ export const ThemeProvider = ({
11
16
  config,
12
17
  throttleDelay = 100, // Délai par défaut de 300ms
13
18
  onLoad,
19
+ loadTheme = false,
14
20
  }: {
15
- config: ConfigInterface;
21
+ config: Readonly<ConfigInterface>;
16
22
  onLoad?: (api: API) => void;
17
23
  throttleDelay?: number;
24
+ loadTheme?: boolean;
18
25
  }) => {
19
- const [outputCss, setOutputCss] = useState<null | string>(null);
26
+ const [themeApi, setThemeApi] = useState<API | null>(null);
20
27
 
21
- // Refs pour gérer le throttling
28
+ // Charger l'API du thème une fois au montage
29
+ useEffect(() => {
30
+ (async () => {
31
+ const api = await loader(config, loadTheme);
32
+ setThemeApi(api);
33
+ })();
34
+ }, []);
35
+
36
+ const [outputCss, setOutputCss] = useState<string | null>(null);
37
+
38
+ // Throttle avec exécution en tête (leading) et en fin (trailing)
22
39
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
23
- const lastSourceColorRef = useRef<string>(config.sourceColor);
24
- const isInitialLoadRef = useRef<boolean>(true);
40
+ const lastExecTimeRef = useRef<number>(0);
41
+ const lastArgsRef = useRef<Partial<ContextOptions> | null>(null);
25
42
 
26
43
  useEffect(() => {
27
- // Si c'est le premier chargement, on applique immédiatement
28
- if (isInitialLoadRef.current) {
29
- isInitialLoadRef.current = false;
30
- lastSourceColorRef.current = config.sourceColor;
31
- applyThemeChange(config.sourceColor);
32
- return;
33
- }
44
+ if (!themeApi) return; // Attendre que l'API soit prête
34
45
 
35
- // Si la couleur n'a pas changé, on ne fait rien
36
- if (config.sourceColor === lastSourceColorRef.current) {
37
- return;
38
- }
46
+ const ctx: Partial<ContextOptions> = {
47
+ ...config,
48
+ // Assurer la compatibilité avec l'API qui attend sourceColorHex
49
+ sourceColor: config.sourceColor,
50
+ };
39
51
 
40
- // Annuler le timeout précédent s'il existe
41
- if (timeoutRef.current) {
42
- clearTimeout(timeoutRef.current);
43
- }
52
+ const now = Date.now();
53
+ const timeSinceLast = now - lastExecTimeRef.current;
44
54
 
45
- // Programmer un nouveau changement de thème avec un délai
46
- timeoutRef.current = setTimeout(async () => {
47
- lastSourceColorRef.current = config.sourceColor;
48
- await applyThemeChange(config.sourceColor);
49
- timeoutRef.current = null;
50
- }, throttleDelay);
55
+ const invoke = async (args: Partial<ContextOptions>) => {
56
+ // applique et notifie
57
+ await applyThemeChange(args);
58
+ };
51
59
 
52
- // Cleanup function pour annuler le timeout si le composant se démonte
53
- return () => {
60
+ // Leading: si délai écoulé ou jamais exécuté, exécuter tout de suite
61
+ if (lastExecTimeRef.current === 0 || timeSinceLast >= throttleDelay) {
54
62
  if (timeoutRef.current) {
55
63
  clearTimeout(timeoutRef.current);
56
64
  timeoutRef.current = null;
57
65
  }
58
- };
59
- }, [config.sourceColor, throttleDelay]);
60
-
61
- const applyThemeChange = async (sourceColor: string) => {
62
- if (!isValidHexColor(sourceColor)) {
63
- throw new Error('Invalid hex color');
66
+ lastArgsRef.current = null;
67
+ lastExecTimeRef.current = now;
68
+ void invoke(ctx);
69
+ } else {
70
+ // Sinon, mémoriser la dernière requête et programmer une exécution en trailing
71
+ lastArgsRef.current = ctx;
72
+ if (!timeoutRef.current) {
73
+ const remaining = Math.max(0, throttleDelay - timeSinceLast);
74
+ timeoutRef.current = setTimeout(async () => {
75
+ timeoutRef.current = null;
76
+ const args = lastArgsRef.current;
77
+ lastArgsRef.current = null;
78
+ if (args) {
79
+ lastExecTimeRef.current = Date.now();
80
+ await invoke(args);
81
+ }
82
+ }, remaining);
83
+ }
64
84
  }
65
85
 
66
- try {
67
- // Mesure du temps de chargement de l'API
68
- const api = await loader({
69
- ...config,
70
- sourceColor,
71
- });
72
- onLoad?.(api);
86
+ // Cleanup: au changement de dépendances, ne rien faire ici (on gère trailing)
87
+ return () => {};
88
+ }, [config, throttleDelay, themeApi]);
73
89
 
74
- const generatedCss = api.plugins
75
- .getPlugin(TailwindPlugin)
76
- .getInstance().outputCss;
77
-
78
- if (generatedCss) {
79
- setOutputCss(generatedCss);
90
+ const applyThemeChange = async (ctx: Partial<ContextOptions>) => {
91
+ if (typeof ctx.sourceColor == 'string') {
92
+ if (!isValidHexColor(ctx.sourceColor)) {
93
+ throw new Error('Invalid hex color');
80
94
  }
81
- } catch (err) {
82
- throw new Error(
83
- err instanceof Error ? err.message : 'Theme loading failed',
84
- );
85
95
  }
96
+
97
+ if (!themeApi) {
98
+ // L'API n'est pas prête; ignorer silencieusement car l'effet principal attend themeApi
99
+ return;
100
+ }
101
+ themeApi.context.update(ctx);
102
+
103
+ await themeApi.load();
104
+
105
+ const outputCss = themeApi?.plugins
106
+ .getPlugin(TailwindPlugin)
107
+ .getInstance().outputCss;
108
+ setOutputCss(outputCss);
109
+
110
+ onLoad?.(themeApi);
86
111
  };
87
112
 
88
113
  // Cleanup lors du démontage du composant
@@ -90,6 +115,7 @@ export const ThemeProvider = ({
90
115
  return () => {
91
116
  if (timeoutRef.current) {
92
117
  clearTimeout(timeoutRef.current);
118
+ timeoutRef.current = null;
93
119
  }
94
120
  };
95
121
  }, []);
@@ -0,0 +1,174 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ type ScrollIntent =
4
+ | {
5
+ type: 'intent';
6
+ source: 'wheel' | 'touch' | 'keyboard';
7
+ deltaX: number;
8
+ deltaY: number;
9
+ originalEvent: Event;
10
+ }
11
+ | {
12
+ type: 'scrollbar';
13
+ scrollTop: number;
14
+ scrollLeft: number;
15
+ maxScrollTop: number;
16
+ maxScrollLeft: number;
17
+ };
18
+
19
+ type BlockScrollProps = {
20
+ onScroll?: (evt: ScrollIntent) => void; // log des intentions + du scroll via scrollbar
21
+ touch?: boolean;
22
+ el: HTMLElement;
23
+ };
24
+
25
+ export const BlockScroll: React.FC<BlockScrollProps> = ({
26
+ onScroll,
27
+ el,
28
+ touch = true,
29
+ }) => {
30
+ const lastTouch = useRef<{ x: number; y: number } | null>(null);
31
+ const lastScrollTop = useRef<number>(0);
32
+ const lastScrollLeft = useRef<number>(0);
33
+
34
+ useEffect(() => {
35
+ if (!el) return;
36
+
37
+ // Initialize last known scroll positions to block scrollbar-based scrolling
38
+ lastScrollTop.current = el.scrollTop;
39
+ lastScrollLeft.current = el.scrollLeft;
40
+
41
+ const emitIntent = (payload: Extract<ScrollIntent, { type: 'intent' }>) => {
42
+ // Log the desired deltaY for every scroll attempt (wheel/touch/keyboard)
43
+ onScroll?.(payload);
44
+ };
45
+
46
+ const onWheel = (e: WheelEvent) => {
47
+ e.preventDefault();
48
+ emitIntent({
49
+ type: 'intent',
50
+ source: 'wheel',
51
+ deltaX: e.deltaX,
52
+ deltaY: e.deltaY,
53
+ originalEvent: e,
54
+ });
55
+ };
56
+
57
+ const onTouchStart = (e: TouchEvent) => {
58
+ if (!touch) return;
59
+ const t = e.touches[0];
60
+ if (t) lastTouch.current = { x: t.clientX, y: t.clientY };
61
+ };
62
+
63
+ const onTouchMove = (e: TouchEvent) => {
64
+ if (!touch) return;
65
+ const t = e.touches[0];
66
+ if (!t || !lastTouch.current) return;
67
+ e.preventDefault();
68
+ const dx = lastTouch.current.x - t.clientX;
69
+ const dy = lastTouch.current.y - t.clientY;
70
+ lastTouch.current = { x: t.clientX, y: t.clientY };
71
+ emitIntent({
72
+ type: 'intent',
73
+ source: 'touch',
74
+ deltaX: dx,
75
+ deltaY: dy,
76
+ originalEvent: e,
77
+ });
78
+ };
79
+
80
+ const onTouchEnd = () => {
81
+ if (!touch) return;
82
+ lastTouch.current = null;
83
+ };
84
+
85
+ const onKeyDown = (e: KeyboardEvent) => {
86
+ const line = 40;
87
+ const page = el.clientHeight * 0.9;
88
+ let dx = 0,
89
+ dy = 0;
90
+
91
+ switch (e.key) {
92
+ case 'ArrowDown':
93
+ dy = line;
94
+ break;
95
+ case 'ArrowUp':
96
+ dy = -line;
97
+ break;
98
+ case 'ArrowRight':
99
+ dx = line;
100
+ break;
101
+ case 'ArrowLeft':
102
+ dx = -line;
103
+ break;
104
+ case 'PageDown':
105
+ dy = page;
106
+ break;
107
+ case 'PageUp':
108
+ dy = -page;
109
+ break;
110
+ case 'Home':
111
+ dy = Number.NEGATIVE_INFINITY;
112
+ break;
113
+ case 'End':
114
+ dy = Number.POSITIVE_INFINITY;
115
+ break;
116
+ case ' ':
117
+ dy = e.shiftKey ? -page : page;
118
+ break;
119
+ default:
120
+ return;
121
+ }
122
+ e.preventDefault();
123
+ emitIntent({
124
+ type: 'intent',
125
+ source: 'keyboard',
126
+ deltaX: dx,
127
+ deltaY: dy,
128
+ originalEvent: e,
129
+ });
130
+ };
131
+
132
+ // const onScrollEvent = (e) => {
133
+ // const currentScrollTop = e.target.scrollTop;
134
+ // const currentScrollLeft = e.target.scrollLeft;
135
+ //
136
+ // // Check if scroll position changed from last known position
137
+ // if (
138
+ // currentScrollTop !== lastScrollTop.current ||
139
+ // currentScrollLeft !== lastScrollLeft.current
140
+ // ) {
141
+ // console.log('onScrollllllllllll', e, document);
142
+ // onScroll?.({
143
+ // type: 'scrollbar',
144
+ // scrollTop: currentScrollTop,
145
+ // scrollLeft: currentScrollLeft,
146
+ // maxScrollTop: e.target.scrollHeight - e.target.clientHeight,
147
+ // maxScrollLeft: e.target.scrollWidth - e.target.clientWidth,
148
+ // });
149
+ // }
150
+ //
151
+ // // Update last known scroll positions
152
+ // lastScrollTop.current = currentScrollTop;
153
+ // lastScrollLeft.current = currentScrollLeft;
154
+ //
155
+ // document.querySelector('html')?.scrollTo({ top: 0 });
156
+ // };
157
+
158
+ el.addEventListener('wheel', onWheel, { passive: false });
159
+ el.addEventListener('touchstart', onTouchStart, { passive: true });
160
+ el.addEventListener('touchmove', onTouchMove, { passive: false });
161
+ el.addEventListener('touchend', onTouchEnd, { passive: true });
162
+ el.addEventListener('keydown', onKeyDown);
163
+ // el.addEventListener('scroll', onScrollEvent, { passive: true });
164
+
165
+ return () => {
166
+ el.removeEventListener('wheel', onWheel as EventListener);
167
+ el.removeEventListener('touchstart', onTouchStart as EventListener);
168
+ el.removeEventListener('touchmove', onTouchMove as EventListener);
169
+ el.removeEventListener('touchend', onTouchEnd as EventListener);
170
+ el.removeEventListener('keydown', onKeyDown as EventListener);
171
+ // el.removeEventListener('scroll', onScrollEvent as EventListener);
172
+ };
173
+ }, [onScroll]);
174
+ };
@@ -90,21 +90,32 @@ export const CustomScroll = ({
90
90
  handleScrollThrottledRef.current = throttle(
91
91
  throttleDuration,
92
92
  (latestValue, scrollOrientation: 'x' | 'y') => {
93
- if (!containerSize.current || !contentScrollSize.current) return;
93
+ if (
94
+ !containerSize.current ||
95
+ !contentScrollSize.current ||
96
+ !ref.current
97
+ )
98
+ return;
94
99
  if (onScroll) {
95
100
  if (orientation === 'horizontal' && scrollOrientation === 'x') {
96
101
  onScroll({
97
102
  scrollProgress: latestValue,
98
- scroll: latestValue * contentScrollSize.current.width,
99
- scrollTotal: contentScrollSize.current.width,
103
+ scroll:
104
+ latestValue *
105
+ (contentScrollSize.current.width - ref.current.clientWidth),
106
+ scrollTotal:
107
+ contentScrollSize.current.width - ref.current.clientWidth,
100
108
  scrollVisible: containerSize.current.width,
101
109
  });
102
110
  }
103
111
  if (orientation === 'vertical' && scrollOrientation === 'y') {
104
112
  onScroll({
105
113
  scrollProgress: latestValue,
106
- scroll: latestValue * contentScrollSize.current.height,
107
- scrollTotal: contentScrollSize.current.height,
114
+ scroll:
115
+ latestValue *
116
+ (contentScrollSize.current.height - ref.current.clientHeight),
117
+ scrollTotal:
118
+ contentScrollSize.current.height - ref.current.clientHeight,
108
119
  scrollVisible: containerSize.current.height,
109
120
  });
110
121
  }
@@ -3,3 +3,4 @@ export * from './custom-scroll';
3
3
  export * from './smooth-scroll.effect';
4
4
  export * from './SyncedFixedWrapper';
5
5
  export * from './ThemeProvider';
6
+ export * from './AnimateOnScroll';
@@ -0,0 +1,239 @@
1
+ import { CSSProperties } from 'react';
2
+
3
+ export type InitAnimationOptions = {
4
+ once?: boolean;
5
+ };
6
+
7
+ let initialized = false;
8
+ let teardown: (() => void) | null = null;
9
+
10
+ function supportsScrollTimeline(): boolean {
11
+ if (typeof window === 'undefined') return false;
12
+ try {
13
+ // @ts-ignore
14
+ if (window.CSS && typeof window.CSS.supports === 'function') {
15
+ // @ts-ignore
16
+ return (
17
+ CSS.supports('animation-timeline: view()') ||
18
+ CSS.supports('animation-timeline: scroll()') ||
19
+ CSS.supports('view-timeline-name: --a')
20
+ );
21
+ }
22
+ } catch {}
23
+ return false;
24
+ }
25
+
26
+ function prefersReducedMotion(): boolean {
27
+ if (typeof window === 'undefined' || !('matchMedia' in window)) return false;
28
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
29
+ }
30
+
31
+ // Helpers to read CSS custom properties used by the Tailwind plugin
32
+ function readVar(el: Element, name: string): string | null {
33
+ const v = getComputedStyle(el).getPropertyValue(name).trim();
34
+ return v || null;
35
+ }
36
+
37
+ function parsePercentFromRangeToken(token?: string | null): number | null {
38
+ if (!token) return null;
39
+ // Expect patterns like "entry 20%", "cover 80%", "center 60%" etc.
40
+ const parts = token.split(/\s+/);
41
+ const last = parts[parts.length - 1];
42
+ if (!last) return null;
43
+ if (last.endsWith('%')) {
44
+ const n = parseFloat(last);
45
+ if (!isNaN(n)) return Math.max(0, Math.min(100, n)) / 100;
46
+ }
47
+ // px not supported for now (would require element size); fallback null
48
+ return null;
49
+ }
50
+
51
+ function getRange(el: Element): { start: number; end: number } {
52
+ const startToken = readVar(el, '--udx-range-start');
53
+ const endToken = readVar(el, '--udx-range-end');
54
+ const start = parsePercentFromRangeToken(startToken) ?? 0.2; // default entry 20%
55
+ const end = parsePercentFromRangeToken(endToken) ?? 0.5; // default cover 50%
56
+ // Ensure sane ordering
57
+ const s = Math.max(0, Math.min(1, start));
58
+ const e = Math.max(s + 0.001, Math.min(1, end));
59
+ return { start: s, end: e };
60
+ }
61
+
62
+ function num(val: string | null | undefined, unit?: 'px' | 'deg'): number | null {
63
+ if (!val) return null;
64
+ const v = val.trim();
65
+ if (unit && v.endsWith(unit)) {
66
+ const n = parseFloat(v);
67
+ return isNaN(n) ? null : n;
68
+ }
69
+ // Try plain number
70
+ const n = parseFloat(v);
71
+ return isNaN(n) ? null : n;
72
+ }
73
+
74
+ function lerp(a: number, b: number, t: number) {
75
+ return a + (b - a) * t;
76
+ }
77
+
78
+ function applyProgress(el: HTMLElement, from: CSSProperties, to: CSSProperties, p: number) {
79
+ // Opacity
80
+ const o0 = from.opacity != null ? Number(from.opacity) : 1;
81
+ const o1 = to.opacity != null ? Number(to.opacity) : 1;
82
+ const op = lerp(o0, o1, p);
83
+ el.style.opacity = String(op);
84
+
85
+ // Transform: translateX/Y (px only), scale, rotate (deg)
86
+ const fx = num((from as any)['--tw-enter-translate-x'] as any) ??
87
+ num((from as any)['--tw-exit-translate-x'] as any) ?? 0;
88
+ const fy = num((from as any)['--tw-enter-translate-y'] as any) ??
89
+ num((from as any)['--tw-exit-translate-y'] as any) ?? 0;
90
+ const tx = num((to as any)['--tw-enter-translate-x'] as any) ??
91
+ num((to as any)['--tw-exit-translate-x'] as any) ?? 0;
92
+ const ty = num((to as any)['--tw-enter-translate-y'] as any) ??
93
+ num((to as any)['--tw-exit-translate-y'] as any) ?? 0;
94
+
95
+ const fs = num((from as any)['--tw-enter-scale'] as any) ??
96
+ num((from as any)['--tw-exit-scale'] as any) ?? 1;
97
+ const ts = num((to as any)['--tw-enter-scale'] as any) ??
98
+ num((to as any)['--tw-exit-scale'] as any) ?? 1;
99
+
100
+ const fr = num((from as any)['--tw-enter-rotate'] as any) ??
101
+ num((from as any)['--tw-exit-rotate'] as any) ?? 0;
102
+ const tr = num((to as any)['--tw-enter-rotate'] as any) ??
103
+ num((to as any)['--tw-exit-rotate'] as any) ?? 0;
104
+
105
+ const x = lerp(fx, tx, p);
106
+ const y = lerp(fy, ty, p);
107
+ const s = lerp(fs, ts, p);
108
+ const r = lerp(fr, tr, p);
109
+
110
+ const transforms: string[] = [];
111
+ if (x !== 0 || y !== 0) transforms.push(`translate3d(${x}px, ${y}px, 0)`);
112
+ if (s !== 1) transforms.push(`scale(${s})`);
113
+ if (r !== 0) transforms.push(`rotate(${r}deg)`);
114
+ el.style.transform = transforms.length ? transforms.join(' ') : 'none';
115
+ }
116
+
117
+ function buildFromTo(el: Element): { from: CSSProperties; to: CSSProperties } | null {
118
+ const cls = el.classList;
119
+ const isIn = cls.contains('animate-in');
120
+ const isOut = cls.contains('animate-out');
121
+ if (!isIn && !isOut) return null;
122
+
123
+ const cs = getComputedStyle(el as Element);
124
+ const enter = {
125
+ opacity: num(cs.getPropertyValue('--tw-enter-opacity')) ?? undefined,
126
+ '--tw-enter-translate-x': cs.getPropertyValue('--tw-enter-translate-x') || undefined,
127
+ '--tw-enter-translate-y': cs.getPropertyValue('--tw-enter-translate-y') || undefined,
128
+ '--tw-enter-scale': cs.getPropertyValue('--tw-enter-scale') || undefined,
129
+ '--tw-enter-rotate': cs.getPropertyValue('--tw-enter-rotate') || undefined,
130
+ } as CSSProperties & Record<string, any>;
131
+ const exit = {
132
+ opacity: num(cs.getPropertyValue('--tw-exit-opacity')) ?? undefined,
133
+ '--tw-exit-translate-x': cs.getPropertyValue('--tw-exit-translate-x') || undefined,
134
+ '--tw-exit-translate-y': cs.getPropertyValue('--tw-exit-translate-y') || undefined,
135
+ '--tw-exit-scale': cs.getPropertyValue('--tw-exit-scale') || undefined,
136
+ '--tw-exit-rotate': cs.getPropertyValue('--tw-exit-rotate') || undefined,
137
+ } as CSSProperties & Record<string, any>;
138
+
139
+ if (isIn) {
140
+ // from enter vars to neutral
141
+ return {
142
+ from: enter,
143
+ to: { opacity: 1, '--tw-enter-translate-x': '0', '--tw-enter-translate-y': '0', '--tw-enter-scale': '1', '--tw-enter-rotate': '0' } as any,
144
+ };
145
+ }
146
+ if (isOut) {
147
+ // from neutral to exit vars
148
+ return {
149
+ from: { opacity: 1, '--tw-exit-translate-x': '0', '--tw-exit-translate-y': '0', '--tw-exit-scale': '1', '--tw-exit-rotate': '0' } as any,
150
+ to: exit,
151
+ };
152
+ }
153
+ return null;
154
+ }
155
+
156
+ function findTargets(): HTMLElement[] {
157
+ const selector = [
158
+ '.udx-view',
159
+ '.udx-view-x',
160
+ '.udx-view-y',
161
+ '.udx-view-inline',
162
+ '.udx-view-block',
163
+ '[data-udx-view]'
164
+ ]
165
+ .map((s) => `${s}.animate-in, ${s}.animate-out`)
166
+ .join(', ');
167
+ return Array.from(document.querySelectorAll<HTMLElement>(selector));
168
+ }
169
+
170
+ export function initScrollViewFallback(options: InitAnimationOptions = {}) {
171
+ if (initialized) return teardown || (() => {});
172
+ if (typeof window === 'undefined') return () => {};
173
+ if (supportsScrollTimeline() || prefersReducedMotion()) {
174
+ initialized = true; // No-op in supporting browsers or reduced motion
175
+ return () => {};
176
+ }
177
+
178
+ initialized = true;
179
+ const once = options.once ?? true;
180
+ const seen = new WeakSet<Element>();
181
+ let rafId: number | null = null;
182
+
183
+ const measure = () => {
184
+ const targets = findTargets();
185
+ const vh = window.innerHeight || 0;
186
+ for (const el of targets) {
187
+ const rect = el.getBoundingClientRect();
188
+ const visible = Math.min(rect.bottom, vh) - Math.max(rect.top, 0);
189
+ const visibleClamped = Math.max(0, Math.min(visible, rect.height));
190
+ const ratio = rect.height > 0 ? visibleClamped / rect.height : 0;
191
+
192
+ const { start, end } = getRange(el);
193
+ let p = (ratio - start) / (end - start);
194
+ p = Math.max(0, Math.min(1, p));
195
+
196
+ if (once && seen.has(el) && p < 1) p = 1;
197
+
198
+ const fe = buildFromTo(el);
199
+ if (!fe) continue;
200
+ const { from, to } = fe;
201
+ applyProgress(el, from, to, p);
202
+
203
+ if (p >= 1 && once) seen.add(el);
204
+ }
205
+ };
206
+
207
+ const onScroll = () => {
208
+ if (rafId != null) return;
209
+ rafId = window.requestAnimationFrame(() => {
210
+ rafId = null;
211
+ measure();
212
+ });
213
+ };
214
+
215
+ // Initial run and listeners
216
+ measure();
217
+ window.addEventListener('scroll', onScroll, { passive: true });
218
+ window.addEventListener('resize', onScroll);
219
+
220
+ const mo = new MutationObserver(() => {
221
+ onScroll();
222
+ });
223
+ mo.observe(document.documentElement, {
224
+ childList: true,
225
+ subtree: true,
226
+ attributes: true,
227
+ });
228
+
229
+ teardown = () => {
230
+ window.removeEventListener('scroll', onScroll);
231
+ window.removeEventListener('resize', onScroll);
232
+ if (rafId != null) cancelAnimationFrame(rafId);
233
+ mo.disconnect();
234
+ initialized = false;
235
+ teardown = null;
236
+ };
237
+
238
+ return teardown;
239
+ }