@windstream/react-shared-components 0.2.76 → 0.2.78

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.
@@ -155,25 +155,121 @@ describe("ProductCardCarousel", () => {
155
155
  expect(screen.getAllByTestId("product-card")).toHaveLength(1);
156
156
  });
157
157
 
158
- it("renders non-carousel layout for 2 or fewer items", () => {
159
- const fields = createProductFields([
160
- sampleProduct,
161
- { ...sampleProduct, sys: { id: "prod-2" } },
162
- ]);
158
+ it("renders non-carousel layout for a single item", () => {
159
+ const fields = createProductFields([sampleProduct]);
163
160
  const { container } = render(<ProductCardCarousel fields={fields} />);
164
161
  // Non-carousel: wrapped in flex layout
165
162
  expect(container.querySelector(".flex.w-full")).toBeInTheDocument();
166
163
  });
167
164
 
168
- it("renders carousel layout for more than 2 items", () => {
169
- const items = Array.from({ length: 3 }, (_, i) => ({
165
+ it("renders carousel layout for more than one item", () => {
166
+ const items = Array.from({ length: 2 }, (_, i) => ({
170
167
  ...sampleProduct,
171
168
  sys: { id: `prod-${i}` },
172
169
  }));
173
170
  const fields = createProductFields(items);
174
171
  const { container } = render(<ProductCardCarousel fields={fields} />);
175
- // 3 cards stay a vertical stack on mobile (flex-col for md:hidden)
172
+ // 2+ cards become a horizontal carousel (vertical stack on mobile via
173
+ // flex-col under sm:hidden); the non-carousel ".flex.w-full" row is gone.
176
174
  expect(container.querySelector(".flex-col")).toBeInTheDocument();
175
+ expect(container.querySelector(".flex.w-full")).not.toBeInTheDocument();
176
+ });
177
+
178
+ describe("desktop carousel track alignment", () => {
179
+ const withOffsetWidth = (width: number, run: () => void) => {
180
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
181
+ configurable: true,
182
+ get: () => width,
183
+ });
184
+ run();
185
+ };
186
+
187
+ it("centers the track (no left indent) when every card fits", () => {
188
+ const items = Array.from({ length: 3 }, (_, i) => ({
189
+ ...sampleProduct,
190
+ sys: { id: `prod-${i}` },
191
+ }));
192
+ withOffsetWidth(1280, () => {
193
+ const { container } = render(
194
+ <ProductCardCarousel fields={createProductFields(items)} />
195
+ );
196
+ fireEvent(window, new Event("resize"));
197
+ const track = container.querySelector(
198
+ '[style*="translateX"]'
199
+ ) as HTMLElement;
200
+ expect(track.className).toContain("justify-center");
201
+ expect(track.className).not.toContain("ml-8");
202
+ });
203
+ });
204
+
205
+ it("left-indents the track for scrolling when cards overflow", () => {
206
+ const items = Array.from({ length: 4 }, (_, i) => ({
207
+ ...sampleProduct,
208
+ sys: { id: `prod-${i}` },
209
+ }));
210
+ withOffsetWidth(900, () => {
211
+ const { container } = render(
212
+ <ProductCardCarousel fields={createProductFields(items)} />
213
+ );
214
+ fireEvent(window, new Event("resize"));
215
+ const track = container.querySelector(
216
+ '[style*="translateX"]'
217
+ ) as HTMLElement;
218
+ expect(track.className).toContain("ml-8");
219
+ expect(track.className).not.toContain("justify-center");
220
+ });
221
+ });
222
+
223
+ it("centers (no clip/scroll) when the row fits exactly in the window", () => {
224
+ // Two 392px cards + one 16px gap = 800px. A window of exactly 800px must
225
+ // center the row rather than subtracting the 32px indent, which would clip
226
+ // the last card and enable needless scrolling.
227
+ const items = Array.from({ length: 2 }, (_, i) => ({
228
+ ...sampleProduct,
229
+ sys: { id: `prod-${i}` },
230
+ }));
231
+ withOffsetWidth(800, () => {
232
+ const { container } = render(
233
+ <ProductCardCarousel fields={createProductFields(items)} />
234
+ );
235
+ fireEvent(window, new Event("resize"));
236
+ const track = container.querySelector(
237
+ '[style*="translateX"]'
238
+ ) as HTMLElement;
239
+ expect(track.className).toContain("justify-center");
240
+ expect(track.className).not.toContain("ml-8");
241
+ });
242
+ });
243
+ });
244
+
245
+ describe("carousel breakpoint by layout", () => {
246
+ it("uses the sm breakpoint for single-row carousels so they go horizontal on tablets", () => {
247
+ const items = Array.from({ length: 2 }, (_, i) => ({
248
+ ...sampleProduct,
249
+ sys: { id: `prod-${i}` },
250
+ }));
251
+ const { container } = render(
252
+ <ProductCardCarousel fields={createProductFields(items)} />
253
+ );
254
+ expect(container.querySelector(".sm\\:hidden")).toBeInTheDocument();
255
+ expect(container.querySelector(".sm\\:block")).toBeInTheDocument();
256
+ // Not gated behind md, so the row goes horizontal from the sm breakpoint.
257
+ expect(container.querySelector(".md\\:hidden")).not.toBeInTheDocument();
258
+ expect(container.querySelector(".md\\:block")).not.toBeInTheDocument();
259
+ });
260
+
261
+ it("keeps the md breakpoint for two-column carousels so the tablet grid is preserved", () => {
262
+ const items = Array.from({ length: 4 }, (_, i) => ({
263
+ ...sampleProduct,
264
+ sys: { id: `prod-${i}` },
265
+ }));
266
+ const { container } = render(
267
+ <ProductCardCarousel fields={createProductFields(items)} />
268
+ );
269
+ expect(container.querySelector(".md\\:hidden")).toBeInTheDocument();
270
+ expect(container.querySelector(".md\\:block")).toBeInTheDocument();
271
+ expect(container.querySelector(".sm\\:hidden")).not.toBeInTheDocument();
272
+ });
177
273
  });
178
274
 
179
275
  it("renders a two-column grid on mobile when more than 3 cards", () => {
@@ -42,7 +42,11 @@ function ProductCardPanel({
42
42
  // When unset, fall back to the count heuristic: two-column once there are
43
43
  // more than 3 cards; 3 or fewer stack vertically.
44
44
  const twoColumn = fields?.twoColumn ?? items.length > 3;
45
- const isCarousel = items.length > 2;
45
+ // Product cards render as a horizontal carousel whenever there is more than
46
+ // one card so they stay side by side (horizontal) at tablet/desktop widths,
47
+ // matching the legacy residential layout, instead of wrapping to a vertical
48
+ // stack. A lone card renders centered via the non-carousel branch.
49
+ const isCarousel = items.length > 1;
46
50
  // When any card is flagged best-value, every card reserves the banner height
47
51
  // so plan name/price stay aligned across a two-column row.
48
52
  const hasAnyBadge = items.some((item: any) => item?.highlighted);
@@ -67,6 +71,14 @@ function ProductCardPanel({
67
71
  const containerWidth = windowRef.current?.offsetWidth || 0;
68
72
  // Skip while hidden (offsetWidth is 0) to avoid overestimating maxIndex
69
73
  if (containerWidth === 0) return;
74
+ // If the whole row already fits, center it (maxIndex 0) rather than
75
+ // subtracting the ml-8 scroll indent — doing so on an exact fit would
76
+ // otherwise clip the last card and enable needless scrolling.
77
+ const fullRowWidth = items.length * cardWidth - gap; // no trailing gap
78
+ if (fullRowWidth <= containerWidth) {
79
+ setMaxIndex(0);
80
+ return;
81
+ }
70
82
  const usableWidth = containerWidth - trackMargin;
71
83
  const visibleCards = Math.max(
72
84
  1,
@@ -143,8 +155,12 @@ function ProductCardPanel({
143
155
  useEffect(() => {
144
156
  if (!isCarousel || !isVisible) return;
145
157
 
158
+ // Capture the ref array once so the resize handler and cleanup operate on a
159
+ // stable reference (satisfies react-hooks/exhaustive-deps for the cleanup).
160
+ const cardsSnapshot = cardsRef.current;
161
+
146
162
  const equalizeHeights = () => {
147
- const cards = cardsRef.current.filter(Boolean) as HTMLDivElement[];
163
+ const cards = cardsSnapshot.filter(Boolean) as HTMLDivElement[];
148
164
  if (cards.length === 0) return;
149
165
 
150
166
  // Reset heights first so each card reports its natural height
@@ -152,6 +168,30 @@ function ProductCardPanel({
152
168
  card.style.height = "auto";
153
169
  });
154
170
 
171
+ // Equalize the plan-name/price header AND the description across cards so
172
+ // the "Select plan" CTA below them starts at the same Y. A longer plan
173
+ // name / wider price (e.g. "$34" vs "$19") can wrap to an extra header
174
+ // line, and below md the fixed description height (md:h-[56px]) does not
175
+ // apply, so differing description lengths would otherwise leave the CTAs
176
+ // at different heights. Reserve the tallest height for each section.
177
+ const equalizeSection = (selector: string) => {
178
+ const els = cards
179
+ .map(card => card.querySelector<HTMLElement>(selector))
180
+ .filter(Boolean) as HTMLElement[];
181
+ els.forEach(el => el.style.removeProperty("min-height"));
182
+ const max = Math.max(
183
+ 0,
184
+ ...els.map(el => el.getBoundingClientRect().height)
185
+ );
186
+ if (max > 0) {
187
+ els.forEach(el =>
188
+ el.style.setProperty("min-height", `${max}px`, "important")
189
+ );
190
+ }
191
+ };
192
+ equalizeSection("[data-pc-header]");
193
+ equalizeSection("[data-pc-description]");
194
+
155
195
  // Find the tallest card
156
196
  const maxHeight = Math.max(...cards.map(card => card.offsetHeight));
157
197
 
@@ -176,6 +216,14 @@ function ProductCardPanel({
176
216
  cancelAnimationFrame(rafId);
177
217
  clearTimeout(timeoutId);
178
218
  window.removeEventListener("resize", equalizeHeights);
219
+ (cardsSnapshot.filter(Boolean) as HTMLDivElement[]).forEach(card => {
220
+ card
221
+ .querySelector<HTMLElement>("[data-pc-header]")
222
+ ?.style.removeProperty("min-height");
223
+ card
224
+ .querySelector<HTMLElement>("[data-pc-description]")
225
+ ?.style.removeProperty("min-height");
226
+ });
179
227
  };
180
228
  }, [isCarousel, isVisible, desktopExpanded, items.length]);
181
229
 
@@ -420,7 +468,7 @@ function ProductCardPanel({
420
468
  : "flex flex-wrap justify-center"
421
469
  )}
422
470
  >
423
- {items.map((item, index) => renderCard(item, index, twoColumn))}
471
+ {items.map((item, index) => renderCard(item, index, true))}
424
472
  </div>
425
473
  ) : (
426
474
  <>
@@ -428,7 +476,12 @@ function ProductCardPanel({
428
476
  <div
429
477
  ref={mobileGridRef}
430
478
  className={cx(
431
- "md:hidden",
479
+ // Two-column layouts keep their stacked/grid view until md (768px)
480
+ // so the tablet 2-up grid and its CTA alignment are preserved;
481
+ // single-row layouts switch to the horizontal carousel from sm
482
+ // (640px) so they stay horizontal on tablets (matching the legacy
483
+ // residential layout) instead of wrapping to a vertical stack.
484
+ twoColumn ? "md:hidden" : "sm:hidden",
432
485
  twoColumn ? "grid grid-cols-2 gap-4" : "flex flex-col gap-6"
433
486
  )}
434
487
  >
@@ -436,10 +489,19 @@ function ProductCardPanel({
436
489
  </div>
437
490
 
438
491
  {/* Desktop View: Horizontal Carousel */}
439
- <div className="relative hidden w-full md:block">
440
- {/* Navigation Arrows */}
492
+ <div
493
+ className={cx(
494
+ "relative hidden w-full",
495
+ twoColumn ? "md:block" : "sm:block"
496
+ )}
497
+ >
498
+ {/* Navigation Arrows. Below md the carousel can appear as early as
499
+ sm (single-row layouts), where the enclosing block has little
500
+ horizontal room and overflow-hidden; keep the arrows at the
501
+ window edges (inside) there, and restore the outset desktop
502
+ positioning from md up. */}
441
503
  {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">
504
+ <div className="pointer-events-none absolute left-0 right-0 top-[50%] z-30 flex -translate-y-1/2 justify-between px-4 md:-left-16 md:-right-16 md:px-10">
443
505
  {currentIndex > 0 ? (
444
506
  <Button
445
507
  onClick={prevSlide}
@@ -477,7 +539,12 @@ function ProductCardPanel({
477
539
  >
478
540
  <div
479
541
  className={cx(
480
- "ml-8 flex items-stretch gap-4 pb-4",
542
+ "flex items-stretch gap-4 py-4",
543
+ // Center the row when every card already fits (no scrolling
544
+ // needed) so a short row (e.g. two cards) sits under the
545
+ // centered title; only indent for the scrollable state so the
546
+ // first card clears the left arrow.
547
+ maxIndex === 0 ? "justify-center" : "ml-8",
481
548
  isSwiping
482
549
  ? "transition-none"
483
550
  : "transition-transform duration-500 ease-in-out"
@@ -75,7 +75,7 @@ export const FloatingBanner: React.FC<FloatingBannerProps> = ({
75
75
  {cta && (
76
76
  <div className="mt-5 flex flex-shrink-0 justify-center lg:mt-0 lg:items-center">
77
77
  <Button
78
- buttonVariant="secondary"
78
+ buttonVariant="secondary"
79
79
  {...cta}
80
80
  size={{ base: "medium", md: "large" }}
81
81
  ></Button>