@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.
- package/dist/contentful/index.d.ts +1 -0
- package/dist/contentful/index.esm.js +3 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +95 -2
- package/dist/index.d.ts +96 -3
- package/dist/index.esm.js +6 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +4 -2
- package/src/components/accordion/types.ts +1 -0
- package/src/contentful/blocks/accordion/index.tsx +11 -5
- package/src/contentful/blocks/cards/testimonial-card/index.tsx +1 -1
- package/src/contentful/blocks/carousel/helper.tsx +191 -107
- package/src/contentful/blocks/carousel/index.tsx +15 -9
- package/src/contentful/blocks/carousel/types.ts +1 -0
- package/src/hooks/use-carousel-swipe.ts +264 -0
- package/src/index.ts +5 -0
|
@@ -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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
nextSlide();
|
|
238
|
-
}, 8000); // 5 seconds per slide
|
|
292
|
+
TestimonialCarouselSlide.displayName = "TestimonialCarouselSlide";
|
|
239
293
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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-
|
|
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={() =>
|
|
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-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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-
|
|
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-
|
|
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="
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|