@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.
- package/dist/contentful/index.esm.js +4 -4
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +1 -1
- package/dist/contentful/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/contentful/blocks/button/index.tsx +4 -3
- package/src/contentful/blocks/cards/product-card/index.tsx +1 -1
- package/src/contentful/blocks/carousel/helper.test.tsx +104 -8
- package/src/contentful/blocks/carousel/helper.tsx +75 -8
- package/src/contentful/blocks/floating-banner/index.tsx +1 -1
|
@@ -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
|
|
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
|
|
169
|
-
const items = Array.from({ length:
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
440
|
-
{
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
78
|
+
buttonVariant="secondary"
|
|
79
79
|
{...cta}
|
|
80
80
|
size={{ base: "medium", md: "large" }}
|
|
81
81
|
></Button>
|