@udixio/ui-react 2.4.3 → 2.5.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.js +1745 -1460
  4. package/dist/lib/components/Carousel.d.ts +7 -2
  5. package/dist/lib/components/Carousel.d.ts.map +1 -1
  6. package/dist/lib/components/CarouselItem.d.ts +1 -1
  7. package/dist/lib/components/CarouselItem.d.ts.map +1 -1
  8. package/dist/lib/components/Slider.d.ts.map +1 -1
  9. package/dist/lib/effects/AnimateOnScroll.d.ts.map +1 -1
  10. package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts +1 -1
  11. package/dist/lib/effects/custom-scroll/custom-scroll.effect.d.ts.map +1 -1
  12. package/dist/lib/effects/custom-scroll/custom-scroll.interface.d.ts +2 -0
  13. package/dist/lib/effects/custom-scroll/custom-scroll.interface.d.ts.map +1 -1
  14. package/dist/lib/effects/custom-scroll/custom-scroll.style.d.ts +4 -0
  15. package/dist/lib/effects/custom-scroll/custom-scroll.style.d.ts.map +1 -1
  16. package/dist/lib/interfaces/carousel-item.interface.d.ts +1 -0
  17. package/dist/lib/interfaces/carousel-item.interface.d.ts.map +1 -1
  18. package/dist/lib/interfaces/carousel.interface.d.ts +19 -1
  19. package/dist/lib/interfaces/carousel.interface.d.ts.map +1 -1
  20. package/dist/lib/styles/carousel-item.style.d.ts +2 -0
  21. package/dist/lib/styles/carousel-item.style.d.ts.map +1 -1
  22. package/dist/lib/styles/carousel.style.d.ts +5 -1
  23. package/dist/lib/styles/carousel.style.d.ts.map +1 -1
  24. package/dist/lib/styles/icon-button.style.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/lib/components/Carousel.tsx +535 -52
  27. package/src/lib/components/CarouselItem.tsx +12 -6
  28. package/src/lib/components/Slider.tsx +25 -28
  29. package/src/lib/effects/AnimateOnScroll.ts +41 -0
  30. package/src/lib/effects/custom-scroll/custom-scroll.effect.tsx +111 -2
  31. package/src/lib/effects/custom-scroll/custom-scroll.interface.ts +4 -0
  32. package/src/lib/interfaces/carousel-item.interface.ts +1 -0
  33. package/src/lib/interfaces/carousel.interface.ts +20 -1
  34. package/src/lib/styles/carousel-item.style.ts +5 -2
  35. package/src/lib/styles/carousel.style.ts +1 -3
  36. package/src/lib/styles/icon-button.style.ts +5 -15
  37. 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
- * Resources
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 (!trackRef.current || !ref.current || !scroll) return [];
73
+ if (
74
+ !trackRef.current ||
75
+ !ref.current ||
76
+ scroll?.scrollProgress === undefined
77
+ )
78
+ return [];
59
79
 
60
- const { scrollVisible, scrollProgress } = scroll;
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
- ): number[] {
66
- return values.map(
67
- (value) => (value - progressScroll) / Math.abs(values[1] - values[0]),
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
- let itemValues = items.map((_, index) => {
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
- let itemScrollXCenter = index / (items.length - 1);
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(itemValues, scrollProgress);
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
- let visible =
87
- ((ref.current?.clientWidth ?? scrollVisible) - (outputRange[0] + gap)) /
120
+ const visible =
121
+ ((ref.current?.clientWidth ?? scrollVisible) + gap) /
88
122
  (outputRange[1] + gap);
89
123
 
90
- itemValues
91
- .map((value, index) => ({ value: Math.abs(value), originalIndex: index })) // Associer chaque élément à son index
92
- .sort((a, b) => a.value - b.value)
93
- .forEach((item, index) => {
94
- if (index === 0) setSelectedItem(item.originalIndex);
95
- let result = normalize(visible, [0, 1], [0, outputRange[1]]);
96
- if (result < outputRange[0]) result = outputRange[0];
97
- visible--;
98
- itemValues[item.originalIndex] = result;
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
- return itemValues;
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
- const scrollProgress = motionValue(scroll?.scrollProgress ?? 0);
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
- scrollProgress,
134
- [0, 1],
135
- [
136
- 0,
137
- 1 -
138
- (ref.current?.clientWidth ?? 0) / (trackRef?.current?.clientWidth ?? 0),
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
- transform,
144
- (value) => `${-value * 100}%`,
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
- setScroll(args);
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 className={styles.carousel} ref={ref} {...restProps}>
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
- <motion.div
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
- x: percentTransform,
670
+ translate: translateX,
671
+ willChange: 'translate',
189
672
  }}
190
673
  >
191
674
  {renderItems}
192
- </motion.div>
675
+ </div>
193
676
  </CustomScroll>
194
677
  </div>
195
678
  );