@windstream/react-shared-components 0.2.42 → 0.2.43

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.
@@ -14,17 +14,31 @@ jest.mock("next/navigation", () => ({
14
14
  }));
15
15
 
16
16
  jest.mock("@shared/components/pagination", () => ({
17
- Pagination: ({ currentPage, totalPages, onPageChange, ariaLabel }: any) => (
17
+ Pagination: ({
18
+ currentPage,
19
+ totalPages,
20
+ onPageChange,
21
+ getPageHref,
22
+ ariaLabel,
23
+ }: any) => (
18
24
  <nav aria-label={ariaLabel} data-testid="pagination">
19
25
  <span>
20
26
  Page {currentPage} of {totalPages}
21
27
  </span>
22
- <button data-testid="page-next" onClick={() => onPageChange(2)}>
28
+ <a
29
+ data-testid="page-next"
30
+ href={getPageHref?.(2)}
31
+ onClick={() => onPageChange(2)}
32
+ >
23
33
  Next
24
- </button>
25
- <button data-testid="page-first" onClick={() => onPageChange(1)}>
34
+ </a>
35
+ <a
36
+ data-testid="page-first"
37
+ href={getPageHref?.(1)}
38
+ onClick={() => onPageChange(1)}
39
+ >
26
40
  First
27
- </button>
41
+ </a>
28
42
  </nav>
29
43
  ),
30
44
  }));
@@ -222,21 +236,38 @@ describe("BlogGrid", () => {
222
236
  });
223
237
 
224
238
  describe("Pagination", () => {
225
- it("navigates with page param on page change", () => {
239
+ it("builds page href with page param", () => {
226
240
  render(<BlogGrid {...defaultProps} />);
227
- fireEvent.click(screen.getByTestId("page-next"));
228
- expect(mockPush).toHaveBeenCalledWith("/blog?page=2", { scroll: false });
241
+ expect(screen.getByTestId("page-next")).toHaveAttribute(
242
+ "href",
243
+ "/blog?page=2"
244
+ );
245
+ });
246
+
247
+ it("removes page param when building href for page 1", () => {
248
+ mockSearchParams = new URLSearchParams("page=2");
249
+ render(<BlogGrid {...defaultProps} currentPage={2} />);
250
+ expect(screen.getByTestId("page-first")).toHaveAttribute("href", "/blog");
251
+ });
252
+
253
+ it("keeps other params when building page href", () => {
254
+ mockSearchParams = new URLSearchParams("category=tech");
255
+ render(<BlogGrid {...defaultProps} />);
256
+ expect(screen.getByTestId("page-first")).toHaveAttribute(
257
+ "href",
258
+ "/blog?category=tech"
259
+ );
260
+ });
261
+
262
+ it("scrolls to top when currentPage changes", () => {
263
+ const { rerender } = render(<BlogGrid {...defaultProps} />);
264
+ expect(window.scrollTo).not.toHaveBeenCalled();
265
+ rerender(<BlogGrid {...defaultProps} currentPage={2} />);
229
266
  expect(window.scrollTo).toHaveBeenCalledWith({
230
267
  top: 0,
231
268
  behavior: "smooth",
232
269
  });
233
270
  });
234
-
235
- it("removes page param when navigating to page 1", () => {
236
- render(<BlogGrid {...defaultProps} currentPage={2} />);
237
- fireEvent.click(screen.getByTestId("page-first"));
238
- expect(mockPush).toHaveBeenCalledWith("/blog?", { scroll: false });
239
- });
240
271
  });
241
272
 
242
273
  describe("Cover image handling", () => {
@@ -99,24 +99,27 @@ describe("EmailInputBlock", () => {
99
99
  const input = screen.getByPlaceholderText("Enter your email here");
100
100
  fireEvent.change(input, { target: { value: "invalid" } });
101
101
  fireEvent.click(screen.getByText("Sign me up"));
102
- expect(
103
- screen.getByText("Please enter a valid email address.")
104
- ).toBeInTheDocument();
102
+ expect(screen.getByText("Invalid email address")).toBeInTheDocument();
103
+ expect(defaultProps.onSubmit).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("shows required error for empty email", () => {
107
+ render(<EmailInputBlock {...defaultProps} />);
108
+ fireEvent.click(screen.getByText("Sign me up"));
109
+ expect(screen.getByText("Email is required")).toBeInTheDocument();
105
110
  expect(defaultProps.onSubmit).not.toHaveBeenCalled();
106
111
  });
107
112
 
108
- it("clears error when user types after error", () => {
113
+ it("clears error when user types a valid email after error", () => {
109
114
  render(<EmailInputBlock {...defaultProps} />);
110
115
  const input = screen.getByPlaceholderText("Enter your email here");
111
116
  fireEvent.change(input, { target: { value: "invalid" } });
112
117
  fireEvent.click(screen.getByText("Sign me up"));
113
- expect(
114
- screen.getByText("Please enter a valid email address.")
115
- ).toBeInTheDocument();
118
+ expect(screen.getByText("Invalid email address")).toBeInTheDocument();
116
119
 
117
- fireEvent.change(input, { target: { value: "a" } });
120
+ fireEvent.change(input, { target: { value: "user@test.com" } });
118
121
  expect(
119
- screen.queryByText("Please enter a valid email address.")
122
+ screen.queryByText("Invalid email address")
120
123
  ).not.toBeInTheDocument();
121
124
  });
122
125
 
@@ -148,62 +151,50 @@ describe("EmailInputBlock", () => {
148
151
  expect(screen.queryByText("Thanks!")).not.toBeInTheDocument();
149
152
  });
150
153
 
151
- it("applies green background by default", () => {
154
+ it("applies green theme background by default", () => {
152
155
  const { container } = render(<EmailInputBlock {...defaultProps} />);
153
- const inner = container.querySelector(".bg-bg-fill-success");
154
- expect(inner).toBeInTheDocument();
155
- });
156
-
157
- it("applies navy background class", () => {
158
- const { container } = render(
159
- <EmailInputBlock {...defaultProps} backgroundColor="navy" />
160
- );
161
- expect(container.querySelector(".bg-bg-fill-inverse")).toBeInTheDocument();
156
+ expect(container.querySelector(".bg-bg-fill-brand")).toBeInTheDocument();
162
157
  });
163
158
 
164
- it("applies blue background class", () => {
159
+ it("applies blue theme background class", () => {
165
160
  const { container } = render(
166
- <EmailInputBlock {...defaultProps} backgroundColor="blue" />
161
+ <EmailInputBlock {...defaultProps} themeColor="blue" />
167
162
  );
168
- expect(container.querySelector(".bg-bg-fill-brand")).toBeInTheDocument();
163
+ expect(
164
+ container.querySelector(".bg-bg-fill-brand-supporting")
165
+ ).toBeInTheDocument();
169
166
  });
170
167
 
171
- it("applies yellow background class", () => {
168
+ it("applies purple theme background class", () => {
172
169
  const { container } = render(
173
- <EmailInputBlock {...defaultProps} backgroundColor="yellow" />
170
+ <EmailInputBlock {...defaultProps} themeColor="purple" />
174
171
  );
175
172
  expect(
176
- container.querySelector(".bg-bg-fill-brand-accent")
173
+ container.querySelector(".bg-bg-fill-brand-tertiary")
177
174
  ).toBeInTheDocument();
178
175
  });
179
176
 
180
- it("applies white background class", () => {
181
- const { container } = render(
182
- <EmailInputBlock {...defaultProps} backgroundColor="white" />
183
- );
184
- expect(container.querySelector(".bg-white")).toBeInTheDocument();
177
+ it("applies white outer background class by default", () => {
178
+ const { container } = render(<EmailInputBlock {...defaultProps} />);
179
+ expect(container.querySelector("section")).toHaveClass("bg-bg");
185
180
  });
186
181
 
187
- it("uses white text for green/blue/navy backgrounds", () => {
182
+ it("uses white text for dark themes", () => {
188
183
  render(
189
- <EmailInputBlock
190
- {...defaultProps}
191
- backgroundColor="navy"
192
- title="Navy title"
193
- />
184
+ <EmailInputBlock {...defaultProps} themeColor="blue" title="Blue title" />
194
185
  );
195
- expect(screen.getByText("Navy title").className).toContain("text-white");
186
+ expect(screen.getByText("Blue title").className).toContain("text-white");
196
187
  });
197
188
 
198
- it("uses dark text for white/yellow backgrounds", () => {
189
+ it("uses dark text for the white theme", () => {
199
190
  render(
200
191
  <EmailInputBlock
201
192
  {...defaultProps}
202
- backgroundColor="yellow"
203
- title="Yellow title"
193
+ themeColor="white"
194
+ title="White title"
204
195
  />
205
196
  );
206
- expect(screen.getByText("Yellow title").className).toContain("text-text");
197
+ expect(screen.getByText("White title").className).toContain("text-text");
207
198
  });
208
199
 
209
200
  it("uses custom ctaText", () => {
@@ -260,12 +260,13 @@ describe("FindKinetic", () => {
260
260
  expect(screen.queryAllByRole("list")).toHaveLength(0);
261
261
  });
262
262
 
263
- it("renders two column layout for mobile and three for desktop", () => {
263
+ it("renders two column layout for mobile, three for tablet and four for desktop", () => {
264
264
  const { container } = render(<FindKinetic list={mockList} />);
265
265
  const lists = container.querySelectorAll("ul");
266
- expect(lists).toHaveLength(2);
266
+ expect(lists).toHaveLength(3);
267
267
  expect(lists[0]).toHaveClass("grid-cols-2");
268
268
  expect(lists[1]).toHaveClass("md:grid-cols-3");
269
+ expect(lists[2]).toHaveClass("xl:grid-cols-4");
269
270
  });
270
271
 
271
272
  it("rearranges items in column-major order for 2-col layout", () => {
@@ -89,7 +89,6 @@ const defaultProps: ImagePromoBarProps = {
89
89
  cta: null,
90
90
  secondaryCta: null,
91
91
  ctaDisclaimer: null,
92
- videoLink: { link: "", image: "", videoPopup: false },
93
92
  maxWidth: true,
94
93
  color: "light",
95
94
  };
@@ -182,15 +181,15 @@ describe("ImagePromoBar", () => {
182
181
  const { container } = render(
183
182
  <ImagePromoBar {...defaultProps} mediaPosition={true} />
184
183
  );
185
- expect(container.innerHTML).toContain("lg:flex-row-reverse");
184
+ expect(container.innerHTML).toContain("xl:flex-row-reverse");
186
185
  });
187
186
 
188
187
  it("applies lg:flex-row when mediaPosition is false", () => {
189
188
  const { container } = render(
190
189
  <ImagePromoBar {...defaultProps} mediaPosition={false} />
191
190
  );
192
- expect(container.innerHTML).toContain("lg:flex-row");
193
- expect(container.innerHTML).not.toContain("lg:flex-row-reverse");
191
+ expect(container.innerHTML).toContain("xl:flex-row");
192
+ expect(container.innerHTML).not.toContain("xl:flex-row-reverse");
194
193
  });
195
194
  });
196
195
 
@@ -276,22 +275,68 @@ describe("ImagePromoBar", () => {
276
275
  });
277
276
 
278
277
  describe("Image", () => {
279
- it("renders NextImage when image is provided", () => {
278
+ it("renders the plain image when there is no videoLink content", () => {
280
279
  render(
281
280
  <ImagePromoBar {...defaultProps} image="https://img.test/hero.jpg" />
282
281
  );
283
- const img = screen.getByTestId("next-image");
284
- expect(img).toHaveAttribute("src", "https://img.test/hero.jpg");
282
+ expect(screen.getByTestId("next-image")).toHaveAttribute(
283
+ "src",
284
+ "https://img.test/hero.jpg"
285
+ );
285
286
  });
286
287
 
287
- it("does not render image section when image is empty", () => {
288
+ it("does not render the image section when image is empty", () => {
288
289
  render(<ImagePromoBar {...defaultProps} image="" />);
289
290
  expect(screen.queryByTestId("next-image")).not.toBeInTheDocument();
290
291
  });
292
+
293
+ it("does not render the plain image when videoLink has a valid href", () => {
294
+ render(
295
+ <ImagePromoBar
296
+ {...defaultProps}
297
+ image="https://img.test/hero.jpg"
298
+ videoLink={{ link: "https://youtube.com/watch?v=123" }}
299
+ />
300
+ );
301
+ expect(screen.queryByTestId("next-image")).not.toBeInTheDocument();
302
+ });
303
+
304
+ it("does not render the plain image when videoLink has its own poster image, even without a valid href", () => {
305
+ render(
306
+ <ImagePromoBar
307
+ {...defaultProps}
308
+ image="https://img.test/hero.jpg"
309
+ videoLink={{ image: "thumb.jpg" }}
310
+ />
311
+ );
312
+ expect(screen.queryByTestId("next-image")).not.toBeInTheDocument();
313
+ });
314
+
315
+ it("renders the plain image when no videoLink is attached at all", () => {
316
+ render(
317
+ <ImagePromoBar
318
+ {...defaultProps}
319
+ image="https://img.test/hero.jpg"
320
+ videoLink={undefined}
321
+ />
322
+ );
323
+ expect(screen.getByTestId("next-image")).toBeInTheDocument();
324
+ });
325
+
326
+ it("suppresses the plain image when an empty videoLink object is attached (VideoLink owns its own fallback from there)", () => {
327
+ render(
328
+ <ImagePromoBar
329
+ {...defaultProps}
330
+ image="https://img.test/hero.jpg"
331
+ videoLink={{}}
332
+ />
333
+ );
334
+ expect(screen.queryByTestId("next-image")).not.toBeInTheDocument();
335
+ });
291
336
  });
292
337
 
293
338
  describe("Video", () => {
294
- it("renders VideoLink when videoLink is provided", () => {
339
+ it("always renders VideoLink, passing videoLink through", () => {
295
340
  render(
296
341
  <ImagePromoBar
297
342
  {...defaultProps}
@@ -317,7 +362,7 @@ describe("ImagePromoBar", () => {
317
362
  );
318
363
  });
319
364
 
320
- it("renders VideoLink even when videoLink has no link (VideoLink owns the gating)", () => {
365
+ it("still renders VideoLink when videoLink has no valid href (VideoLink owns its own poster fallback)", () => {
321
366
  render(<ImagePromoBar {...defaultProps} videoLink={{}} />);
322
367
  expect(screen.getByTestId("video-link")).toBeInTheDocument();
323
368
  });
@@ -36,6 +36,8 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
36
36
  renderCheckPlans,
37
37
  enableAnimation = false,
38
38
  }) => {
39
+ const hasVideoLink = Boolean(videoLink);
40
+
39
41
  return (
40
42
  <div className="component-container shared-component-ipb pb-16 pt-16">
41
43
  <div
@@ -161,8 +163,8 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
161
163
  )}
162
164
  </div>
163
165
  <aside className="flex w-full flex-1 shrink-0 items-center justify-center lg:w-auto">
164
- {/* Media Section */}
165
- {image && (
166
+ {/* Media Section: only when no Video Link is attached at all */}
167
+ {image && !hasVideoLink && (
166
168
  <div
167
169
  className={cx(
168
170
  !useOriginalImageAspectRatio
@@ -196,6 +198,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
196
198
  imageWidth={imageWidth}
197
199
  imageHeight={imageHeight}
198
200
  useOriginalImageAspectRatio={useOriginalImageAspectRatio}
201
+ isAboveTheFold={isAboveTheFold}
199
202
  />
200
203
  </aside>
201
204
  </div>
@@ -22,7 +22,7 @@ export type ImagePromoBarProps = {
22
22
  cta: any;
23
23
  secondaryCta: any;
24
24
  ctaDisclaimer: React.ReactNode;
25
- videoLink: VideoLinkProps;
25
+ videoLink?: VideoLinkProps;
26
26
  maxWidth?: boolean;
27
27
  color: "light" | "dark";
28
28
  enableAnimation?: boolean;
@@ -52,12 +52,28 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
52
52
  const mobileVideoUrl = heroVideoMobile?.url?.trim();
53
53
  const hasDesktopVideo = !!desktopVideoUrl;
54
54
  const hasMobileVideo = !!mobileVideoUrl;
55
+ const normalizeImageUrl = (url?: string) =>
56
+ url?.startsWith("//") ? `https:${url}` : url;
57
+ const badgeImageUrl = normalizeImageUrl(badgeImage);
58
+ const heroImageUrl = normalizeImageUrl(carouselImages?.[0]);
55
59
  const bottomLinkLabel = bottomLink?.buttonLabel ?? bottomLink?.label;
56
60
  const baseTextColorClass = textColor === "dark" ? "text-text" : "text-white";
57
61
  const textColorClass =
58
62
  hasDesktopVideo && textColor === "dark"
59
63
  ? `${baseTextColorClass} xl:text-white`
60
64
  : baseTextColorClass;
65
+ const Badge = ({ className }: { className: string }) =>
66
+ badgeImageUrl ? (
67
+ <NextImage
68
+ src={badgeImageUrl}
69
+ alt="Badge"
70
+ className={className}
71
+ width={208}
72
+ height={208}
73
+ quality={90}
74
+ loading="eager"
75
+ />
76
+ ) : null;
61
77
  return (
62
78
  <div className="component-container relative overflow-hidden p-4 sm:px-6 sm:py-12 xl:flex xl:min-h-[724px] xl:items-center xl:px-10">
63
79
  {hasDesktopVideo ? (
@@ -267,23 +283,26 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
267
283
  <div className="w-full xl:hidden">
268
284
  <div className={"relative my-8"}>
269
285
  {hasMobileVideo ? (
270
- <video
271
- autoPlay={true}
272
- muted={true}
273
- loop={true}
274
- playsInline={true}
275
- preload="auto"
276
- aria-label={heroVideoMobile?.title || title || "Hero video"}
277
- className="h-[350px] w-full rounded-[40px] object-cover object-center sm:h-[420px] md:h-[520px]"
278
- data-testid="hero-video-mobile"
279
- >
280
- <source src={mobileVideoUrl} type="video/mp4" />
281
- </video>
286
+ <div className="relative">
287
+ <video
288
+ autoPlay={true}
289
+ muted={true}
290
+ loop={true}
291
+ playsInline={true}
292
+ preload="auto"
293
+ aria-label={heroVideoMobile?.title || title || "Hero video"}
294
+ className="h-[350px] w-full rounded-[40px] object-cover object-center sm:h-[420px] md:h-[520px]"
295
+ data-testid="hero-video-mobile"
296
+ >
297
+ <source src={mobileVideoUrl} type="video/mp4" />
298
+ </video>
299
+ <Badge className="absolute left-5 top-5 z-10 aspect-square w-26 object-cover object-center" />
300
+ </div>
282
301
  ) : (
283
302
  <>
284
303
  {badgeImage ? (
285
304
  <NextImage
286
- src={badgeImage}
305
+ src={badgeImageUrl || ""}
287
306
  alt={"Badge"}
288
307
  className="absolute left-5 top-5 aspect-square w-26 object-cover object-center"
289
308
  width={104}
@@ -292,9 +311,9 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
292
311
  loading="eager"
293
312
  />
294
313
  ) : null}
295
- {carouselImages && carouselImages[0] ? (
314
+ {heroImageUrl ? (
296
315
  <NextImage
297
- src={carouselImages[0]}
316
+ src={heroImageUrl}
298
317
  alt={"Hero"}
299
318
  className="h-[205px] w-full rounded-[40px] object-cover object-center sm:h-[420px]"
300
319
  width={1024}
@@ -339,32 +358,25 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
339
358
  </div>
340
359
 
341
360
  {/* Right column: bright blue background, diagonal top edge, hero image */}
342
- {!hasDesktopVideo ? (
343
- <div className={cx("relative hidden xl:block")}>
344
- {badgeImage ? (
345
- <NextImage
346
- src={badgeImage}
347
- alt={"Badge"}
348
- className="absolute -left-26 top-18 aspect-square w-52 object-cover object-center"
349
- width={208}
350
- height={208}
351
- quality={90}
352
- loading="eager"
353
- />
354
- ) : null}
355
- {carouselImages && carouselImages[0] ? (
356
- <NextImage
357
- src={carouselImages[0]}
358
- alt={"Hero"}
359
- className="aspect-square rounded-[40px] object-cover object-center"
360
- width={600}
361
- height={600}
362
- quality={90}
363
- loading="eager"
364
- />
365
- ) : null}
366
- </div>
367
- ) : null}
361
+ <div
362
+ className={cx(
363
+ "relative hidden xl:block",
364
+ hasDesktopVideo && "h-[600px] w-[600px]"
365
+ )}
366
+ >
367
+ <Badge className="absolute -left-26 top-18 z-10 aspect-square w-52 object-cover object-center" />
368
+ {!hasDesktopVideo && heroImageUrl ? (
369
+ <NextImage
370
+ src={heroImageUrl}
371
+ alt={"Hero"}
372
+ className="aspect-square rounded-[40px] object-cover object-center"
373
+ width={600}
374
+ height={600}
375
+ quality={90}
376
+ loading="eager"
377
+ />
378
+ ) : null}
379
+ </div>
368
380
  </div>
369
381
  </div>
370
382
  );
@@ -549,9 +549,10 @@ describe("useContentfulRichText", () => {
549
549
  const { container } = render(<>{result.current}</>);
550
550
  expect(container.querySelector("h1")).toHaveTextContent("H1");
551
551
  expect(container.querySelector("h2")).toHaveTextContent("H2");
552
- // h3-h6 all render as h3 per defaultOptions
553
- const h3s = container.querySelectorAll("h3");
554
- expect(h3s.length).toBe(4);
552
+ expect(container.querySelector("h3")).toHaveTextContent("H3");
553
+ expect(container.querySelector("h4")).toHaveTextContent("H4");
554
+ expect(container.querySelector("h5")).toHaveTextContent("H5");
555
+ expect(container.querySelector("h6")).toHaveTextContent("H6");
555
556
  });
556
557
 
557
558
  it("renders unordered list", () => {