@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.
- package/dist/contentful/index.esm.js +2 -2
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +2 -2
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/video-link/contentful-video-embed.test.tsx +44 -1
- package/src/components/video-link/index.test.tsx +117 -1
- package/src/components/video-link/index.tsx +31 -2
- package/src/components/video-link/vimeo-embed.analytics.test.tsx +112 -0
- package/src/components/video-link/vimeo-embed.test.tsx +13 -0
- package/src/components/video-link/vimeo-embed.tsx +2 -1
- package/src/components/video-link/youtube-embed.test.tsx +75 -3
- package/src/components/video-link/youtube-embed.tsx +2 -1
- package/src/components/video-link/youtube-iframe-api-loader.test.ts +108 -0
- package/src/contentful/blocks/cart-retention-banner/index.tsx +6 -2
- package/src/contentful/blocks/email-input-block/index.test.tsx +4 -4
- package/src/contentful/blocks/email-input-block/index.tsx +3 -3
- package/src/contentful/blocks/modal/index.tsx +1 -1
|
@@ -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
|
|
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=
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
<
|
|
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
|
-
<
|
|
117
|
+
<h3 className={`mb-3 text-heading1 font-black ${textColor}`}>
|
|
118
118
|
{subTitle}
|
|
119
|
-
</
|
|
119
|
+
</h3>
|
|
120
120
|
)}
|
|
121
121
|
|
|
122
122
|
{emailInputDescription && (
|