@windstream/react-shared-components 0.2.14 → 0.2.16

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.
@@ -29,20 +29,96 @@ function ProductCardPanel({
29
29
  Record<number, boolean>
30
30
  >({});
31
31
  const [currentIndex, setCurrentIndex] = useState(0);
32
+ const [maxIndex, setMaxIndex] = useState(0);
33
+ const [swipeOffset, setSwipeOffset] = useState(0);
34
+ const [isSwiping, setIsSwiping] = useState(false);
32
35
  const items = fields?.items?.items || [];
33
36
  const isCarousel = items.length > 2;
34
37
  const showArrows = fields?.showArrows !== false && isCarousel;
35
38
  const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
39
+ const windowRef = useRef<HTMLDivElement | null>(null);
40
+ const touchStartX = useRef<number>(0);
41
+
42
+ // Determine how many cards fit so we never scroll past the last card
43
+ useEffect(() => {
44
+ if (!isCarousel || !isVisible) return;
45
+
46
+ const gap = 16; // track gap-4 (16px)
47
+ const cardWidth = 392 + gap; // card width + gap
48
+ const trackMargin = 32; // inner track ml-8 (32px)
49
+ const computeMaxIndex = () => {
50
+ const containerWidth = windowRef.current?.offsetWidth || 0;
51
+ // Skip while hidden (offsetWidth is 0) to avoid overestimating maxIndex
52
+ if (containerWidth === 0) return;
53
+ const usableWidth = containerWidth - trackMargin;
54
+ const visibleCards = Math.max(
55
+ 1,
56
+ Math.floor((usableWidth + gap) / cardWidth)
57
+ );
58
+ setMaxIndex(Math.max(0, items.length - visibleCards));
59
+ };
60
+
61
+ computeMaxIndex();
62
+ window.addEventListener("resize", computeMaxIndex);
63
+ return () => window.removeEventListener("resize", computeMaxIndex);
64
+ }, [isCarousel, isVisible, items.length]);
65
+
66
+ // Keep the active index within the available range
67
+ useEffect(() => {
68
+ setCurrentIndex(prev => Math.min(prev, maxIndex));
69
+ }, [maxIndex]);
36
70
 
37
71
  const prevSlide = useCallback(() => {
38
72
  if (!isCarousel) return;
39
- setCurrentIndex(prev => (prev - 1 + items.length) % items.length);
40
- }, [isCarousel, items.length]);
73
+ setCurrentIndex(prev => Math.max(0, prev - 1));
74
+ }, [isCarousel]);
41
75
 
42
76
  const nextSlide = useCallback(() => {
43
77
  if (!isCarousel) return;
44
- setCurrentIndex(prev => (prev + 1) % items.length);
45
- }, [isCarousel, items.length]);
78
+ setCurrentIndex(prev => Math.min(maxIndex, prev + 1));
79
+ }, [isCarousel, maxIndex]);
80
+
81
+ // Touch/swipe handlers for finger-following drag (touch devices)
82
+ const handleTouchStart = useCallback(
83
+ (e: React.TouchEvent) => {
84
+ if (!isCarousel) return;
85
+ touchStartX.current = e.touches[0].clientX;
86
+ setIsSwiping(true);
87
+ },
88
+ [isCarousel]
89
+ );
90
+
91
+ const handleTouchMove = useCallback(
92
+ (e: React.TouchEvent) => {
93
+ if (!isSwiping) return;
94
+ const diff = e.touches[0].clientX - touchStartX.current;
95
+ // Apply resistance when dragging past the start/end boundaries
96
+ const atStart = currentIndex === 0;
97
+ const atEnd = currentIndex >= maxIndex;
98
+ if ((atStart && diff > 0) || (atEnd && diff < 0)) {
99
+ setSwipeOffset(diff * 0.3);
100
+ } else {
101
+ setSwipeOffset(diff);
102
+ }
103
+ },
104
+ [isSwiping, currentIndex, maxIndex]
105
+ );
106
+
107
+ const handleTouchEnd = useCallback(() => {
108
+ if (!isSwiping) return;
109
+ setIsSwiping(false);
110
+
111
+ const containerWidth = windowRef.current?.offsetWidth || 0;
112
+ const threshold = containerWidth * 0.15;
113
+
114
+ if (swipeOffset > threshold) {
115
+ prevSlide();
116
+ } else if (swipeOffset < -threshold) {
117
+ nextSlide();
118
+ }
119
+
120
+ setSwipeOffset(0);
121
+ }, [isSwiping, swipeOffset, prevSlide, nextSlide]);
46
122
 
47
123
  // Equalize card heights
