@windstream/react-shared-components 0.2.65 → 0.2.67

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.
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { VideoLink } from "./index";
3
+ import * as VideoLinkBarrel from "./index";
3
4
 
4
5
  import { fireEvent, render, screen } from "@testing-library/react";
5
6
 
@@ -23,6 +24,7 @@ jest.mock("./vimeo-embed", () => ({
23
24
  jest.mock("./youtube-embed", () => ({
24
25
  YoutubeEmbed: ({
25
26
  link,
27
+ title,
26
28
  autoplay,
27
29
  onPlay,
28
30
  onPause,
@@ -30,7 +32,12 @@ jest.mock("./youtube-embed", () => ({
30
32
  onEnded,
31
33
  onFullscreenToggle,
32
34
  }: any) => (
33
- <div data-testid="youtube-embed" data-link={link} data-autoplay={autoplay}>
35
+ <div
36
+ data-testid="youtube-embed"
37
+ data-link={link}
38
+ data-title={title}
39
+ data-autoplay={autoplay}
40
+ >
34
41
  <button data-testid="yt-play" onClick={() => onPlay?.()} />
35
42
  <button data-testid="yt-pause" onClick={() => onPause?.()} />
36
43
  <button data-testid="yt-ended" onClick={() => onEnded?.()} />
@@ -101,6 +108,22 @@ describe("VideoLink", () => {
101
108
  expect(screen.queryByTestId("play-button")).not.toBeInTheDocument();
102
109
  });
103
110
 
111
+ it("renders the fallback poster using the original aspect ratio and custom dimensions", () => {
112
+ render(
113
+ <VideoLink
114
+ videoLink={{ image: "thumb.jpg" }}
115
+ useOriginalImageAspectRatio={true}
116
+ imageWidth={800}
117
+ imageHeight={450}
118
+ />
119
+ );
120
+ expect(screen.getByAltText("section-image")).toHaveAttribute(
121
+ "src",
122
+ "thumb.jpg"
123
+ );
124
+ expect(screen.queryByTestId("play-button")).not.toBeInTheDocument();
125
+ });
126
+
104
127
  it("ignores an unrelated fallbackImage-like prop - only videoLink.image is used as the poster", () => {
105
128
  const { container } = render(
106
129
  // @ts-expect-error - fallbackImage is intentionally not a supported prop
@@ -436,3 +459,96 @@ describe("VideoLink analytics", () => {
436
459
  expect(progressCalls).toHaveLength(1);
437
460
  });
438
461
  });
462
+
463
+ describe("VideoLink accessibility & SEO", () => {
464
+ const baseVideoLink = {
465
+ link: "https://youtube.com/watch?v=123",
466
+ image: "thumb.jpg",
467
+ title: "eero Pro 7 Introduction Video",
468
+ };
469
+
470
+ it("uses the video title in the preview image alt text", () => {
471
+ render(<VideoLink videoLink={baseVideoLink} />);
472
+ expect(
473
+ screen.getByAltText("eero Pro 7 Introduction Video video preview")
474
+ ).toBeInTheDocument();
475
+ });
476
+
477
+ it("falls back to generic preview alt text when no title is provided", () => {
478
+ render(
479
+ <VideoLink
480
+ videoLink={{ link: "https://youtube.com/watch?v=123", image: "t.jpg" }}
481
+ />
482
+ );
483
+ expect(screen.getByAltText("Video preview")).toBeInTheDocument();
484
+ });
485
+
486
+ it("exposes the section as a labelled button for keyboard/AT users on page load", () => {
487
+ const { container } = render(<VideoLink videoLink={baseVideoLink} />);
488
+ const section = container.querySelector(".video-section")!;
489
+ expect(section).toHaveAttribute("role", "button");
490
+ expect(section).toHaveAttribute("tabindex", "0");
491
+ expect(section).toHaveAttribute(
492
+ "aria-label",
493
+ "Play video: eero Pro 7 Introduction Video"
494
+ );
495
+ });
496
+
497
+ it("hides the decorative play button from the accessibility tree", () => {
498
+ render(<VideoLink videoLink={baseVideoLink} />);
499
+ const playButton = screen.getByTestId("play-button");
500
+ expect(playButton.parentElement).toHaveAttribute("aria-hidden", "true");
501
+ });
502
+
503
+ it("starts playback when Enter is pressed on the section", () => {
504
+ const { container } = render(<VideoLink videoLink={baseVideoLink} />);
505
+ fireEvent.keyDown(container.querySelector(".video-section")!, {
506
+ key: "Enter",
507
+ });
508
+ expect(screen.getByTestId("youtube-embed")).toHaveAttribute(
509
+ "data-autoplay",
510
+ "true"
511
+ );
512
+ });
513
+
514
+ it("starts playback when Space is pressed on the section", () => {
515
+ const { container } = render(<VideoLink videoLink={baseVideoLink} />);
516
+ fireEvent.keyDown(container.querySelector(".video-section")!, {
517
+ key: " ",
518
+ });
519
+ expect(screen.getByTestId("youtube-embed")).toHaveAttribute(
520
+ "data-autoplay",
521
+ "true"
522
+ );
523
+ });
524
+
525
+ it("drops button semantics from the section once the video is playing", () => {
526
+ const { container } = render(<VideoLink videoLink={baseVideoLink} />);
527
+ clickVideoSection(container);
528
+ const section = container.querySelector(".video-section")!;
529
+ expect(section).not.toHaveAttribute("role");
530
+ expect(section).not.toHaveAttribute("tabindex");
531
+ });
532
+
533
+ it("passes the video title through to the youtube embed", () => {
534
+ render(<VideoLink videoLink={baseVideoLink} />);
535
+ // The embed mock is mounted up-front for SEO even before play.
536
+ expect(screen.getByTestId("youtube-embed")).toHaveAttribute(
537
+ "data-title",
538
+ "eero Pro 7 Introduction Video"
539
+ );
540
+ });
541
+ });
542
+
543
+ describe("VideoLink public exports", () => {
544
+ it("re-exports the embed building blocks and helpers from the barrel", () => {
545
+ expect(VideoLinkBarrel.VideoLink).toBeDefined();
546
+ expect(VideoLinkBarrel.default).toBe(VideoLinkBarrel.VideoLink);
547
+ expect(VideoLinkBarrel.ContentfulVideoEmbed).toBeDefined();
548
+ expect(VideoLinkBarrel.PlayButton).toBeDefined();
549
+ expect(VideoLinkBarrel.VimeoEmbed).toBeDefined();
550
+ expect(VideoLinkBarrel.getVimeoEmbedId).toBeDefined();
551
+ expect(VideoLinkBarrel.YoutubeEmbed).toBeDefined();
552
+ expect(VideoLinkBarrel.getYoutubeEmbedId).toBeDefined();
553
+ });
554
+ });
@@ -107,6 +107,25 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
107
107
  setActiveVideo("");
108
108
  };
109
109
 
110
+ // Handle keydown events for accessibility (Enter or Space to play)
111
+ const handleSectionKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
112
+ if (isPlaying) return;
113
+ if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
114
+ e.preventDefault();
115
+ handlePlayClick();
116
+ }
117
+ };
118
+
119
+ // Descriptive labels for crawlers/assistive tech, readable on page load
120
+ const videoTitle =
121
+ typeof videoLink?.title === "string" ? videoLink.title.trim() : "";
122
+ const previewAltText = videoTitle
123
+ ? `${videoTitle} video preview`
124
+ : "Video preview";
125
+ const sectionAriaLabel = videoTitle
126
+ ? `Play video: ${videoTitle}`
127
+ : "Play video";
128
+
110
129
  const embedSelector = () => {
111
130
  const analyticsProps = {
112
131
  onPlay: () => videoAnalytics.handlePlay(analyticsMeta),
@@ -140,6 +159,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
140
159
  <VimeoEmbed
141
160
  key={playbackKey}
142
161
  link={videoHref}
162
+ title={videoLink?.title}
143
163
  autoplay={isPlaying}
144
164
  {...analyticsProps}
145
165
  />
@@ -149,6 +169,7 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
149
169
  <YoutubeEmbed
150
170
  key={playbackKey}
151
171
  link={videoHref}
172
+ title={videoLink?.title}
152
173
  autoplay={isPlaying}
153
174
  {...analyticsProps}
154
175
  />
@@ -161,11 +182,16 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
161
182
  <div
162
183
  className={cx(
163
184
  "video-section relative w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
185
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus",
164
186
  !isPlaying && "hover:ring-2 hover:ring-border-focus"
165
187
  )}
166
188
  onClick={handlePlayClick}
189
+ onKeyDown={handleSectionKeyDown}
167
190
  onMouseEnter={() => setIsHovered(true)}
168
191
  onMouseLeave={() => setIsHovered(false)}
192
+ role={!isPlaying ? "button" : undefined}
193
+ tabIndex={!isPlaying ? 0 : undefined}
194
+ aria-label={!isPlaying ? sectionAriaLabel : undefined}
169
195
  >
170
196
  {/* Preview Image & Play Button */}
171
197
  <div
@@ -178,14 +204,17 @@ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
178
204
  {posterImage && (
179
205
  <NextImage
180
206
  src={posterImage}
181
- alt="Video preview"
207
+ alt={previewAltText}
182
208
  width={imageWidth || 660}
183
209
  height={imageHeight || 660}
184
210
  sizes="(max-width: 430px) 100vw, (max-width: 834px) 100vw, 50vw"
185
211
  className="h-auto w-full rounded-image"
186
212
  />
187
213
  )}
188
- <div className="absolute inset-0 flex items-center justify-center">
214
+ <div
215
+ aria-hidden="true"
216
+ className="pointer-events-none absolute inset-0 flex items-center justify-center"
217
+ >
189
218
  <PlayButton isHovered={isHovered} />
190
219
  </div>
191
220
  </div>
@@ -0,0 +1,112 @@
1
+ import React from "react";
2
+ import { getVimeoEmbedId, VimeoEmbed } from "./vimeo-embed";
3
+
4
+ import { render } from "@testing-library/react";
5
+ import Player from "@vimeo/player";
6
+
7
+ jest.mock("@shared/utils", () => ({
8
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
9
+ }));
10
+
11
+ jest.mock("@vimeo/player", () => ({
12
+ __esModule: true,
13
+ default: jest.fn(),
14
+ }));
15
+
16
+ const handlers: Record<string, (...args: any[]) => void> = {};
17
+ const mockOn = jest.fn((event: string, cb: (...args: any[]) => void) => {
18
+ handlers[event] = cb;
19
+ });
20
+ const mockOff = jest.fn();
21
+ const mockDestroy = jest.fn(() => Promise.resolve());
22
+
23
+ describe("VimeoEmbed analytics", () => {
24
+ beforeEach(() => {
25
+ Object.keys(handlers).forEach(key => delete handlers[key]);
26
+ mockOn.mockClear();
27
+ mockOff.mockClear();
28
+ mockDestroy.mockClear();
29
+ (Player as unknown as jest.Mock).mockImplementation(() => ({
30
+ on: mockOn,
31
+ off: mockOff,
32
+ destroy: mockDestroy,
33
+ }));
34
+ });
35
+
36
+ it("forwards player events to the analytics callbacks", () => {
37
+ const onPlay = jest.fn();
38
+ const onPause = jest.fn();
39
+ const onEnded = jest.fn();
40
+ const onTimeUpdate = jest.fn();
41
+ const onFullscreenToggle = jest.fn();
42
+
43
+ render(
44
+ <VimeoEmbed
45
+ link="https://vimeo.com/123456789"
46
+ onPlay={onPlay}
47
+ onPause={onPause}
48
+ onEnded={onEnded}
49
+ onTimeUpdate={onTimeUpdate}
50
+ onFullscreenToggle={onFullscreenToggle}
51
+ />
52
+ );
53
+
54
+ handlers.play();
55
+ handlers.pause();
56
+ handlers.ended();
57
+ handlers.timeupdate({ seconds: 5, duration: 10 });
58
+ handlers.fullscreenchange({ fullscreen: true });
59
+
60
+ expect(onPlay).toHaveBeenCalledTimes(1);
61
+ expect(onPause).toHaveBeenCalledTimes(1);
62
+ expect(onEnded).toHaveBeenCalledTimes(1);
63
+ expect(onTimeUpdate).toHaveBeenCalledWith(5, 10);
64
+ expect(onFullscreenToggle).toHaveBeenCalledWith(true);
65
+ });
66
+
67
+ it("does not throw when events fire without analytics callbacks", () => {
68
+ render(<VimeoEmbed link="https://vimeo.com/123456789" />);
69
+ expect(() => {
70
+ handlers.play();
71
+ handlers.pause();
72
+ handlers.ended();
73
+ handlers.timeupdate({ seconds: 1, duration: 2 });
74
+ handlers.fullscreenchange({ fullscreen: false });
75
+ }).not.toThrow();
76
+ });
77
+
78
+ it("detaches every listener and destroys the player on unmount", () => {
79
+ const { unmount } = render(
80
+ <VimeoEmbed link="https://vimeo.com/123456789" />
81
+ );
82
+ expect(mockOn).toHaveBeenCalledTimes(5);
83
+
84
+ unmount();
85
+
86
+ expect(mockOff).toHaveBeenCalledTimes(5);
87
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("does not instantiate a player for an invalid vimeo link", () => {
91
+ (Player as unknown as jest.Mock).mockClear();
92
+ render(<VimeoEmbed link="https://example.com/notavideo" />);
93
+ expect(Player as unknown as jest.Mock).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ describe("getVimeoEmbedId", () => {
98
+ it("returns an empty string for a missing link", () => {
99
+ expect(getVimeoEmbedId()).toBe("");
100
+ expect(getVimeoEmbedId("")).toBe("");
101
+ });
102
+
103
+ it("extracts the id from a standard vimeo url", () => {
104
+ expect(getVimeoEmbedId("https://vimeo.com/123456789")).toBe("123456789");
105
+ });
106
+
107
+ it("extracts the id and hash from a url with a private hash", () => {
108
+ expect(getVimeoEmbedId("https://vimeo.com/123456789/abcdef")).toBe(
109
+ "123456789/abcdef"
110
+ );
111
+ });
112
+ });
@@ -23,6 +23,19 @@ describe("VimeoEmbed", () => {
23
23
  expect(container.innerHTML).toBe("");
24
24
  });
25
25
 
26
+ it("uses the video title as the iframe title when provided", () => {
27
+ render(
28
+ <VimeoEmbed
29
+ link="https://vimeo.com/123456789"
30
+ title="Wi-Fi Manager Walkthrough"
31
+ />
32
+ );
33
+ expect(
34
+ screen.getByTitle("Wi-Fi Manager Walkthrough")
35
+ ).toBeInTheDocument();
36
+ expect(screen.queryByTitle("Embedded vimeo")).not.toBeInTheDocument();
37
+ });
38
+
26
39
  it("renders null when link is undefined", () => {
27
40
  const { container } = render(<VimeoEmbed link={undefined} />);
28
41
  expect(container.innerHTML).toBe("");
@@ -36,6 +36,7 @@ export const getVimeoEmbedId = (link?: string): string => {
36
36
  export const VimeoEmbed: React.FC<VideoEmbedProps> = props => {
37
37
  const {
38
38
  link,
39
+ title,
39
40
  containerClassName,
40
41
  autoplay = false,
41
42
  debug = false,
@@ -122,7 +123,7 @@ export const VimeoEmbed: React.FC<VideoEmbedProps> = props => {
122
123
  allow="autoplay; fullscreen; picture-in-picture"
123
124
  allowFullScreen={true}
124
125
  referrerPolicy="strict-origin-when-cross-origin"
125
- title="Embedded vimeo"
126
+ title={title || "Embedded vimeo"}
126
127
  />
127
128
  </div>
128
129
  );
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
3
+ import { loadYoutubeIframeApi } from "./youtube-iframe-api-loader";
3
4
 
4
5
  import { render, screen, waitFor } from "@testing-library/react";
5
6
 
@@ -13,16 +14,22 @@ let capturedVideoId: string | undefined;
13
14
  let capturedPlayerVars: any;
14
15
 
15
16
  jest.mock("./youtube-iframe-api-loader", () => ({
16
- loadYoutubeIframeApi: () =>
17
+ loadYoutubeIframeApi: jest.fn(() =>
17
18
  Promise.resolve({
18
19
  PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 },
19
20
  Player: jest.fn().mockImplementation((_el, options) => {
20
21
  capturedEvents = options.events;
21
22
  capturedVideoId = options.videoId;
22
23
  capturedPlayerVars = options.playerVars;
23
- return { playVideo: mockPlayVideo, destroy: jest.fn() };
24
+ return {
25
+ playVideo: mockPlayVideo,
26
+ destroy: jest.fn(),
27
+ getDuration: () => 120,
28
+ getCurrentTime: () => 30,
29
+ };
24
30
  }),
25
- }),
31
+ })
32
+ ),
26
33
  }));
27
34
 
28
35
  describe("getYoutubeEmbedId", () => {
@@ -71,6 +78,19 @@ describe("YoutubeEmbed", () => {
71
78
  expect(screen.getByTitle("Embedded youtube")).toBeInTheDocument();
72
79
  });
73
80
 
81
+ it("uses the video title as the iframe title when provided", () => {
82
+ render(
83
+ <YoutubeEmbed
84
+ link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
85
+ title="eero Pro 7 Introduction Video"
86
+ />
87
+ );
88
+ expect(
89
+ screen.getByTitle("eero Pro 7 Introduction Video")
90
+ ).toBeInTheDocument();
91
+ expect(screen.queryByTitle("Embedded youtube")).not.toBeInTheDocument();
92
+ });
93
+
74
94
  it("renders a real iframe whose src carries the videoId, enablejsapi and autoplay/mute off by default", () => {
75
95
  render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
76
96
  const iframe = screen.getByTitle("Embedded youtube") as HTMLIFrameElement;
@@ -115,6 +135,45 @@ describe("YoutubeEmbed", () => {
115
135
  expect(onEnded).toHaveBeenCalled();
116
136
  });
117
137
 
138
+ it("emits timeupdate progress on an interval while playing", async () => {
139
+ const onTimeUpdate = jest.fn();
140
+ render(
141
+ <YoutubeEmbed
142
+ link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
143
+ onTimeUpdate={onTimeUpdate}
144
+ />
145
+ );
146
+ await waitFor(() => expect(capturedEvents).toBeDefined());
147
+
148
+ jest.useFakeTimers();
149
+ try {
150
+ capturedEvents.onStateChange({ data: 1 }); // PLAYING -> starts interval
151
+ jest.advanceTimersByTime(1000);
152
+ expect(onTimeUpdate).toHaveBeenCalledWith(30, 120);
153
+
154
+ // Pausing clears the interval, so no further ticks are reported.
155
+ onTimeUpdate.mockClear();
156
+ capturedEvents.onStateChange({ data: 2 }); // PAUSED
157
+ jest.advanceTimersByTime(3000);
158
+ expect(onTimeUpdate).not.toHaveBeenCalled();
159
+ } finally {
160
+ jest.useRealTimers();
161
+ }
162
+ });
163
+
164
+ it("destroys the player on unmount", async () => {
165
+ const { unmount } = render(
166
+ <YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
167
+ );
168
+ await waitFor(() => expect(capturedEvents).toBeDefined());
169
+ const loader = loadYoutubeIframeApi as jest.Mock;
170
+ const api = await loader.mock.results[loader.mock.results.length - 1].value;
171
+ const destroy = (api.Player as jest.Mock).mock.results[0].value.destroy;
172
+ expect(destroy).not.toHaveBeenCalled();
173
+ unmount();
174
+ expect(destroy).toHaveBeenCalledTimes(1);
175
+ });
176
+
118
177
  it("applies containerClassName", () => {
119
178
  const { container } = render(
120
179
  <YoutubeEmbed
@@ -132,4 +191,17 @@ describe("YoutubeEmbed", () => {
132
191
  });
133
192
  expect(capturedVideoId).toBeUndefined();
134
193
  });
194
+
195
+ it("still renders the iframe when the IFrame API fails to load", async () => {
196
+ (loadYoutubeIframeApi as jest.Mock).mockRejectedValueOnce(
197
+ new Error("network down")
198
+ );
199
+ render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
200
+
201
+ // The API never initialises, but the iframe is still present so the video
202
+ // can play on its own.
203
+ const iframe = await screen.findByTitle("Embedded youtube");
204
+ expect(iframe).toBeInTheDocument();
205
+ expect(capturedEvents).toBeUndefined();
206
+ });
135
207
  });
@@ -26,6 +26,7 @@ export const getYoutubeEmbedId = (link?: string): string => {
26
26
  export const YoutubeEmbed: React.FC<Props> = props => {
27
27
  const {
28
28
  link,
29
+ title,
29
30
  containerClassName,
30
31
  autoplay = false,
31
32
  onPlay,
@@ -117,7 +118,7 @@ export const YoutubeEmbed: React.FC<Props> = props => {
117
118
  ref={iframeRef}
118
119
  className="absolute left-0 top-0 h-full w-full"
119
120
  src={src}
120
- title="Embedded youtube"
121
+ title={title || "Embedded youtube"}
121
122
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
122
123
  allowFullScreen={true}
123
124
  referrerPolicy="strict-origin-when-cross-origin"
@@ -0,0 +1,108 @@
1
+ describe("loadYoutubeIframeApi", () => {
2
+ const API_SRC = 'script[src*="youtube.com/iframe_api"]';
3
+
4
+ const importLoader = () =>
5
+ require("./youtube-iframe-api-loader")
6
+ .loadYoutubeIframeApi as () => Promise<any>;
7
+
8
+ beforeEach(() => {
9
+ jest.resetModules();
10
+ document.head.innerHTML = "";
11
+ delete (window as any).YT;
12
+ delete (window as any).onYouTubeIframeAPIReady;
13
+ });
14
+
15
+ afterEach(() => {
16
+ document.head.innerHTML = "";
17
+ delete (window as any).YT;
18
+ delete (window as any).onYouTubeIframeAPIReady;
19
+ });
20
+
21
+ it("resolves immediately when the API is already loaded", async () => {
22
+ (window as any).YT = { Player: function () {} };
23
+ const loadYoutubeIframeApi = importLoader();
24
+
25
+ await expect(loadYoutubeIframeApi()).resolves.toBe((window as any).YT);
26
+ // Already available, so it must not inject a script.
27
+ expect(document.querySelector(API_SRC)).toBeNull();
28
+ });
29
+
30
+ it("injects the API script and resolves when onYouTubeIframeAPIReady fires", async () => {
31
+ const loadYoutubeIframeApi = importLoader();
32
+ const promise = loadYoutubeIframeApi();
33
+
34
+ const script = document.querySelector<HTMLScriptElement>(API_SRC);
35
+ expect(script).not.toBeNull();
36
+ expect(script!.src).toContain("https://www.youtube.com/iframe_api");
37
+
38
+ (window as any).YT = { Player: function () {} };
39
+ (window as any).onYouTubeIframeAPIReady();
40
+
41
+ await expect(promise).resolves.toBe((window as any).YT);
42
+ });
43
+
44
+ it("chains a pre-existing onYouTubeIframeAPIReady callback", async () => {
45
+ const previous = jest.fn();
46
+ (window as any).onYouTubeIframeAPIReady = previous;
47
+
48
+ const loadYoutubeIframeApi = importLoader();
49
+ const promise = loadYoutubeIframeApi();
50
+
51
+ (window as any).YT = { Player: function () {} };
52
+ (window as any).onYouTubeIframeAPIReady();
53
+
54
+ await promise;
55
+ expect(previous).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it("caches the promise and injects only one script across calls", () => {
59
+ const loadYoutubeIframeApi = importLoader();
60
+ const first = loadYoutubeIframeApi();
61
+ const second = loadYoutubeIframeApi();
62
+
63
+ expect(second).toBe(first);
64
+ expect(document.querySelectorAll(API_SRC)).toHaveLength(1);
65
+ });
66
+
67
+ it("reuses an existing API script tag instead of appending another", () => {
68
+ const existing = document.createElement("script");
69
+ existing.src = "https://www.youtube.com/iframe_api";
70
+ document.head.appendChild(existing);
71
+
72
+ const loadYoutubeIframeApi = importLoader();
73
+ loadYoutubeIframeApi();
74
+
75
+ expect(document.querySelectorAll(API_SRC)).toHaveLength(1);
76
+ });
77
+
78
+ it("rejects and allows a retry when the script fails to load", async () => {
79
+ const loadYoutubeIframeApi = importLoader();
80
+ const promise = loadYoutubeIframeApi();
81
+
82
+ const script = document.querySelector<HTMLScriptElement>(API_SRC)!;
83
+ script.onerror!(new Event("error"));
84
+
85
+ await expect(promise).rejects.toThrow("Failed to load YouTube IFrame API");
86
+
87
+ // The cached promise is cleared, so a later call starts a fresh attempt.
88
+ document.head.innerHTML = "";
89
+ const retry = loadYoutubeIframeApi();
90
+ expect(retry).not.toBe(promise);
91
+ expect(document.querySelector(API_SRC)).not.toBeNull();
92
+ });
93
+
94
+ it("rejects outside a browser (no window)", async () => {
95
+ const originalWindow = global.window;
96
+ // @ts-expect-error - simulate a server-side (non-browser) environment
97
+ delete global.window;
98
+ try {
99
+ jest.resetModules();
100
+ const loadYoutubeIframeApi = importLoader();
101
+ await expect(loadYoutubeIframeApi()).rejects.toThrow(
102
+ "YouTube IFrame API requires a browser"
103
+ );
104
+ } finally {
105
+ global.window = originalWindow;
106
+ }
107
+ });
108
+ });
@@ -185,7 +185,7 @@ const CartRetentionBanner: React.FC<{
185
185
  )}
186
186
  <div
187
187
  className={cx(
188
- "flex w-full pt-5",
188
+ "flex w-full pt-5 md:px-4",
189
189
  hasSideImage ? "justify-center md:justify-start" : "justify-center"
190
190
  )}
191
191
  >
@@ -286,7 +286,11 @@ const CartRetentionBanner: React.FC<{
286
286
  {hasSideImage ? (
287
287
  wrappedBodyContent
288
288
  ) : (
289
- <div className="pop-up-body mb-5 w-full">{wrappedBodyContent}</div>
289
+ // overflow-x:clip contains the CTA hover-scale (keeps overflow-y);
290
+ // px keeps the full-width button off the modal border.
291
+ <div className="pop-up-body mb-5 w-full [overflow-x:clip] px-3 md:px-6">
292
+ {wrappedBodyContent}
293
+ </div>
290
294
  )}
291
295
  </Modal>
292
296
  </section>
@@ -32,24 +32,24 @@ describe("EmailInputBlock", () => {
32
32
  render(<EmailInputBlock {...defaultProps} title="Join us" />);
33
33
  const title = screen.getByText("Join us");
34
34
  expect(title).toBeInTheDocument();
35
- expect(title.tagName).toBe("H1");
35
+ expect(title.tagName).toBe("H2");
36
36
  });
37
37
 
38
38
  it("does not render title when not provided", () => {
39
39
  const { container } = render(<EmailInputBlock {...defaultProps} />);
40
- expect(container.querySelector("h1")).not.toBeInTheDocument();
40
+ expect(container.querySelector("h2")).not.toBeInTheDocument();
41
41
  });
42
42
 
43
43
  it("renders subTitle when provided", () => {
44
44
  render(<EmailInputBlock {...defaultProps} subTitle="Stay updated" />);
45
45
  const subtitle = screen.getByText("Stay updated");
46
46
  expect(subtitle).toBeInTheDocument();
47
- expect(subtitle.tagName).toBe("H2");
47
+ expect(subtitle.tagName).toBe("H3");
48
48
  });
49
49
 
50
50
  it("does not render subTitle when not provided", () => {
51
51
  const { container } = render(<EmailInputBlock {...defaultProps} />);
52
- expect(container.querySelector("h2")).not.toBeInTheDocument();
52
+ expect(container.querySelector("h3")).not.toBeInTheDocument();
53
53
  });
54
54
 
55
55
  it("renders emailInputDescription when provided", () => {
@@ -111,12 +111,12 @@ export const EmailInputBlock: React.FC<EmailInputBlockProps> = props => {
111
111
  <div className="w-full">
112
112
  <div className="lg:w-[548px]">
113
113
  {title && (
114
- <h1 className={`mb-3 text-[2.25rem] leading-[2.5rem] font-black md:text-heading1 md:font-black ${textColor}`}>{title}</h1>
114
+ <h2 className={`mb-3 text-[2.25rem] leading-[2.5rem] font-black md:text-heading1 md:font-black ${textColor}`}>{title}</h2>
115
115
  )}
116
116
  {subTitle && (
117
- <h2 className={`mb-3 text-heading1 font-black ${textColor}`}>
117
+ <h3 className={`mb-3 text-heading1 font-black ${textColor}`}>
118
118
  {subTitle}
119
- </h2>
119
+ </h3>
120
120
  )}
121
121
 
122
122
  {emailInputDescription && (