@windstream/react-shared-components 0.2.74 → 0.2.75

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.
@@ -46,10 +46,14 @@ function ProductCardPanel({
46
46
  // When any card is flagged best-value, every card reserves the banner height
47
47
  // so plan name/price stay aligned across a two-column row.
48
48
  const hasAnyBadge = items.some((item: any) => item?.highlighted);
49
+ const hasAnyGift = items.some(
50
+ (item: any) => (item?.giftRewards?.list?.items?.length ?? 0) > 0
51
+ );
49
52
  const showArrows = fields?.showArrows !== false && isCarousel;
50
53
  const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
51
54
  const windowRef = useRef<HTMLDivElement | null>(null);
52
55
  const mobileGridRef = useRef<HTMLDivElement | null>(null);
56
+ const panelRef = useRef<HTMLDivElement | null>(null);
53
57
  const touchStartX = useRef<number>(0);
54
58
 
55
59
  // Determine how many cards fit so we never scroll past the last card
@@ -176,16 +180,13 @@ function ProductCardPanel({
176
180
  }, [isCarousel, isVisible, desktopExpanded, items.length]);
177
181
 
178
182
  // Two-column mobile: match each card's sections to the tallest card in the
179
- // same row (pair) so section spacing and the CTA align per row. Desktop keeps
183
+ // same row (pair) so section spacing aligns per row. The gift slot is handled
184
+ // separately (panel-wide) so it sizes to the real gift content. Desktop keeps
180
185
  // its fixed section heights and is left untouched.
181
186
  useEffect(() => {
182
187
  if (!twoColumn || !isVisible) return;
183
188
 
184
- const selectors = [
185
- "[data-pc-header]",
186
- "[data-pc-description]",
187
- "[data-pc-gift]",
188
- ];
189
+ const selectors = ["[data-pc-header]", "[data-pc-description]"];
189
190
 
190
191
  const clear = () => {
191
192
  const grid = mobileGridRef.current;
@@ -193,35 +194,20 @@ function ProductCardPanel({
193
194
  selectors.forEach(sel =>
194
195
  grid.querySelectorAll<HTMLElement>(sel).forEach(el => {
195
196
  el.style.removeProperty("min-height");
196
- el.style.removeProperty("display");
197
197
  })
198
198
  );
199
199
  };
200
200
 
201
201
  // `important` is required because Tailwind utilities are generated with
202
202
  // !important in this project (tailwind.config `important: true`).
203
- const equalizePairs = (sel: string, collapseEmpty = false) => {
203
+ const equalizePairs = (sel: string) => {
204
204
  const grid = mobileGridRef.current;
205
205
  if (!grid) return;
206
206
  const els = Array.from(grid.querySelectorAll<HTMLElement>(sel));
207
207
  // Reset before measuring so a previous pass doesn't inflate the result.
208
- els.forEach(el => {
209
- el.style.removeProperty("min-height");
210
- el.style.removeProperty("display");
211
- });
208
+ els.forEach(el => el.style.removeProperty("min-height"));
212
209
  for (let i = 0; i < els.length; i += 2) {
213
210
  const pair = els.slice(i, i + 2);
214
- // Collapse a row (or lone trailing card) whose gift slots are all empty
215
- // so no space is reserved when the row has no gift.
216
- if (
217
- collapseEmpty &&
218
- pair.every(el => el.hasAttribute("data-pc-gift-empty"))
219
- ) {
220
- pair.forEach(el =>
221
- el.style.setProperty("display", "none", "important")
222
- );
223
- continue;
224
- }
225
211
  if (pair.length < 2) break;
226
212
  const max = Math.max(
227
213
  ...pair.map(el => el.getBoundingClientRect().height)
@@ -240,7 +226,6 @@ function ProductCardPanel({
240
226
  }
241
227
  equalizePairs("[data-pc-header]");
242
228
  equalizePairs("[data-pc-description]");
243
- equalizePairs("[data-pc-gift]", true);
244
229
  };
245
230
 
246
231
  const rafId = requestAnimationFrame(equalize);
@@ -257,6 +242,83 @@ function ProductCardPanel({
257
242
  };
258
243
  }, [twoColumn, isVisible, desktopExpanded, items.length]);
259
244
 
245
+ // Size the gift-reward slot per visual row so the Select plan CTA sits
246
+ // directly below the gift badge, while staying aligned only with the cards
247
+ // that sit next to it (same row). Cards are grouped by their on-screen row,
248
+ // so a giftless row never borrows the gift height of another row in the same
249
+ // tab: desktop equalizes the single row of cards; two-column mobile equalizes
250
+ // each pair; a row whose slots are all empty collapses so its CTA sits flush
251
+ // with the description. When the tab has no gift at all, `hasAnyGift` omits
252
+ // the slot entirely.
253
+ useEffect(() => {
254
+ if (!isVisible) return;
255
+
256
+ const root = panelRef.current;
257
+
258
+ const reset = () => {
259
+ if (!root) return;
260
+ root.querySelectorAll<HTMLElement>("[data-pc-gift]").forEach(el => {
261
+ el.style.removeProperty("min-height");
262
+ el.style.removeProperty("display");
263
+ });
264
+ };
265
+
266
+ const equalizeGiftSlots = () => {
267
+ if (!root) return;
268
+ reset();
269
+ const els = Array.from(
270
+ root.querySelectorAll<HTMLElement>("[data-pc-gift]")
271
+ ).filter(el => el.offsetParent !== null);
272
+ if (els.length === 0) return;
273
+
274
+ // Group slots by the top of their owning card so each visual row (the
275
+ // cards that sit next to each other) is equalized independently.
276
+ const tolerance = 4;
277
+ const rows: { top: number; els: HTMLElement[] }[] = [];
278
+ els.forEach(el => {
279
+ const card = (el.closest("article") as HTMLElement) || el;
280
+ const top = card.getBoundingClientRect().top;
281
+ const row = rows.find(r => Math.abs(r.top - top) <= tolerance);
282
+ if (row) {
283
+ row.els.push(el);
284
+ } else {
285
+ rows.push({ top, els: [el] });
286
+ }
287
+ });
288
+
289
+ rows.forEach(({ els: rowEls }) => {
290
+ // Collapse a row whose slots are all empty so no space is reserved.
291
+ if (rowEls.every(el => el.hasAttribute("data-pc-gift-empty"))) {
292
+ rowEls.forEach(el =>
293
+ el.style.setProperty("display", "none", "important")
294
+ );
295
+ return;
296
+ }
297
+ const max = Math.max(
298
+ ...rowEls.map(el => el.getBoundingClientRect().height)
299
+ );
300
+ if (max <= 0) return;
301
+ rowEls.forEach(el =>
302
+ el.style.setProperty("min-height", `${max}px`, "important")
303
+ );
304
+ });
305
+ };
306
+
307
+ equalizeGiftSlots();
308
+ const rafId = requestAnimationFrame(equalizeGiftSlots);
309
+ const t1 = setTimeout(equalizeGiftSlots, 150);
310
+ const t2 = setTimeout(equalizeGiftSlots, 500);
311
+ window.addEventListener("resize", equalizeGiftSlots);
312
+
313
+ return () => {
314
+ cancelAnimationFrame(rafId);
315
+ clearTimeout(t1);
316
+ clearTimeout(t2);
317
+ window.removeEventListener("resize", equalizeGiftSlots);
318
+ reset();
319
+ };
320
+ }, [isVisible, desktopExpanded, items.length]);
321
+
260
322
  if (!items.length && !fields?.title) {
261
323
  return null;
262
324
  }
@@ -338,6 +400,7 @@ function ProductCardPanel({
338
400
  cta={item.cta}
339
401
  priceSuffixBottom={item?.priceSuffixBottom}
340
402
  hasAnyBadge={hasAnyBadge}
403
+ hasAnyGift={hasAnyGift}
341
404
  twoColumn={twoColumn}
342
405
  enableBusinessSettings={enableBusinessSettings}
343
406
  />
@@ -345,87 +408,91 @@ function ProductCardPanel({
345
408
  );
346
409
  };
347
410
 
348
- return !isCarousel ? (
349
- <div
350
- ref={mobileGridRef}
351
- className={cx(
352
- "w-full max-w-[1280px] gap-4",
353
- twoColumn
354
- ? "grid grid-cols-2 md:flex md:flex-wrap md:justify-center"
355
- : "flex flex-wrap justify-center"
356
- )}
357
- >
358
- {items.map((item, index) => renderCard(item, index, twoColumn))}
359
- </div>
360
- ) : (
361
- <>
362
- {/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
363
- <div
364
- ref={mobileGridRef}
365
- className={cx(
366
- "md:hidden",
367
- twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
368
- )}
369
- >
370
- {items.map((item, index) => renderCard(item, index, true))}
371
- </div>
372
-
373
- {/* Desktop View: Horizontal Carousel */}
374
- <div className="relative hidden w-full md:block">
375
- {/* Navigation Arrows */}
376
- {showArrows && (
377
- <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">
378
- {currentIndex > 0 ? (
379
- <Button
380
- onClick={prevSlide}
381
- 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"
382
- aria-label="Previous"
383
- >
384
- <MaterialIcon name="arrow_back" size={24} />
385
- </Button>
386
- ) : (
387
- <span className="h-12 w-12" aria-hidden="true" />
388
- )}
389
-
390
- {currentIndex < maxIndex ? (
391
- <Button
392
- onClick={nextSlide}
393
- 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"
394
- aria-label="Next"
395
- >
396
- <MaterialIcon name="arrow_forward" size={24} />
397
- </Button>
398
- ) : (
399
- <span className="h-12 w-12" aria-hidden="true" />
400
- )}
401
- </div>
402
- )}
403
-
404
- {/* Carousel Window */}
411
+ return (
412
+ <div ref={panelRef} style={{ display: "contents" }}>
413
+ {!isCarousel ? (
405
414
  <div
406
- ref={windowRef}
407
- className="mx-auto max-w-[1280px] select-none overflow-hidden"
408
- onTouchStart={handleTouchStart}
409
- onTouchMove={handleTouchMove}
410
- onTouchEnd={handleTouchEnd}
411
- onTouchCancel={handleTouchEnd}
415
+ ref={mobileGridRef}
416
+ className={cx(
417
+ "w-full max-w-[1280px] gap-4",
418
+ twoColumn
419
+ ? "grid grid-cols-2 md:flex md:flex-wrap md:justify-center"
420
+ : "flex flex-wrap justify-center"
421
+ )}
412
422
  >
423
+ {items.map((item, index) => renderCard(item, index, twoColumn))}
424
+ </div>
425
+ ) : (
426
+ <>
427
+ {/* Mobile/Tablet View: Vertical Stack (or two-column grid) */}
413
428
  <div
429
+ ref={mobileGridRef}
414
430
  className={cx(
415
- "ml-8 flex items-stretch gap-4 pb-4",
416
- isSwiping
417
- ? "transition-none"
418
- : "transition-transform duration-500 ease-in-out"
431
+ "md:hidden",
432
+ twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
419
433
  )}
420
- style={{
421
- transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
422
- }}
423
434
  >
424
- {items.map((item, index) => renderCard(item, index, false))}
435
+ {items.map((item, index) => renderCard(item, index, true))}
425
436
  </div>
426
- </div>
427
- </div>
428
- </>
437
+
438
+ {/* Desktop View: Horizontal Carousel */}
439
+ <div className="relative hidden w-full md:block">
440
+ {/* Navigation Arrows */}
441
+ {showArrows && (
442
+ <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">
443
+ {currentIndex > 0 ? (
444
+ <Button
445
+ onClick={prevSlide}
446
+ 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"
447
+ aria-label="Previous"
448
+ >
449
+ <MaterialIcon name="arrow_back" size={24} />
450
+ </Button>
451
+ ) : (
452
+ <span className="h-12 w-12" aria-hidden="true" />
453
+ )}
454
+
455
+ {currentIndex < maxIndex ? (
456
+ <Button
457
+ onClick={nextSlide}
458
+ 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"
459
+ aria-label="Next"
460
+ >
461
+ <MaterialIcon name="arrow_forward" size={24} />
462
+ </Button>
463
+ ) : (
464
+ <span className="h-12 w-12" aria-hidden="true" />
465
+ )}
466
+ </div>
467
+ )}
468
+
469
+ {/* Carousel Window */}
470
+ <div
471
+ ref={windowRef}
472
+ className="mx-auto max-w-[1280px] select-none overflow-hidden"
473
+ onTouchStart={handleTouchStart}
474
+ onTouchMove={handleTouchMove}
475
+ onTouchEnd={handleTouchEnd}
476
+ onTouchCancel={handleTouchEnd}
477
+ >
478
+ <div
479
+ className={cx(
480
+ "ml-8 flex items-stretch gap-4 pb-4",
481
+ isSwiping
482
+ ? "transition-none"
483
+ : "transition-transform duration-500 ease-in-out"
484
+ )}
485
+ style={{
486
+ transform: `translateX(calc(-${currentIndex} * (392px + 16px) + ${swipeOffset}px))`,
487
+ }}
488
+ >
489
+ {items.map((item, index) => renderCard(item, index, false))}
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </>
494
+ )}
495
+ </div>
429
496
  );
430
497
  }
431
498
 
@@ -0,0 +1,163 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ import React from "react";
4
+ import { OrderedList } from "./index";
5
+
6
+ import { render, screen, within } from "@testing-library/react";
7
+
8
+ jest.mock("@shared/components/link", () => ({
9
+ Link: ({ children, href, title, className }: any) => (
10
+ <a href={href} title={title} className={className} data-testid="ol-link">
11
+ {children}
12
+ </a>
13
+ ),
14
+ }));
15
+
16
+ jest.mock("@shared/components/text", () => ({
17
+ Text: ({ children, as: Tag = "span", className }: any) => (
18
+ <Tag className={className}>{children}</Tag>
19
+ ),
20
+ }));
21
+
22
+ jest.mock("@shared/contentful/blocks/dynamic-tabs", () => ({
23
+ DynamicTabs: ({
24
+ tabs,
25
+ dynamic,
26
+ isOrderedList,
27
+ navigationType,
28
+ mobileSidebarTabType,
29
+ }: any) => (
30
+ <div
31
+ data-testid="dynamic-tabs"
32
+ data-dynamic={String(dynamic)}
33
+ data-ordered={String(isOrderedList)}
34
+ data-nav={String(navigationType)}
35
+ data-sidebar={String(mobileSidebarTabType)}
36
+ >
37
+ {tabs.items.map((tab: any) => (
38
+ <div
39
+ key={tab.anchorId}
40
+ data-testid="dt-tab"
41
+ data-tabname={tab.tabName}
42
+ data-anchor={tab.anchorId}
43
+ >
44
+ {tab.tabContent}
45
+ </div>
46
+ ))}
47
+ </div>
48
+ ),
49
+ }));
50
+
51
+ describe("OrderedList", () => {
52
+ const tabs = [
53
+ {
54
+ items: {
55
+ tabName: "States",
56
+ tabContent: [
57
+ { itemName: "new york", itemSlug: "/new-york" },
58
+ { itemName: "los angeles" },
59
+ ],
60
+ },
61
+ },
62
+ {
63
+ items: {
64
+ tabName: "Cities",
65
+ tabContent: [{ itemName: "san FRANCISCO", itemSlug: "/sf" }],
66
+ },
67
+ },
68
+ ];
69
+
70
+ const renderComponent = (overrides: Partial<any> = {}) =>
71
+ render(
72
+ <OrderedList
73
+ title="Browse locations"
74
+ description="Find availability near you"
75
+ tabs={tabs}
76
+ {...overrides}
77
+ />
78
+ );
79
+
80
+ it("renders the title and description", () => {
81
+ renderComponent();
82
+ expect(screen.getByText("Browse locations")).toBeInTheDocument();
83
+ expect(screen.getByText("Find availability near you")).toBeInTheDocument();
84
+ });
85
+
86
+ it("renders the section with the provided anchor id and testid", () => {
87
+ const { container } = renderComponent({ anchorId: "locations" });
88
+ const section = container.querySelector(
89
+ '[data-testid="non-fiber-city-list"]'
90
+ );
91
+ expect(section).toBeInTheDocument();
92
+ expect(section).toHaveAttribute("id", "locations");
93
+ });
94
+
95
+ it("defaults the background to white", () => {
96
+ const { container } = renderComponent();
97
+ expect(container.querySelector(".bg-secondary-white")).toBeInTheDocument();
98
+ });
99
+
100
+ it("applies the cream background when requested", () => {
101
+ const { container } = renderComponent({ backgroundColor: "cream" });
102
+ expect(container.querySelector(".bg-secondary-cream")).toBeInTheDocument();
103
+ expect(
104
+ container.querySelector(".bg-secondary-white")
105
+ ).not.toBeInTheDocument();
106
+ });
107
+
108
+ it("forwards the expected flags to DynamicTabs", () => {
109
+ renderComponent();
110
+ const dynamicTabs = screen.getByTestId("dynamic-tabs");
111
+ expect(dynamicTabs).toHaveAttribute("data-dynamic", "true");
112
+ expect(dynamicTabs).toHaveAttribute("data-ordered", "true");
113
+ expect(dynamicTabs).toHaveAttribute("data-nav", "false");
114
+ expect(dynamicTabs).toHaveAttribute("data-sidebar", "false");
115
+ });
116
+
117
+ it("maps each tab with its name and a generated anchor id", () => {
118
+ renderComponent();
119
+ const dtTabs = screen.getAllByTestId("dt-tab");
120
+ expect(dtTabs).toHaveLength(2);
121
+ expect(dtTabs[0]).toHaveAttribute("data-tabname", "States");
122
+ expect(dtTabs[0]).toHaveAttribute("data-anchor", "tab-0");
123
+ expect(dtTabs[1]).toHaveAttribute("data-tabname", "Cities");
124
+ expect(dtTabs[1]).toHaveAttribute("data-anchor", "tab-1");
125
+ });
126
+
127
+ it("title-cases item names regardless of input casing", () => {
128
+ renderComponent();
129
+ // "new york" -> "New York", "los angeles" -> "Los Angeles",
130
+ // "san FRANCISCO" -> "San Francisco"
131
+ expect(screen.getByText("New York")).toBeInTheDocument();
132
+ expect(screen.getByText("Los Angeles")).toBeInTheDocument();
133
+ expect(screen.getByText("San Francisco")).toBeInTheDocument();
134
+ });
135
+
136
+ it("renders a linked item with an uppercased title and href", () => {
137
+ renderComponent();
138
+ const link = screen.getByText("New York").closest("a");
139
+ expect(link).not.toBeNull();
140
+ expect(link).toHaveAttribute("href", "/new-york");
141
+ expect(link).toHaveAttribute("title", "NEW YORK");
142
+ });
143
+
144
+ it("renders an item without a slug as plain text (no link)", () => {
145
+ renderComponent();
146
+ const losAngeles = screen.getByText("Los Angeles");
147
+ expect(losAngeles.closest("a")).toBeNull();
148
+ });
149
+
150
+ it("keeps each tab's items within that tab", () => {
151
+ renderComponent();
152
+ const dtTabs = screen.getAllByTestId("dt-tab");
153
+ expect(within(dtTabs[0]).getByText("New York")).toBeInTheDocument();
154
+ expect(within(dtTabs[0]).getByText("Los Angeles")).toBeInTheDocument();
155
+ expect(within(dtTabs[1]).getByText("San Francisco")).toBeInTheDocument();
156
+ expect(within(dtTabs[1]).queryByText("New York")).toBeNull();
157
+ });
158
+
159
+ it("does not render DynamicTabs when there are no tabs", () => {
160
+ renderComponent({ tabs: [] });
161
+ expect(screen.queryByTestId("dynamic-tabs")).toBeNull();
162
+ });
163
+ });
@@ -184,6 +184,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
184
184
  const checklistEl = (
185
185
  <Checklist
186
186
  iconSize={24}
187
+ iconPosition="top"
187
188
  listItemClassName={`body2 text-left ${textColorClass}`}
188
189
  items={checklist.map(item =>
189
190
  item.iconUrl