@windstream/react-shared-components 0.1.27 → 0.1.28

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.
@@ -1,5 +1,3 @@
1
- "use client";
2
-
3
1
  import React, { useCallback, useEffect, useRef, useState } from "react";
4
2
  import {
5
3
  CarouselWithProductCards,
@@ -11,6 +9,7 @@ import { Button } from "@shared/components/button";
11
9
  import { MaterialIcon } from "@shared/components/material-icon";
12
10
  import { ProductCard } from "@shared/contentful/blocks/cards/product-card";
13
11
  import { TestimonialCard } from "@shared/contentful/blocks/cards/testimonial-card";
12
+ import { useCarouselSwipe } from "@shared/hooks/use-carousel-swipe";
14
13
  import { CheckPlansProps } from "@shared/types/micro-components";
15
14
  import { cx } from "@shared/utils";
16
15
 
@@ -212,109 +211,166 @@ export function ProductCardCarousel({
212
211
  );
213
212
  }
214
213
 
215
- export const TestimonialCarousel: React.FC<{
216
- fields: CarouselWithTestimonialCards;
217
- }> = ({ fields }) => {
218
- const testimonials = fields?.items?.items || [];
219
- const [currentIndex, setCurrentIndex] = useState(0);
220
- const timeoutRef = useRef<ReturnType<typeof setInterval> | null>(null);
221
-
222
- const nextSlide = useCallback(() => {
223
- if (testimonials.length === 0) return;
224
- setCurrentIndex(prev => (prev + 1) % testimonials.length);
225
- }, [testimonials.length]);
214
+ /**
215
+ * Individual slide component for the testimonial carousel
216
+ * Memoized to prevent unnecessary re-renders of inactive slides
217
+ */
218
+ const TestimonialCarouselSlide = React.memo<{
219
+ item: any;
220
+ index: number;
221
+ currentIndex: number;
222
+ totalItems: number;
223
+ swipeOffset: number;
224
+ isSwiping: boolean;
225
+ isMobile: boolean;
226
+ containerWidth: number;
227
+ cardOffsetPercentage: number;
228
+ }>(
229
+ ({
230
+ item,
231
+ index,
232
+ currentIndex,
233
+ totalItems,
234
+ swipeOffset,
235
+ isSwiping,
236
+ isMobile,
237
+ containerWidth,
238
+ cardOffsetPercentage,
239
+ }) => {
240
+ // Calculate circular offset for infinite carousel feel
241
+ let offset = index - currentIndex;
242
+ const len = totalItems;
243
+ if (offset > len / 2) offset -= len;
244
+ if (offset < -len / 2) offset += len;
245
+
246
+ const isActive = offset === 0;
247
+ const isAdjacent = Math.abs(offset) === 1;
248
+
249
+ // Calculate card position with smooth swipe following
250
+ // Base position is determined by card index offset (105% spacing between cards)
251
+ const baseTransform = offset * cardOffsetPercentage;
252
+ // Convert swipe pixels to percentage for smooth finger-following during swipe
253
+ const swipePercentage = (swipeOffset / containerWidth) * 100;
254
+ const totalTransform = baseTransform + swipePercentage;
255
+
256
+ // Mobile: Show adjacent cards during swipe for preview effect
257
+ // Desktop: Cards always visible (handled by opacity check below)
258
+ const showOnMobile = isSwiping ? isActive || isAdjacent : isActive;
259
+
260
+ // Determine opacity visibility (mobile-specific behavior)
261
+ const isVisible = isMobile ? showOnMobile : true;
226
262
 
227
- const prevSlide = useCallback(() => {
228
- if (testimonials.length === 0) return;
229
- setCurrentIndex(prev => (prev === 0 ? testimonials.length - 1 : prev - 1));
230
- }, [testimonials.length]);
231
-
232
- // Auto-scroll logic
233
- useEffect(() => {
234
- if (testimonials.length === 0) return;
263
+ return (
264
+ <div
265
+ className={cx(
266
+ // Layout
267
+ "col-start-1 row-start-1 w-full md:max-w-[815px]",
268
+ // Performance: Hint browser to optimize transform and opacity animations
269
+ "will-change-[transform,opacity]",
270
+ // Z-index: Active slide on top
271
+ isActive ? "z-10" : "z-5",
272
+ // Visibility: Hide slides that are too far away (performance optimization)
273
+ Math.abs(offset) > 1 ? "invisible" : "visible",
274
+ // Opacity: Mobile fade effect for adjacent cards, desktop always visible
275
+ isVisible ? "opacity-100" : "opacity-0",
276
+ // Transitions: Faster during swipe, slower when snapping to position
277
+ isSwiping
278
+ ? "transition-opacity duration-200"
279
+ : "transition-[transform,opacity] duration-500 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
280
+ )}
281
+ style={{
282
+ // Use CSS variable for dynamic transform value (can't be done with Tailwind)
283
+ transform: `translateX(${totalTransform}%)`,
284
+ }}
285
+ >
286
+ <TestimonialCard {...item} isActive={isActive} className="h-full" />
287
+ </div>
288
+ );
289
+ }
290
+ );
235
291
 
236
- timeoutRef.current = setInterval(() => {
237
- nextSlide();
238
- }, 8000); // 5 seconds per slide
292
+ TestimonialCarouselSlide.displayName = "TestimonialCarouselSlide";
239
293
 
240
- return () => {
241
- if (timeoutRef.current) clearInterval(timeoutRef.current);
242
- };
243
- }, [nextSlide, testimonials.length]);
294
+ export const TestimonialCarousel: React.FC<{
295
+ fields: CarouselWithTestimonialCards;
296
+ autoScroll?: boolean;
297
+ autoScrollInterval?: number;
298
+ }> = ({ fields, autoScroll = true, autoScrollInterval = 8000 }) => {
299
+ const testimonials = fields?.items?.items || [];
244
300
 
245
301
  if (!testimonials || testimonials.length === 0) {
246
302
  return null;
247
303
  }
248
304
 
305
+ // Use the generic carousel swipe hook
306
+ const carousel = useCarouselSwipe({
307
+ itemCount: testimonials.length,
308
+ cardOffsetPercentage: 105, // How far each card is offset (100% width + 5% gap)
309
+ swipeThreshold: 0.15, // Need to swipe 15% of screen to change slide
310
+ mobileBreakpoint: 768, // Tailwind's md breakpoint, Mobile if width < 768px
311
+ autoScrollInterval: autoScrollInterval, // 8 seconds between slides transition
312
+ enableAutoScroll: autoScroll,
313
+ });
314
+
249
315
  return (
250
- <div className="relative mx-auto max-w-[1280px] overflow-hidden md:px-24">
251
- {/* Navigation Overlay - Left (Hidden on mobile) */}
252
- <Button
253
- onClick={prevSlide}
254
- className="group absolute left-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
255
- aria-label="Previous"
256
- >
257
- <MaterialIcon name="arrow_back" size={24} />
258
- </Button>
259
-
260
- {/* Slider Track Wrapper */}
261
- <div className="grid grid-cols-1 justify-items-center">
262
- {/* Slides */}
263
- {testimonials.map((item, index) => {
264
- let offset = index - currentIndex;
265
- const len = testimonials.length;
266
- if (offset > len / 2) offset -= len;
267
- if (offset < -len / 2) offset += len;
268
-
269
- const isActive = offset === 0;
270
-
271
- return (
272
- <div
273
- key={index}
274
- className={cx(
275
- "col-start-1 row-start-1 w-full md:max-w-[815px]",
276
- // Mobile: only show active card
277
- isActive ? "opacity-100" : "opacity-0",
278
- // Desktop: show active and peeking cards
279
- "md:opacity-100"
280
- )}
281
- style={{
282
- transform: `translateX(${isActive ? "0%" : offset * 105 + "%"})`,
283
- zIndex: isActive ? 10 : 5,
284
- visibility: Math.abs(offset) > 1 ? "hidden" : "visible",
285
- transition: "all 0.5s ease-in-out",
286
- }}
287
- >
288
- <TestimonialCard
289
- {...item}
290
- isActive={isActive}
291
- className="h-full"
316
+ <div>
317
+ <div className="relative max-w-[1280px] md:px-11">
318
+ {/* Navigation Overlay - Left (Hidden on mobile) */}
319
+ <Button
320
+ onClick={carousel.prevSlide}
321
+ className="group absolute left-5 top-1/2 z-50 hidden h-13 w-13 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
322
+ aria-label="Previous"
323
+ >
324
+ <MaterialIcon name="arrow_back" size={24} />
325
+ </Button>
326
+
327
+ {/* Navigation Overlay - Right (Hidden on mobile) */}
328
+ <Button
329
+ onClick={carousel.nextSlide}
330
+ className="group absolute right-5 top-1/2 z-50 hidden h-13 w-13 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
331
+ aria-label="Next"
332
+ >
333
+ <MaterialIcon name="arrow_forward" size={24} />
334
+ </Button>
335
+
336
+ {/* Slider Track Wrapper */}
337
+ <div
338
+ ref={carousel.containerRef}
339
+ className="select-none overflow-hidden rounded-card-sm will-change-transform"
340
+ onTouchStart={carousel.handleTouchStart}
341
+ onTouchMove={carousel.handleTouchMove}
342
+ onTouchEnd={carousel.handleTouchEnd}
343
+ >
344
+ <div className="grid grid-cols-1 justify-items-center">
345
+ {/* Slides */}
346
+ {testimonials.map((item, index) => (
347
+ <TestimonialCarouselSlide
348
+ key={index}
349
+ item={item}
350
+ index={index}
351
+ currentIndex={carousel.currentIndex}
352
+ totalItems={testimonials.length}
353
+ swipeOffset={carousel.swipeOffset}
354
+ isSwiping={carousel.isSwiping}
355
+ isMobile={carousel.isMobile}
356
+ containerWidth={carousel.containerWidth}
357
+ cardOffsetPercentage={carousel.constants.CARD_OFFSET_PERCENTAGE}
292
358
  />
293
- </div>
294
- );
295
- })}
359
+ ))}
360
+ </div>
361
+ </div>
296
362
  </div>
297
-
298
- {/* Navigation Overlay - Right (Hidden on mobile) */}
299
- <Button
300
- onClick={nextSlide}
301
- className="group absolute right-4 top-1/2 z-20 hidden h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white p-2 text-text shadow-md transition-colors hover:bg-gray-100 md:flex"
302
- aria-label="Next"
303
- >
304
- <MaterialIcon name="arrow_forward" size={24} />
305
- </Button>
306
-
307
363
  {/* Dots (Hidden on mobile) */}
308
- <div className="mt-8 hidden justify-center gap-2 md:flex">
364
+ <div className="mt-5 flex justify-center gap-2 md:mt-10">
309
365
  {testimonials.map((_, idx) => (
310
366
  <button
311
367
  key={idx}
312
- onClick={() => setCurrentIndex(idx)}
368
+ onClick={() => carousel.goToSlide(idx)}
313
369
  className={cx(
314
370
  "h-2 w-2 rounded-full transition-all duration-300",
315
- currentIndex === idx
371
+ carousel.currentIndex === idx
316
372
  ? "w-6 bg-white"
317
- : "bg-white/40 hover:bg-white/60"
373
+ : "bg-bg-fill-inverse-disabled hover:bg-white/60"
318
374
  )}
319
375
  aria-label={`Go to slide ${idx + 1}`}
320
376
  />
@@ -330,27 +386,55 @@ export const TabSwitch: React.FC<TabSwitchProps> = ({
330
386
  onChange,
331
387
  className,
332
388
  }) => {
389
+ const activeIndex = tabs.indexOf(activeTab);
390
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
391
+ const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 });
392
+
393
+ useEffect(() => {
394
+ const activeButton = buttonRefs.current[activeIndex];
395
+ if (activeButton) {
396
+ setIndicatorStyle({
397
+ width: activeButton.offsetWidth,
398
+ left: activeButton.offsetLeft,
399
+ });
400
+ }
401
+ }, [activeIndex, tabs]);
402
+
333
403
  return (
334
- <div
335
- className={cx(
336
- "mx-auto flex w-fit rounded-2xl bg-bg-surface-active p-1",
337
- className
338
- )}
339
- >
340
- {tabs.map(tab => (
341
- <button
342
- key={tab}
343
- onClick={() => onChange(tab)}
344
- className={cx(
345
- "min-w-[160px] whitespace-nowrap rounded-2xl bg-bg-surface-active px-4 py-2.5 text-label1 font-black transition-all duration-300",
346
- activeTab === tab
347
- ? "bg-bg-fill-brand text-white shadow-md"
348
- : "text-text-info hover:bg-bg-surface-hover"
349
- )}
350
- >
351
- {tab}
352
- </button>
353
- ))}
404
+ <div className="flex w-full justify-center">
405
+ <div
406
+ className={cx(
407
+ "relative flex w-fit gap-1 rounded-button-lg bg-bg-surface-active p-1 transition-all duration-200 ease-out",
408
+ className
409
+ )}
410
+ >
411
+ {/* Sliding background indicator */}
412
+ <div
413
+ className="absolute bottom-1 top-1 rounded-button bg-bg-fill-brand shadow-md transition-all duration-300 ease-out"
414
+ style={{
415
+ width: `${indicatorStyle.width}px`,
416
+ left: `${indicatorStyle.left}px`,
417
+ }}
418
+ />
419
+
420
+ {tabs.map((tab, index) => (
421
+ <button
422
+ key={tab}
423
+ ref={el => {
424
+ buttonRefs.current[index] = el;
425
+ }}
426
+ onClick={() => onChange(tab)}
427
+ className={cx(
428
+ "label1 relative z-10 flex min-w-[160px] items-center justify-center whitespace-nowrap rounded-button px-4 py-2.5 transition-colors duration-200 focus:outline-none active:bg-transparent",
429
+ activeTab === tab
430
+ ? "text-text-inverse"
431
+ : "text-text-disabled hover:bg-bg-surface-hover"
432
+ )}
433
+ >
434
+ {tab}
435
+ </button>
436
+ ))}
437
+ </div>
354
438
  </div>
355
439
  );
356
440
  };
@@ -1,5 +1,3 @@
1
- "use client";
2
-
3
1
  import React from "react";
4
2
  import { TabSwitch } from "../carousel/helper";
5
3
  import { ProductCardCarousel, TestimonialCarousel } from "./helper";
@@ -22,22 +20,23 @@ export const Carousel: React.FC<CarouselProps> = ({
22
20
  tabs,
23
21
  onModalButtonClick,
24
22
  renderCheckPlans,
23
+ testimonialAutoScroll = true,
25
24
  showSwitch = false,
26
25
  }) => {
27
26
  return (
28
27
  <div
29
- className={`${backgroundColor} mx-auto overflow-hidden py-9 md:px-5 md:py-20`}
28
+ className={`${backgroundColor} mx-auto overflow-hidden px-5 py-12 md:pb-16 md:pt-20`}
30
29
  >
31
- <div className="relative mx-auto flex max-w-[1280px] flex-col gap-12 overflow-visible">
30
+ <div className="relative mx-auto flex max-w-[1280px] flex-col gap-8 overflow-visible md:gap-12">
32
31
  <div>
33
32
  <Text
34
33
  as="h2"
35
- className="text-center text-4xl font-bold text-text md:text-5xl"
34
+ className="heading2 text-text md:heading1 md:text-center"
36
35
  >
37
36
  {fields?.title}
38
37
  </Text>
39
38
  {fields?.subTitle && (
40
- <Text as="h3" className="body1 px-4 pt-4 text-center">
39
+ <Text as="h3" className="body1 px-4 pt-4 md:text-center">
41
40
  {fields?.subTitle}
42
41
  </Text>
43
42
  )}
@@ -53,6 +52,8 @@ export const Carousel: React.FC<CarouselProps> = ({
53
52
  {hasTestimonialCards && (
54
53
  <TestimonialCarousel
55
54
  fields={fields as CarouselWithTestimonialCards}
55
+ autoScroll={testimonialAutoScroll}
56
+ autoScrollInterval={8000}
56
57
  />
57
58
  )}
58
59
  {hasProductCards && (
@@ -62,9 +63,14 @@ export const Carousel: React.FC<CarouselProps> = ({
62
63
  fields={fields as CarouselWithProductCards}
63
64
  />
64
65
  )}
65
- <Text as="div" className="body1 mt-8 px-4 text-center text-neutral-600">
66
- {disclaimerText}
67
- </Text>
66
+ {disclaimerText && (
67
+ <Text
68
+ as="div"
69
+ className="body1 mt-8 px-4 text-center text-neutral-600"
70
+ >
71
+ {disclaimerText}
72
+ </Text>
73
+ )}
68
74
  </div>
69
75
  </div>
70
76
  );
@@ -129,6 +129,7 @@ export type CarouselProps = {
129
129
  setActiveTab: (tab: string) => void;
130
130
  tabs: string[];
131
131
  showSwitch?: boolean;
132
+ testimonialAutoScroll?: boolean;
132
133
  onModalButtonClick?: (id?: string) => void;
133
134
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
134
135
  };
@@ -0,0 +1,264 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+
9
+ /**
10
+ * Configuration options for the carousel swipe behavior
11
+ */
12
+ export interface CarouselSwipeConfig {
13
+ /** Total number of items in the carousel */
14
+ itemCount: number;
15
+ /** Percentage of card width to offset each slide (default: 105) */
16
+ cardOffsetPercentage?: number;
17
+ /** Percentage of container width needed to trigger slide change (default: 0.15) */
18
+ swipeThreshold?: number;
19
+ /** Mobile breakpoint in pixels (default: 768) */
20
+ mobileBreakpoint?: number;
21
+ /** Auto-scroll interval in milliseconds (default: 8000). Set to 0 to disable. */
22
+ autoScrollInterval?: number;
23
+ /** Enable auto-scroll (default: true) */
24
+ enableAutoScroll?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Return value from the useCarouselSwipe hook
29
+ */
30
+ export interface CarouselSwipeReturn {
31
+ /** Current active slide index */
32
+ currentIndex: number;
33
+ /** Current swipe offset in pixels */
34
+ swipeOffset: number;
35
+ /** Whether user is currently swiping */
36
+ isSwiping: boolean;
37
+ /** Whether viewport is mobile size */
38
+ isMobile: boolean;
39
+ /** Memoized container width */
40
+ containerWidth: number;
41
+ /** Ref to attach to the carousel container element */
42
+ containerRef: React.RefObject<HTMLDivElement>;
43
+ /** Navigate to next slide */
44
+ nextSlide: () => void;
45
+ /** Navigate to previous slide */
46
+ prevSlide: () => void;
47
+ /** Navigate to specific slide index */
48
+ goToSlide: (index: number) => void;
49
+ /** Touch start handler */
50
+ handleTouchStart: (e: React.TouchEvent) => void;
51
+ /** Touch move handler */
52
+ handleTouchMove: (e: React.TouchEvent) => void;
53
+ /** Touch end handler */
54
+ handleTouchEnd: () => void;
55
+ /** Constants used for calculations */
56
+ constants: {
57
+ CARD_OFFSET_PERCENTAGE: number;
58
+ SWIPE_THRESHOLD: number;
59
+ MOBILE_BREAKPOINT: number;
60
+ AUTO_SCROLL_INTERVAL: number;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Custom hook for implementing swipe/touch gestures in carousels
66
+ *
67
+ * Features:
68
+ * - Touch/swipe support with smooth finger-following
69
+ * - Auto-scroll with pause on interaction
70
+ * - Responsive mobile detection with resize listener
71
+ * - Performance optimized with memoization
72
+ * - Configurable thresholds and behavior
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * const carousel = useCarouselSwipe({
77
+ * itemCount: items.length,
78
+ * autoScrollInterval: 5000,
79
+ * });
80
+ *
81
+ * return (
82
+ * <div
83
+ * ref={carousel.containerRef}
84
+ * onTouchStart={carousel.handleTouchStart}
85
+ * onTouchMove={carousel.handleTouchMove}
86
+ * onTouchEnd={carousel.handleTouchEnd}
87
+ * >
88
+ * {items.map((item, index) => (
89
+ * <div
90
+ * key={index}
91
+ * style={{
92
+ * transform: `translateX(${calculateTransform(index, carousel)})`,
93
+ * }}
94
+ * >
95
+ * {item}
96
+ * </div>
97
+ * ))}
98
+ * </div>
99
+ * );
100
+ * ```
101
+ */
102
+ export function useCarouselSwipe(
103
+ config: CarouselSwipeConfig
104
+ ): CarouselSwipeReturn {
105
+ const {
106
+ itemCount,
107
+ cardOffsetPercentage = 105,
108
+ swipeThreshold = 0.15,
109
+ mobileBreakpoint = 768,
110
+ autoScrollInterval = 8000,
111
+ enableAutoScroll = true,
112
+ } = config;
113
+
114
+ // State
115
+ const [currentIndex, setCurrentIndex] = useState(0);
116
+ const [swipeOffset, setSwipeOffset] = useState(0);
117
+ const [isSwiping, setIsSwiping] = useState(false);
118
+ const [containerWidth, setContainerWidth] = useState(window.innerWidth);
119
+ // Performance: Store mobile state to avoid repeated window.innerWidth checks on every render
120
+ // This prevents expensive DOM queries during swipe operations
121
+ const [isMobile, setIsMobile] = useState(false);
122
+
123
+ // Refs
124
+ const timeoutRef = useRef<ReturnType<typeof setInterval> | null>(null);
125
+ const touchStartX = useRef<number>(0);
126
+ const containerRef = useRef<HTMLDivElement>(null);
127
+
128
+ // Constants
129
+ const constants = {
130
+ CARD_OFFSET_PERCENTAGE: cardOffsetPercentage,
131
+ SWIPE_THRESHOLD: swipeThreshold,
132
+ MOBILE_BREAKPOINT: mobileBreakpoint,
133
+ AUTO_SCROLL_INTERVAL: autoScrollInterval,
134
+ };
135
+
136
+ // Performance: Memoize container width to prevent recalculation on every render
137
+ // This is especially important during swipe operations where the component re-renders
138
+ // frequently. Without memoization, it'd query the DOM on every frame while swiping.
139
+ useEffect(() => {
140
+ const updateWidth = () => {
141
+ setContainerWidth(containerRef.current?.offsetWidth || window.innerWidth);
142
+ };
143
+ updateWidth(); // Initial
144
+ window.addEventListener("resize", updateWidth);
145
+ return () => window.removeEventListener("resize", updateWidth);
146
+ }, []);
147
+
148
+ // Navigation functions
149
+ const nextSlide = useCallback(() => {
150
+ if (itemCount === 0) return;
151
+ setCurrentIndex(prev => (prev + 1) % itemCount);
152
+ }, [itemCount]);
153
+
154
+ const prevSlide = useCallback(() => {
155
+ if (itemCount === 0) return;
156
+ setCurrentIndex(prev => (prev === 0 ? itemCount - 1 : prev - 1));
157
+ }, [itemCount]);
158
+
159
+ const goToSlide = useCallback(
160
+ (index: number) => {
161
+ if (index < 0 || index >= itemCount) return;
162
+ setCurrentIndex(index);
163
+ },
164
+ [itemCount]
165
+ );
166
+
167
+ // Touch handlers for mobile swipe
168
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
169
+ touchStartX.current = e.touches[0].clientX;
170
+ setIsSwiping(true);
171
+ // Pause auto-scroll during user interaction
172
+ if (timeoutRef.current) {
173
+ clearInterval(timeoutRef.current);
174
+ }
175
+ }, []);
176
+
177
+ const handleTouchMove = useCallback(
178
+ (e: React.TouchEvent) => {
179
+ if (!isSwiping) return;
180
+ const currentX = e.touches[0].clientX;
181
+ const diff = currentX - touchStartX.current;
182
+ setSwipeOffset(diff);
183
+ },
184
+ [isSwiping]
185
+ );
186
+
187
+ const handleTouchEnd = useCallback(() => {
188
+ setIsSwiping(false);
189
+ // Use memoized containerWidth and constant threshold for performance
190
+ const threshold = containerWidth * swipeThreshold;
191
+
192
+ // Determine if swipe was strong enough to change slides
193
+ if (swipeOffset > threshold) {
194
+ prevSlide();
195
+ } else if (swipeOffset < -threshold) {
196
+ nextSlide();
197
+ }
198
+
199
+ // Reset swipe offset to return card to snapped position
200
+ setSwipeOffset(0);
201
+
202
+ // Restart auto-scroll after user interaction completes
203
+ if (enableAutoScroll && autoScrollInterval > 0) {
204
+ timeoutRef.current = setInterval(() => {
205
+ nextSlide();
206
+ }, autoScrollInterval);
207
+ }
208
+ }, [
209
+ swipeOffset,
210
+ containerWidth,
211
+ swipeThreshold,
212
+ prevSlide,
213
+ nextSlide,
214
+ enableAutoScroll,
215
+ autoScrollInterval,
216
+ ]);
217
+
218
+ // Performance: Detect mobile viewport and update on resize
219
+ // This replaces inline window.innerWidth checks that were running on every render.
220
+ // By using state and a resize listener, we only check when the viewport actually changes.
221
+ useEffect(() => {
222
+ const checkMobile = () => {
223
+ setIsMobile(window.innerWidth < mobileBreakpoint);
224
+ };
225
+
226
+ // Check immediately on mount
227
+ checkMobile();
228
+
229
+ // Update when window is resized (e.g., device rotation, browser resize)
230
+ window.addEventListener("resize", checkMobile);
231
+ return () => window.removeEventListener("resize", checkMobile);
232
+ }, [mobileBreakpoint]);
233
+
234
+ // Auto-scroll logic
235
+ useEffect(() => {
236
+ if (!enableAutoScroll || itemCount === 0 || autoScrollInterval === 0) {
237
+ return;
238
+ }
239
+
240
+ timeoutRef.current = setInterval(() => {
241
+ nextSlide();
242
+ }, autoScrollInterval);
243
+
244
+ return () => {
245
+ if (timeoutRef.current) clearInterval(timeoutRef.current);
246
+ };
247
+ }, [nextSlide, itemCount, enableAutoScroll, autoScrollInterval]);
248
+
249
+ return {
250
+ currentIndex,
251
+ swipeOffset,
252
+ isSwiping,
253
+ isMobile,
254
+ containerWidth,
255
+ containerRef,
256
+ nextSlide,
257
+ prevSlide,
258
+ goToSlide,
259
+ handleTouchStart,
260
+ handleTouchMove,
261
+ handleTouchEnd,
262
+ constants,
263
+ };
264
+ }