@udixio/ui-react 2.4.3 → 2.5.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.
- package/CHANGELOG.md +21 -0
- package/dist/index.cjs +2 -2
- package/dist/index.js +1712 -1437
- package/dist/lib/components/Carousel.d.ts +7 -2
- package/dist/lib/components/Carousel.d.ts.map +1 -1
- package/dist/lib/components/CarouselItem.d.ts +1 -1
- package/dist/lib/components/CarouselItem.d.ts.map +1 -1
- package/dist/lib/components/Slider.d.ts.map +1 -1
- package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts +1 -1
- package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts.map +1 -1
- package/dist/lib/effects/custom-scroll/custom-scroll.interface.d.ts +2 -0
- package/dist/lib/effects/custom-scroll/custom-scroll.interface.d.ts.map +1 -1
- package/dist/lib/effects/custom-scroll/custom-scroll.style.d.ts +4 -0
- package/dist/lib/effects/custom-scroll/custom-scroll.style.d.ts.map +1 -1
- package/dist/lib/interfaces/carousel-item.interface.d.ts +1 -0
- package/dist/lib/interfaces/carousel-item.interface.d.ts.map +1 -1
- package/dist/lib/interfaces/carousel.interface.d.ts +19 -1
- package/dist/lib/interfaces/carousel.interface.d.ts.map +1 -1
- package/dist/lib/styles/carousel-item.style.d.ts +2 -0
- package/dist/lib/styles/carousel-item.style.d.ts.map +1 -1
- package/dist/lib/styles/carousel.style.d.ts +5 -1
- package/dist/lib/styles/carousel.style.d.ts.map +1 -1
- package/dist/lib/styles/icon-button.style.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/lib/components/Carousel.tsx +535 -52
- package/src/lib/components/CarouselItem.tsx +12 -6
- package/src/lib/components/Slider.tsx +25 -28
- package/src/lib/effects/custom-scroll/custom-scroll.effect.tsx +111 -2
- package/src/lib/effects/custom-scroll/custom-scroll.interface.ts +4 -0
- package/src/lib/interfaces/carousel-item.interface.ts +1 -0
- package/src/lib/interfaces/carousel.interface.ts +20 -1
- package/src/lib/styles/carousel-item.style.ts +5 -2
- package/src/lib/styles/carousel.style.ts +1 -3
- package/src/lib/styles/icon-button.style.ts +5 -15
- package/src/lib/styles/slider.style.ts +1 -1
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { animate } from 'motion/react';
|
|
2
3
|
import { CarouselInterface, CarouselItemInterface } from '../interfaces';
|
|
3
4
|
|
|
4
|
-
import { motion, motionValue, useTransform } from 'motion/react';
|
|
5
|
-
|
|
6
5
|
import { carouselStyle } from '../styles';
|
|
7
6
|
import { CustomScroll } from '../effects';
|
|
8
7
|
import { ReactProps } from '../utils';
|
|
@@ -10,9 +9,14 @@ import { CarouselItem, normalize } from './CarouselItem';
|
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Carousels show a collection of items that can be scrolled on and off the screen
|
|
13
|
-
*
|
|
12
|
+
*
|
|
14
13
|
* @status beta
|
|
15
14
|
* @category Layout
|
|
15
|
+
* @limitations
|
|
16
|
+
* - At the end of the scroll, a residual gap/space may remain visible.
|
|
17
|
+
* - In/out behavior is inconsistent at range edges.
|
|
18
|
+
* - Responsive behavior on mobile is not supported.
|
|
19
|
+
* - Only the default (hero) variant is supported.
|
|
16
20
|
*/
|
|
17
21
|
export const Carousel = ({
|
|
18
22
|
variant = 'hero',
|
|
@@ -24,13 +28,18 @@ export const Carousel = ({
|
|
|
24
28
|
outputRange = [42, 300],
|
|
25
29
|
gap = 8,
|
|
26
30
|
onChange,
|
|
31
|
+
onMetricsChange,
|
|
32
|
+
index,
|
|
27
33
|
scrollSensitivity = 1.25,
|
|
28
34
|
...restProps
|
|
29
35
|
}: ReactProps<CarouselInterface>) => {
|
|
30
36
|
const defaultRef = useRef(null);
|
|
31
37
|
const ref = optionalRef || defaultRef;
|
|
32
38
|
|
|
39
|
+
const [translateX, setTranslateX] = useState(0);
|
|
40
|
+
|
|
33
41
|
const styles = carouselStyle({
|
|
42
|
+
index,
|
|
34
43
|
className,
|
|
35
44
|
children,
|
|
36
45
|
variant,
|
|
@@ -47,58 +56,269 @@ export const Carousel = ({
|
|
|
47
56
|
);
|
|
48
57
|
|
|
49
58
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
50
|
-
const [itemsWidth, setItemsWidth] = useState<number
|
|
59
|
+
const [itemsWidth, setItemsWidth] = useState<Record<number, number | null>>(
|
|
60
|
+
{},
|
|
61
|
+
);
|
|
51
62
|
const [scroll, setScroll] = useState<{
|
|
52
63
|
scrollProgress: number;
|
|
53
64
|
scrollTotal: number;
|
|
54
65
|
scrollVisible: number;
|
|
55
66
|
scroll: number;
|
|
56
67
|
} | null>(null);
|
|
68
|
+
// Smoothed scroll progress using framer-motion animate()
|
|
69
|
+
const smoothedProgressRef = useRef(0);
|
|
70
|
+
const scrollAnimationRef = useRef<ReturnType<typeof animate> | null>(null);
|
|
71
|
+
|
|
57
72
|
const calculatePercentages = () => {
|
|
58
|
-
if (
|
|
73
|
+
if (
|
|
74
|
+
!trackRef.current ||
|
|
75
|
+
!ref.current ||
|
|
76
|
+
scroll?.scrollProgress === undefined
|
|
77
|
+
)
|
|
78
|
+
return [];
|
|
59
79
|
|
|
60
|
-
const
|
|
80
|
+
const scrollVisible =
|
|
81
|
+
scroll?.scrollVisible ?? (ref.current as any)?.clientWidth ?? 0;
|
|
82
|
+
// const scrollProgress = scrollMV.get();
|
|
61
83
|
|
|
62
84
|
function assignRelativeIndexes(
|
|
63
85
|
values: number[],
|
|
64
86
|
progressScroll: number,
|
|
65
|
-
):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
): {
|
|
88
|
+
itemScrollXCenter: number;
|
|
89
|
+
relativeIndex: number;
|
|
90
|
+
index: number;
|
|
91
|
+
width: number;
|
|
92
|
+
}[] {
|
|
93
|
+
return values.map((value, index) => ({
|
|
94
|
+
itemScrollXCenter: value,
|
|
95
|
+
relativeIndex:
|
|
96
|
+
(value - progressScroll) / Math.abs(values[1] - values[0]),
|
|
97
|
+
index: index,
|
|
98
|
+
width: 0,
|
|
99
|
+
}));
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
|
|
102
|
+
const itemsScrollXCenter = items.map((_, index) => {
|
|
72
103
|
const itemRef = itemRefs[index];
|
|
73
104
|
|
|
74
105
|
if (!itemRef.current || !trackRef.current) return 0;
|
|
75
106
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (itemScrollXCenter > 1) itemScrollXCenter = 1;
|
|
79
|
-
if (itemScrollXCenter < 0) itemScrollXCenter = 0;
|
|
107
|
+
const itemScrollXCenter = index / (items.length - 1);
|
|
80
108
|
|
|
81
|
-
return itemScrollXCenter;
|
|
109
|
+
return normalize(itemScrollXCenter, [0, 1], [0, 1]);
|
|
82
110
|
});
|
|
83
111
|
|
|
84
|
-
itemValues = assignRelativeIndexes(
|
|
112
|
+
const itemValues = assignRelativeIndexes(
|
|
113
|
+
itemsScrollXCenter,
|
|
114
|
+
scroll?.scrollProgress ?? 0,
|
|
115
|
+
).sort((a, b) => a.index - b.index);
|
|
116
|
+
// const visible =
|
|
117
|
+
// ((ref.current?.clientWidth ?? scrollVisible) - (outputRange[0] + gap)) /
|
|
118
|
+
// (outputRange[1] + gap);
|
|
85
119
|
|
|
86
|
-
|
|
87
|
-
((ref.current?.clientWidth ?? scrollVisible)
|
|
120
|
+
const visible =
|
|
121
|
+
((ref.current?.clientWidth ?? scrollVisible) + gap) /
|
|
88
122
|
(outputRange[1] + gap);
|
|
89
123
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
let widthContent = visible;
|
|
125
|
+
|
|
126
|
+
let selectedItem;
|
|
127
|
+
|
|
128
|
+
const visibleItemValues = itemValues
|
|
129
|
+
.sort((a, b) => Math.abs(a.relativeIndex) - Math.abs(b.relativeIndex))
|
|
130
|
+
.map((item, index) => {
|
|
131
|
+
if (!item) return;
|
|
132
|
+
|
|
133
|
+
if (index === 0) {
|
|
134
|
+
setSelectedItem(item.index);
|
|
135
|
+
selectedItem = item;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const el = itemRefs[item.index]?.current;
|
|
139
|
+
if (!ref.current || !el) return;
|
|
140
|
+
|
|
141
|
+
if (widthContent <= 0) {
|
|
142
|
+
return undefined;
|
|
143
|
+
} else {
|
|
144
|
+
item.width = outputRange[1];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
--widthContent;
|
|
148
|
+
return item;
|
|
149
|
+
})
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.sort((a, b) => a.index - b.index) as {
|
|
152
|
+
itemScrollXCenter: number;
|
|
153
|
+
relativeIndex: number;
|
|
154
|
+
index: number;
|
|
155
|
+
width: number;
|
|
156
|
+
}[];
|
|
157
|
+
|
|
158
|
+
let widthLeft = (ref.current?.clientWidth ?? scrollVisible) - gap;
|
|
159
|
+
|
|
160
|
+
const dynamicItems = visibleItemValues.filter((item, index, array) => {
|
|
161
|
+
let isDynamic = false;
|
|
162
|
+
if (item.width == outputRange[1]) {
|
|
163
|
+
if (index === 0 || index === array.length - 1) {
|
|
164
|
+
isDynamic = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (isDynamic) {
|
|
168
|
+
return true;
|
|
169
|
+
} else {
|
|
170
|
+
widthLeft -= item.width + gap;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// console.log(dynamicItems, visibleItemValues, visible);
|
|
176
|
+
|
|
177
|
+
let dynamicWidth = 0;
|
|
178
|
+
|
|
179
|
+
dynamicItems.forEach((item) => {
|
|
180
|
+
if (!item) return;
|
|
181
|
+
|
|
182
|
+
const result = normalize(
|
|
183
|
+
1 - Math.abs(scroll.scrollProgress - item.itemScrollXCenter),
|
|
184
|
+
[0, 1],
|
|
185
|
+
[0, 1],
|
|
186
|
+
);
|
|
187
|
+
item.width = result;
|
|
188
|
+
dynamicWidth += result;
|
|
189
|
+
});
|
|
100
190
|
|
|
101
|
-
|
|
191
|
+
// let widthLeft =
|
|
192
|
+
// (visible -
|
|
193
|
+
// visibleItemValues
|
|
194
|
+
// .slice(0, visibleItemValues.length - 2)
|
|
195
|
+
// .filter((item) => item.width === outputRange[1]).length) *
|
|
196
|
+
// outputRange[1];
|
|
197
|
+
|
|
198
|
+
// console.log(
|
|
199
|
+
// visible,
|
|
200
|
+
// widthLeft,
|
|
201
|
+
// visibleItemValues
|
|
202
|
+
// .slice(0, visibleItemValues.length - 2)
|
|
203
|
+
// .filter((item) => item.width === outputRange[1]).length,
|
|
204
|
+
// );
|
|
205
|
+
|
|
206
|
+
let translate = 0;
|
|
207
|
+
dynamicItems.forEach((item, index) => {
|
|
208
|
+
if (!item) return;
|
|
209
|
+
|
|
210
|
+
if (index == 0) {
|
|
211
|
+
const percent = normalize(
|
|
212
|
+
item?.relativeIndex,
|
|
213
|
+
[-2, item.index == 0 ? 0 : -1],
|
|
214
|
+
[0, 1],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (item.index >= 1) {
|
|
218
|
+
itemValues.sort((a, b) => a.index - b.index);
|
|
219
|
+
|
|
220
|
+
itemValues[item.index - 1].width = outputRange[0];
|
|
221
|
+
visibleItemValues.unshift(itemValues[item.index - 1]);
|
|
222
|
+
widthLeft -= outputRange[0] + gap;
|
|
223
|
+
|
|
224
|
+
translate = normalize(
|
|
225
|
+
1 - percent,
|
|
226
|
+
[0, 1],
|
|
227
|
+
[0, -(outputRange[0] + gap)],
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
widthLeft -= translate;
|
|
231
|
+
|
|
232
|
+
// let relative = selectedItem?.relativeIndex * 2;
|
|
233
|
+
// relative = relative > 0 ? (1 - relative) * -1 : 1 + relative;
|
|
234
|
+
|
|
235
|
+
item.width = normalize(
|
|
236
|
+
percent,
|
|
237
|
+
[0, 1],
|
|
238
|
+
[outputRange[0], outputRange[1]],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
widthLeft -= item.width;
|
|
242
|
+
|
|
243
|
+
// console.log(widthLeft);
|
|
244
|
+
} else {
|
|
245
|
+
let dynamicIndex = item.index;
|
|
246
|
+
// console.log('n', dynamicIndex, widthLeft);
|
|
247
|
+
let isEnd = dynamicIndex == itemValues.length - 1;
|
|
248
|
+
const isNearEnd = dynamicIndex == itemValues.length - 2;
|
|
249
|
+
// console.log('start');
|
|
250
|
+
while (widthLeft > 0) {
|
|
251
|
+
// console.log('boucle', widthLeft);
|
|
252
|
+
const dynamicItem = itemValues.filter(
|
|
253
|
+
(item) => item.index === dynamicIndex,
|
|
254
|
+
)[0];
|
|
255
|
+
|
|
256
|
+
if (!dynamicItem) {
|
|
257
|
+
if (isEnd) {
|
|
258
|
+
throw new Error('dynamicItem not found');
|
|
259
|
+
}
|
|
260
|
+
// dynamicIndex = dynamicItems[0].index;
|
|
261
|
+
isEnd = true;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!visibleItemValues.includes(dynamicItem)) {
|
|
266
|
+
visibleItemValues.push(dynamicItem);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
dynamicItem.width = normalize(
|
|
270
|
+
widthLeft,
|
|
271
|
+
[outputRange[0], outputRange[1] + (gap + outputRange[0]) * 2],
|
|
272
|
+
[outputRange[0], outputRange[1]],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
widthLeft -= dynamicItem.width + gap;
|
|
276
|
+
|
|
277
|
+
if (
|
|
278
|
+
(isNearEnd || isEnd) &&
|
|
279
|
+
dynamicItem.index == itemValues.length - 1
|
|
280
|
+
) {
|
|
281
|
+
let dynamicItemIndexStart = isEnd ? 1 : 1;
|
|
282
|
+
|
|
283
|
+
while (widthLeft > 0) {
|
|
284
|
+
const dynamicItemStart = visibleItemValues[dynamicItemIndexStart];
|
|
285
|
+
|
|
286
|
+
const width =
|
|
287
|
+
normalize(
|
|
288
|
+
dynamicItemStart.width + widthLeft,
|
|
289
|
+
[outputRange[0], outputRange[1]],
|
|
290
|
+
[outputRange[0], outputRange[1]],
|
|
291
|
+
) - dynamicItemStart.width;
|
|
292
|
+
|
|
293
|
+
dynamicItemStart.width += width;
|
|
294
|
+
widthLeft -= width;
|
|
295
|
+
|
|
296
|
+
dynamicItemIndexStart--;
|
|
297
|
+
// break;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
break;
|
|
301
|
+
} else {
|
|
302
|
+
dynamicIndex++;
|
|
303
|
+
}
|
|
304
|
+
// }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// console.log(item, dynamicWidth, visible, selectedItem);
|
|
309
|
+
|
|
310
|
+
// item.width = normalize(
|
|
311
|
+
// item.width / dynamicWidth,
|
|
312
|
+
// [0, 1],
|
|
313
|
+
// [0, visible * outputRange[1]],
|
|
314
|
+
// );
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
setTranslateX(translate);
|
|
318
|
+
|
|
319
|
+
return Object.fromEntries(
|
|
320
|
+
visibleItemValues.map((item) => [item.index, item.width]),
|
|
321
|
+
);
|
|
102
322
|
};
|
|
103
323
|
const itemRefs = useRef<React.RefObject<HTMLDivElement | null>[]>([]).current;
|
|
104
324
|
const [selectedItem, setSelectedItem] = useState(0);
|
|
@@ -107,6 +327,22 @@ export const Carousel = ({
|
|
|
107
327
|
if (onChange) onChange(selectedItem);
|
|
108
328
|
}, [selectedItem]);
|
|
109
329
|
|
|
330
|
+
// Sync controlled index prop to internal state/position
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (
|
|
333
|
+
typeof index === 'number' &&
|
|
334
|
+
items.length > 0 &&
|
|
335
|
+
index !== selectedItem
|
|
336
|
+
) {
|
|
337
|
+
centerOnIndex(index);
|
|
338
|
+
}
|
|
339
|
+
}, [index, items.length]);
|
|
340
|
+
|
|
341
|
+
// keep focused index aligned with selected when selection changes through scroll
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
setFocusedIndex(selectedItem);
|
|
344
|
+
}, [selectedItem]);
|
|
345
|
+
|
|
110
346
|
if (itemRefs.length !== items.length) {
|
|
111
347
|
items.forEach((_, i) => {
|
|
112
348
|
if (!itemRefs[i]) {
|
|
@@ -115,34 +351,87 @@ export const Carousel = ({
|
|
|
115
351
|
});
|
|
116
352
|
}
|
|
117
353
|
|
|
354
|
+
// accessibility and interaction states
|
|
355
|
+
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
356
|
+
|
|
357
|
+
const centerOnIndex = (index: number, opts: { animate?: boolean } = {}) => {
|
|
358
|
+
// Guard: need valid refs and at least one item
|
|
359
|
+
if (!items.length) return 0;
|
|
360
|
+
const itemRef = itemRefs[index];
|
|
361
|
+
if (!itemRef || !itemRef.current || !trackRef.current) return 0;
|
|
362
|
+
|
|
363
|
+
// Compute progress (0..1) for the target item center within the track
|
|
364
|
+
const itemScrollXCenter = normalize(
|
|
365
|
+
index / Math.max(1, items.length - 1),
|
|
366
|
+
[0, 1],
|
|
367
|
+
[0, 1],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Update selection/focus hint
|
|
371
|
+
setFocusedIndex(index);
|
|
372
|
+
|
|
373
|
+
// Ask CustomScroll to move to the computed progress. This will trigger onScroll,
|
|
374
|
+
// which in turn drives the smoothed animation via handleScroll().
|
|
375
|
+
const track = trackRef.current as HTMLElement;
|
|
376
|
+
track.dispatchEvent(
|
|
377
|
+
new CustomEvent('udx:customScroll:set', {
|
|
378
|
+
bubbles: true,
|
|
379
|
+
detail: {
|
|
380
|
+
progress: itemScrollXCenter,
|
|
381
|
+
orientation: 'horizontal',
|
|
382
|
+
animate: opts.animate !== false,
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return itemScrollXCenter;
|
|
388
|
+
};
|
|
389
|
+
|
|
118
390
|
const renderItems = items.map((child, index) => {
|
|
391
|
+
const existingOnClick = (child as any).props?.onClick as
|
|
392
|
+
| ((e: any) => void)
|
|
393
|
+
| undefined;
|
|
394
|
+
const handleClick = (e: any) => {
|
|
395
|
+
existingOnClick?.(e);
|
|
396
|
+
// centerOnIndex(index);
|
|
397
|
+
};
|
|
398
|
+
const handleFocus = () => setFocusedIndex(index);
|
|
399
|
+
|
|
119
400
|
return React.cloneElement(
|
|
120
401
|
child as React.ReactElement<ReactProps<CarouselItemInterface>>,
|
|
121
402
|
{
|
|
122
403
|
width: itemsWidth[index],
|
|
404
|
+
outputRange,
|
|
123
405
|
ref: itemRefs[index],
|
|
124
406
|
key: index,
|
|
125
407
|
index,
|
|
126
|
-
|
|
408
|
+
role: 'option',
|
|
409
|
+
'aria-selected': selectedItem === index,
|
|
410
|
+
tabIndex: selectedItem === index ? 0 : -1,
|
|
411
|
+
onClick: handleClick,
|
|
412
|
+
onFocus: handleFocus,
|
|
413
|
+
} as any,
|
|
127
414
|
);
|
|
128
415
|
});
|
|
129
416
|
|
|
130
|
-
|
|
417
|
+
// persistent motion value for scroll progress, driven by user scroll and programmatic centering
|
|
418
|
+
// const scrollMVRef = useRef(motionValue(0));
|
|
419
|
+
// const scrollMV = scrollMVRef.current;
|
|
131
420
|
|
|
132
|
-
const transform = useTransform(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
);
|
|
421
|
+
// const transform = useTransform(
|
|
422
|
+
// scrollMV,
|
|
423
|
+
// [0, 1],
|
|
424
|
+
// [
|
|
425
|
+
// 0,
|
|
426
|
+
// 1 -
|
|
427
|
+
// (ref.current?.clientWidth ?? 0) / (trackRef?.current?.clientWidth ?? 0),
|
|
428
|
+
// ],
|
|
429
|
+
// );
|
|
141
430
|
|
|
142
|
-
const percentTransform = useTransform(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
431
|
+
// const percentTransform = useTransform(
|
|
432
|
+
// transform,
|
|
433
|
+
// (value) => `${-value * 100}%`,
|
|
434
|
+
// );
|
|
146
435
|
|
|
147
436
|
const handleScroll = (args: {
|
|
148
437
|
scrollProgress: number;
|
|
@@ -151,7 +440,24 @@ export const Carousel = ({
|
|
|
151
440
|
scroll: number;
|
|
152
441
|
}) => {
|
|
153
442
|
if (args.scrollTotal > 0) {
|
|
154
|
-
|
|
443
|
+
// Smooth and inertial transition of scrollProgress using framer-motion animate()
|
|
444
|
+
// Stop any previous animation to avoid stacking
|
|
445
|
+
scrollAnimationRef.current?.stop();
|
|
446
|
+
const from = smoothedProgressRef.current ?? 0;
|
|
447
|
+
const to = args.scrollProgress ?? 0;
|
|
448
|
+
|
|
449
|
+
scrollAnimationRef.current = animate(from, to, {
|
|
450
|
+
// Spring tuning to add a bit of inertia and smoothness
|
|
451
|
+
type: 'spring',
|
|
452
|
+
stiffness: 260,
|
|
453
|
+
damping: 32,
|
|
454
|
+
mass: 0.6,
|
|
455
|
+
restDelta: 0.0005,
|
|
456
|
+
onUpdate: (v) => {
|
|
457
|
+
smoothedProgressRef.current = v;
|
|
458
|
+
setScroll({ ...args, scrollProgress: v });
|
|
459
|
+
},
|
|
460
|
+
});
|
|
155
461
|
}
|
|
156
462
|
};
|
|
157
463
|
|
|
@@ -160,6 +466,100 @@ export const Carousel = ({
|
|
|
160
466
|
setItemsWidth(updatedPercentages);
|
|
161
467
|
}, [scroll]);
|
|
162
468
|
|
|
469
|
+
// Keep latest onMetricsChange in a ref to avoid effect dependency loops
|
|
470
|
+
const onMetricsChangeRef = useRef(onMetricsChange);
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
onMetricsChangeRef.current = onMetricsChange;
|
|
473
|
+
}, [onMetricsChange]);
|
|
474
|
+
|
|
475
|
+
// Cache last emitted metrics to prevent redundant calls
|
|
476
|
+
const lastMetricsRef = useRef<any>(null);
|
|
477
|
+
|
|
478
|
+
// Compute and emit live metrics for external control
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
const cb = onMetricsChangeRef.current;
|
|
481
|
+
if (!cb) return;
|
|
482
|
+
if (!ref?.current) return;
|
|
483
|
+
const total = items.length;
|
|
484
|
+
if (total <= 0) return;
|
|
485
|
+
const viewportWidth = (ref.current as any)?.clientWidth ?? 0;
|
|
486
|
+
const itemMaxWidth = outputRange[1];
|
|
487
|
+
const sProgress =
|
|
488
|
+
smoothedProgressRef.current ?? scroll?.scrollProgress ?? 0;
|
|
489
|
+
const visibleApprox = (viewportWidth + gap) / (itemMaxWidth + gap);
|
|
490
|
+
const visibleFull = Math.max(1, Math.floor(visibleApprox));
|
|
491
|
+
const stepHalf = Math.max(1, Math.round(visibleFull * (2 / 3)));
|
|
492
|
+
const selectedIndexSafe = Math.min(
|
|
493
|
+
Math.max(0, selectedItem),
|
|
494
|
+
Math.max(0, total - 1),
|
|
495
|
+
);
|
|
496
|
+
const canPrev = selectedIndexSafe > 0;
|
|
497
|
+
const canNext = selectedIndexSafe < total - 1;
|
|
498
|
+
|
|
499
|
+
const metrics = {
|
|
500
|
+
total,
|
|
501
|
+
selectedIndex: selectedIndexSafe,
|
|
502
|
+
visibleApprox,
|
|
503
|
+
visibleFull,
|
|
504
|
+
stepHalf,
|
|
505
|
+
canPrev,
|
|
506
|
+
canNext,
|
|
507
|
+
scrollProgress: sProgress,
|
|
508
|
+
viewportWidth,
|
|
509
|
+
itemMaxWidth,
|
|
510
|
+
gap,
|
|
511
|
+
} as any;
|
|
512
|
+
|
|
513
|
+
// Shallow compare with last metrics to avoid spamming parent and loops
|
|
514
|
+
const last = lastMetricsRef.current;
|
|
515
|
+
let changed = !last;
|
|
516
|
+
if (!changed) {
|
|
517
|
+
for (const k in metrics) {
|
|
518
|
+
if (metrics[k] !== last[k]) {
|
|
519
|
+
changed = true;
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (changed) {
|
|
526
|
+
lastMetricsRef.current = metrics;
|
|
527
|
+
cb(metrics);
|
|
528
|
+
}
|
|
529
|
+
}, [ref, items.length, selectedItem, scroll, gap, outputRange]);
|
|
530
|
+
|
|
531
|
+
// // Recalculate on scrollMV changes (e.g., programmatic animations)
|
|
532
|
+
// useEffect(() => {
|
|
533
|
+
// const unsubscribe = scrollMV.on('change', (p) => {
|
|
534
|
+
// // Keep CustomScroll container in sync by dispatching a bubbling control event
|
|
535
|
+
// const track = trackRef.current as HTMLElement | null;
|
|
536
|
+
// if (track) {
|
|
537
|
+
// track.dispatchEvent(
|
|
538
|
+
// new CustomEvent('udx:customScroll:set', {
|
|
539
|
+
// bubbles: true,
|
|
540
|
+
// detail: { progress: p, orientation: 'horizontal' },
|
|
541
|
+
// }),
|
|
542
|
+
// );
|
|
543
|
+
// }
|
|
544
|
+
// const updated = calculatePercentages();
|
|
545
|
+
// if (updated.length) setItemsWidth(updated);
|
|
546
|
+
// });
|
|
547
|
+
// return () => unsubscribe();
|
|
548
|
+
// }, [scrollMV, trackRef]);
|
|
549
|
+
|
|
550
|
+
// Initial compute on mount and when items count changes
|
|
551
|
+
// useLayoutEffect(() => {
|
|
552
|
+
// const updated = calculatePercentages();
|
|
553
|
+
// if (updated.length) setItemsWidth(updated);
|
|
554
|
+
// }, [items.length]);
|
|
555
|
+
|
|
556
|
+
// Cleanup any pending animation on unmount
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
return () => {
|
|
559
|
+
scrollAnimationRef.current?.stop();
|
|
560
|
+
};
|
|
561
|
+
}, []);
|
|
562
|
+
|
|
163
563
|
const [scrollSize, setScrollSize] = useState(0);
|
|
164
564
|
useLayoutEffect(() => {
|
|
165
565
|
let maxWidth = outputRange[1];
|
|
@@ -170,26 +570,109 @@ export const Carousel = ({
|
|
|
170
570
|
setScrollSize(result);
|
|
171
571
|
}, [ref, itemRefs, scroll]);
|
|
172
572
|
|
|
573
|
+
// Recompute sizes on container/track resize
|
|
574
|
+
// useEffect(() => {
|
|
575
|
+
// const root = ref.current as unknown as HTMLElement | null;
|
|
576
|
+
// const track = trackRef.current as unknown as HTMLElement | null;
|
|
577
|
+
// if (!root || !track) return;
|
|
578
|
+
// const ro = new ResizeObserver(() => {
|
|
579
|
+
// const updated = calculatePercentages();
|
|
580
|
+
// if (updated.length) setItemsWidth(updated);
|
|
581
|
+
// let maxWidth = outputRange[1];
|
|
582
|
+
// const visible = scroll?.scrollVisible ?? root.clientWidth;
|
|
583
|
+
// if (maxWidth > visible) maxWidth = visible;
|
|
584
|
+
// const result =
|
|
585
|
+
// ((maxWidth + gap) * renderItems.length) / scrollSensitivity;
|
|
586
|
+
// setScrollSize(result);
|
|
587
|
+
// });
|
|
588
|
+
// ro.observe(root);
|
|
589
|
+
// ro.observe(track);
|
|
590
|
+
// return () => ro.disconnect();
|
|
591
|
+
// }, [
|
|
592
|
+
// ref,
|
|
593
|
+
// trackRef,
|
|
594
|
+
// renderItems.length,
|
|
595
|
+
// gap,
|
|
596
|
+
// outputRange,
|
|
597
|
+
// scrollSensitivity,
|
|
598
|
+
// scroll,
|
|
599
|
+
// ]);
|
|
600
|
+
|
|
601
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
602
|
+
if (!items.length) return;
|
|
603
|
+
const idx = focusedIndex ?? selectedItem;
|
|
604
|
+
switch (e.key) {
|
|
605
|
+
case 'ArrowLeft':
|
|
606
|
+
e.preventDefault();
|
|
607
|
+
centerOnIndex(Math.max(0, idx - 1));
|
|
608
|
+
break;
|
|
609
|
+
case 'ArrowRight':
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
centerOnIndex(Math.min(items.length - 1, idx + 1));
|
|
612
|
+
break;
|
|
613
|
+
case 'Home':
|
|
614
|
+
e.preventDefault();
|
|
615
|
+
centerOnIndex(0);
|
|
616
|
+
break;
|
|
617
|
+
case 'End':
|
|
618
|
+
e.preventDefault();
|
|
619
|
+
centerOnIndex(items.length - 1);
|
|
620
|
+
break;
|
|
621
|
+
case 'Enter':
|
|
622
|
+
case ' ': // Space
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
centerOnIndex(idx);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// External control via CustomEvent on root element
|
|
630
|
+
useEffect(() => {
|
|
631
|
+
const root = ref.current as any;
|
|
632
|
+
if (!root) return;
|
|
633
|
+
const handler = (ev: Event) => {
|
|
634
|
+
const detail = (ev as CustomEvent).detail as
|
|
635
|
+
| { index?: number }
|
|
636
|
+
| undefined;
|
|
637
|
+
if (detail && typeof detail.index === 'number') {
|
|
638
|
+
centerOnIndex(detail.index);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
root.addEventListener('udx:carousel:centerIndex', handler as EventListener);
|
|
642
|
+
return () => {
|
|
643
|
+
root.removeEventListener(
|
|
644
|
+
'udx:carousel:centerIndex',
|
|
645
|
+
handler as EventListener,
|
|
646
|
+
);
|
|
647
|
+
};
|
|
648
|
+
}, [ref, items.length]);
|
|
649
|
+
|
|
173
650
|
return (
|
|
174
|
-
<div
|
|
651
|
+
<div
|
|
652
|
+
className={styles.carousel}
|
|
653
|
+
ref={ref}
|
|
654
|
+
role="listbox"
|
|
655
|
+
aria-orientation="horizontal"
|
|
656
|
+
onKeyDown={handleKeyDown}
|
|
657
|
+
{...restProps}
|
|
658
|
+
>
|
|
175
659
|
<CustomScroll
|
|
176
660
|
draggable
|
|
177
661
|
orientation={'horizontal'}
|
|
178
662
|
onScroll={handleScroll}
|
|
179
663
|
scrollSize={scrollSize}
|
|
180
664
|
>
|
|
181
|
-
<
|
|
665
|
+
<div
|
|
182
666
|
className={styles.track}
|
|
183
667
|
ref={trackRef}
|
|
184
668
|
style={{
|
|
185
|
-
transitionDuration: '0.5s',
|
|
186
|
-
transitionTimingFunction: 'ease-out',
|
|
187
669
|
gap: `${gap}px`,
|
|
188
|
-
|
|
670
|
+
translate: translateX,
|
|
671
|
+
willChange: 'translate',
|
|
189
672
|
}}
|
|
190
673
|
>
|
|
191
674
|
{renderItems}
|
|
192
|
-
</
|
|
675
|
+
</div>
|
|
193
676
|
</CustomScroll>
|
|
194
677
|
</div>
|
|
195
678
|
);
|