48
124
  useEffect(() => {
@@ -162,10 +238,6 @@ function ProductCardPanel({
162
238
  );
163
239
  };
164
240
 
165
- // For infinite scroll with peeking, starting in the middle of the repeated array
166
- const displayItems = [...items, ...items, ...items, ...items, ...items];
167
- const offset = items.length * 2;
168
-
169
241
  return !isCarousel ? (
170
242
  <div className="flex w-full max-w-[1280px] flex-wrap justify-center gap-4">
171
243
  {items.map((item, index) => renderCard(item, index))}
@@ -182,33 +254,53 @@ function ProductCardPanel({
182
254
  {/* Navigation Arrows */}
183
255
  {showArrows && (
184
256
  <div className="pointer-events-none absolute -left-16 -right-16 top-[50%] z-30 flex -translate-y-1/2 justify-between px-4 md:px-10">
185
- <Button
186
- onClick={prevSlide}
187
- className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
188
- aria-label="Previous"
189
- >
190
- <MaterialIcon name="arrow_back" size={24} />
191
- </Button>
257
+ {currentIndex > 0 ? (
258
+ <Button
259
+ onClick={prevSlide}
260
+ className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
261
+ aria-label="Previous"
262
+ >
263
+ <MaterialIcon name="arrow_back" size={24} />
264
+ </Button>
265
+ ) : (
266
+ <span className="h-12 w-12" aria-hidden="true" />
267
+ )}
192
268
 
193
- <Button
194
- onClick={nextSlide}
195
- className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
196
- aria-label="Next"
197
- >
198
- <MaterialIcon name="arrow_forward" size={24} />
199
- </Button>
269
+ {currentIndex < maxIndex ? (
270
+ <Button
271
+ onClick={nextSlide}
272
+ className="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full border border-gray-100 bg-white p-2 text-text shadow-cardDrop transition-all hover:bg-gray-50"
273
+ aria-label="Next"
274
+ >
275
+ <MaterialIcon name="arrow_forward" size={24} />
276
+ </Button>
277
+ ) : (
278
+ <span className="h-12 w-12" aria-hidden="true" />
279
+ )}
200
280
  </div>
201
281
  )}
202
282
 
203
283
  {/* Carousel Window */}
204
- <div className="mx-auto max-w-[1280px] overflow-hidden">
284
+ <div
285
+ ref={windowRef}
286
+ className="mx-auto max-w-[1280px] select-none overflow-hidden"
287
+ onTouchStart={handleTouchStart}
288
+ onTouchMove={handleTouchMove}
289
+ onTouchEnd={handleTouchEnd}
290
+ onTouchCancel={handleTouchEnd}
291
+ >
205
292
  <div
206
- className="flex items-stretch gap-4 pb-4 transition-transform duration-500 ease-in-out"
293
+ className={cx(
294
+ "ml-8 flex items-stretch gap-4 pb-4",
295
+ isSwiping
296
+ ? "transition-none"
297
+ : "transition-transform duration-500 ease-in-out"
298
+ )}
207
299
  style={{
208
- transform: `translateX(calc(-${offset + currentIndex} * (392px + 12px)))`,
300
+ transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
209
301
  }}
210
302
  >
211
- {displayItems.map((item, index) => renderCard(item, index, false))}
303
+ {items.map((item, index) => renderCard(item, index, false))}
212
304
  </div>
213
305
  </div>
214
306
  </div>
@@ -70,6 +70,8 @@ const CartRetentionBanner: React.FC<{
70
70
  <Button
71
71
  onClick={continueCheckout}
72
72
  buttonLabel={cta?.buttonLabel ?? "Continue checkout"}
73
+ disableAnimation={cta?.disableAnimation ?? true}
74
+ animationType={cta?.animationType}
73
75
  />
74
76
  </div>
75
77
  </div>
@@ -98,6 +100,8 @@ const CartRetentionBanner: React.FC<{
98
100
  <Button
99
101
  onClick={continueCheckout}
100
102
  buttonLabel={cta?.buttonLabel ?? "Continue checkout"}
103
+ disableAnimation={cta?.disableAnimation ?? true}
104
+ animationType={cta?.animationType}
101
105
  />
102
106
  </div>
103
107
  </div>
@@ -1,3 +1,5 @@
1
+ import type { AnimationType } from "@shared/components/animation-wrapper";
2
+
1
3
  export interface TypeCartRetentionBannerFields {
2
4
  entryTitle?: string;
3
5
  anchorId?: string;
@@ -5,7 +7,12 @@ export interface TypeCartRetentionBannerFields {
5
7
  description?: {
6
8
  json: any;
7
9
  };
8
- cta?: { buttonLabel?: string; [key: string]: any };
10
+ cta?: {
11
+ buttonLabel?: string;
12
+ disableAnimation?: boolean;
13
+ animationType?: AnimationType | AnimationType[];
14
+ [key: string]: any;
15
+ };
9
16
  }
10
17
 
11
18
  export type TypeCartRetentionBanner = TypeCartRetentionBannerFields;
@@ -30,10 +30,10 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
30
30
 
31
31
  const bottomLinkLabel = bottomLink?.buttonLabel ?? bottomLink?.label;
32
32
  return (
33
- <div className="component-container p-5 lg:flex lg:min-h-[724px] lg:items-center lg:p-0 lg:py-4">
33
+ <div className="component-container p-5 xl:flex xl:min-h-[724px] xl:items-center xl:p-0 xl:py-4">
34
34
  <div
35
35
  className={cx(
36
- "primary-hero-container mx-auto flex w-full flex-col sm:items-center sm:text-center lg:h-full lg:max-w-120 lg:flex-row lg:items-center lg:justify-between lg:gap-4 lg:text-left"
36
+ "primary-hero-container mx-auto flex w-full flex-col sm:items-center sm:text-center xl:h-full xl:max-w-120 xl:flex-row xl:items-center xl:justify-between xl:gap-4 xl:text-left"
37
37
  )}
38
38
  >
39
39
  {/* Left column: navy background, headline, body, address, button, link */}
@@ -84,7 +84,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
84
84
  {/* checklist */}
85
85
  {checklist && (
86
86
  <Checklist
87
- listItemClassName="text-white text-left"
87
+ listItemClassName="body2 text-white text-left"
88
88
  items={checklist.map(item =>
89
89
  item.iconUrl
90
90
  ? { title: item.title, iconUrl: item.iconUrl }
@@ -145,7 +145,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
145
145
  </div>
146
146
 
147
147
  {/* mobile hero image and bottom link */}
148
- <div className="lg:hidden">
148
+ <div className="xl:hidden">
149
149
  <div className={"relative my-8"}>
150
150
  {badgeImage ? (
151
151
  <NextImage
@@ -207,7 +207,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
207
207
  </div>
208
208
 
209
209
  {/* Right column: bright blue background, diagonal top edge, hero image */}
210
- <div className={cx("relative hidden lg:block")}>
210
+ <div className={cx("relative hidden xl:block")}>
211
211
  {badgeImage ? (
212
212
  <NextImage
213
213
  src={badgeImage}
@@ -390,4 +390,43 @@ describe("useCarouselSwipe", () => {
390
390
  removeEventListenerSpy.mockRestore();
391
391
  });
392
392
  });
393
+
394
+ describe("SSR safety (window undefined)", () => {
395
+ it("can be created without throwing when window is undefined and uses fallback containerWidth", () => {
396
+ const origWindow = globalThis.window;
397
+ // @ts-expect-error - intentionally deleting window to simulate SSR
398
+ delete globalThis.window;
399
+
400
+ // Polyfill TextEncoder/TextDecoder required by react-dom/server in jest
401
+ const { TextEncoder, TextDecoder } = require("util");
402
+ const origTE = globalThis.TextEncoder;
403
+ const origTD = globalThis.TextDecoder;
404
+ globalThis.TextEncoder = TextEncoder;
405
+ globalThis.TextDecoder = TextDecoder;
406
+
407
+ let hookResult: ReturnType<typeof useCarouselSwipe> | undefined;
408
+
409
+ function TestComponent() {
410
+ hookResult = useCarouselSwipe(defaultConfig);
411
+ return null;
412
+ }
413
+
414
+ try {
415
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
416
+ const { renderToString } = require("react-dom/server");
417
+ // renderToString simulates real SSR: no window, no effects run
418
+ renderToString(React.createElement(TestComponent));
419
+
420
+ // containerWidth should use the fallback value of 1 since window is absent
421
+ expect(hookResult!.containerWidth).toBe(1);
422
+ expect(hookResult!.isSwiping).toBe(false);
423
+ expect(hookResult!.currentIndex).toBe(0);
424
+ } finally {
425
+ // Always restore globals so subsequent tests aren't affected
426
+ globalThis.window = origWindow;
427
+ globalThis.TextEncoder = origTE;
428
+ globalThis.TextDecoder = origTD;
429
+ }
430
+ });
431
+ });
393
432
  });
@@ -115,7 +115,9 @@ export function useCarouselSwipe(
115
115
  const [currentIndex, setCurrentIndex] = useState(0);
116
116
  const [swipeOffset, setSwipeOffset] = useState(0);
117
117
  const [isSwiping, setIsSwiping] = useState(false);
118
- const [containerWidth, setContainerWidth] = useState(window.innerWidth);
118
+ const [containerWidth, setContainerWidth] = useState(
119
+ typeof window !== "undefined" ? window.innerWidth : 1,
120
+ );
119
121
  // Performance: Store mobile state to avoid repeated window.innerWidth checks on every render
120
122
  // This prevents expensive DOM queries during swipe operations
121
123
  const [isMobile, setIsMobile] = useState(false);