@windstream/react-shared-components 0.2.32 → 0.2.34

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.
Files changed (26) hide show
  1. package/dist/contentful/index.d.ts +3 -6
  2. package/dist/contentful/index.esm.js +2 -2
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/styles.css +1 -1
  7. package/package.json +1 -1
  8. package/src/components/video-link/contentful-video-embed.test.tsx +76 -0
  9. package/src/components/video-link/contentful-video-embed.tsx +42 -0
  10. package/src/components/video-link/index.test.tsx +227 -0
  11. package/src/components/video-link/index.tsx +142 -0
  12. package/src/components/video-link/types.ts +28 -0
  13. package/src/contentful/blocks/blogs-grid-base/index.tsx +6 -1
  14. package/src/contentful/blocks/blogs-grid-base/types.ts +1 -0
  15. package/src/contentful/blocks/button/index.test.tsx +52 -0
  16. package/src/contentful/blocks/button/index.tsx +8 -3
  17. package/src/contentful/blocks/image-promo-bar/index.test.tsx +15 -198
  18. package/src/contentful/blocks/image-promo-bar/index.tsx +8 -92
  19. package/src/contentful/blocks/image-promo-bar/types.ts +1 -17
  20. package/src/contentful/blocks/primary-hero/index.tsx +18 -18
  21. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/helper.test.tsx +0 -0
  22. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/helper.tsx +0 -0
  23. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/vimeo-embed.test.tsx +0 -0
  24. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/vimeo-embed.tsx +0 -0
  25. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/youtube-embed.test.tsx +0 -0
  26. /package/src/{contentful/blocks/image-promo-bar → components/video-link}/youtube-embed.tsx +0 -0
@@ -0,0 +1,227 @@
1
+ import React from "react";
2
+ import { VideoLink } from "./index";
3
+
4
+ import { fireEvent, render, screen } from "@testing-library/react";
5
+
6
+ jest.mock("./contentful-video-embed", () => ({
7
+ ContentfulVideoEmbed: ({ videoAssetUrl, autoplay }: any) => (
8
+ <div
9
+ data-testid="contentful-video-embed"
10
+ data-video-asset-url={videoAssetUrl}
11
+ data-autoplay={autoplay}
12
+ />
13
+ ),
14
+ }));
15
+
16
+ jest.mock("./vimeo-embed", () => ({
17
+ VimeoEmbed: ({ link, autoplay }: any) => (
18
+ <div data-testid="vimeo-embed" data-link={link} data-autoplay={autoplay} />
19
+ ),
20
+ }));
21
+
22
+ jest.mock("./youtube-embed", () => ({
23
+ YoutubeEmbed: ({ link, autoplay }: any) => (
24
+ <div
25
+ data-testid="youtube-embed"
26
+ data-link={link}
27
+ data-autoplay={autoplay}
28
+ />
29
+ ),
30
+ }));
31
+
32
+ jest.mock("./helper", () => ({
33
+ PlayButton: ({ isHovered }: any) => (
34
+ <div data-testid="play-button" data-hovered={isHovered} />
35
+ ),
36
+ }));
37
+
38
+ jest.mock("@shared/components/next-image", () => ({
39
+ NextImage: ({ src, alt }: any) => (
40
+ <img data-testid="next-image" src={src} alt={alt} />
41
+ ),
42
+ }));
43
+
44
+ jest.mock("@shared/utils", () => ({
45
+ cx: (...args: any[]) => args.filter(Boolean).join(" "),
46
+ }));
47
+
48
+ const clickVideoSection = (container: HTMLElement) => {
49
+ fireEvent.click(container.querySelector(".video-section")!);
50
+ };
51
+
52
+ describe("VideoLink", () => {
53
+ it("renders nothing when videoLink is undefined", () => {
54
+ const { container } = render(<VideoLink />);
55
+ expect(container.innerHTML).toBe("");
56
+ });
57
+
58
+ it("renders nothing when videoLink has neither link nor videoAssetUrl", () => {
59
+ const { container } = render(
60
+ <VideoLink videoLink={{ image: "thumb.jpg" }} />
61
+ );
62
+ expect(container.innerHTML).toBe("");
63
+ });
64
+
65
+ it("renders nothing when videoSourceType is 'contentful' but videoAssetUrl is missing", () => {
66
+ const { container } = render(
67
+ <VideoLink
68
+ videoLink={{ videoSourceType: "contentful", image: "thumb.jpg" }}
69
+ />
70
+ );
71
+ expect(container.innerHTML).toBe("");
72
+ });
73
+
74
+ it("renders preview image and play button when videoLink has link and image", () => {
75
+ render(
76
+ <VideoLink
77
+ videoLink={{
78
+ link: "https://youtube.com/watch?v=123",
79
+ image: "thumb.jpg",
80
+ }}
81
+ />
82
+ );
83
+ expect(screen.getByTestId("play-button")).toBeInTheDocument();
84
+ expect(screen.getByAltText("Video preview")).toBeInTheDocument();
85
+ });
86
+
87
+ it("does not render preview image when videoLink.image is missing", () => {
88
+ render(
89
+ <VideoLink videoLink={{ link: "https://youtube.com/watch?v=123" }} />
90
+ );
91
+ expect(screen.queryByAltText("Video preview")).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("shows youtube embed inline on click for a youtube link", () => {
95
+ const { container } = render(
96
+ <VideoLink
97
+ videoLink={{
98
+ link: "https://youtube.com/watch?v=123",
99
+ image: "thumb.jpg",
100
+ }}
101
+ />
102
+ );
103
+ clickVideoSection(container);
104
+ expect(screen.getByTestId("youtube-embed")).toBeInTheDocument();
105
+ });
106
+
107
+ it("shows vimeo embed inline on click for a vimeo link", () => {
108
+ const { container } = render(
109
+ <VideoLink
110
+ videoLink={{ link: "https://vimeo.com/123", image: "thumb.jpg" }}
111
+ />
112
+ );
113
+ clickVideoSection(container);
114
+ expect(screen.getByTestId("vimeo-embed")).toBeInTheDocument();
115
+ });
116
+
117
+ it("shows the contentful embed when videoSourceType is 'contentful'", () => {
118
+ const { container } = render(
119
+ <VideoLink
120
+ videoLink={{
121
+ videoSourceType: "contentful",
122
+ videoAssetUrl: "https://videos.ctfassets.net/space/asset/sample.mp4",
123
+ image: "thumb.jpg",
124
+ }}
125
+ />
126
+ );
127
+ clickVideoSection(container);
128
+ expect(screen.getByTestId("contentful-video-embed")).toHaveAttribute(
129
+ "data-video-asset-url",
130
+ "https://videos.ctfassets.net/space/asset/sample.mp4"
131
+ );
132
+ });
133
+
134
+ it("shows popup overlay when videoPopup is true and playing", () => {
135
+ const { container } = render(
136
+ <VideoLink
137
+ videoLink={{
138
+ link: "https://youtube.com/watch?v=123",
139
+ image: "thumb.jpg",
140
+ videoPopup: true,
141
+ }}
142
+ />
143
+ );
144
+ clickVideoSection(container);
145
+ expect(container.querySelector(".fixed")).toBeInTheDocument();
146
+ });
147
+
148
+ it("does not render inline embed when videoPopup is true", () => {
149
+ const { container } = render(
150
+ <VideoLink
151
+ videoLink={{
152
+ link: "https://youtube.com/watch?v=123",
153
+ image: "thumb.jpg",
154
+ videoPopup: true,
155
+ }}
156
+ />
157
+ );
158
+ clickVideoSection(container);
159
+ expect(container.querySelector(".fixed")).toBeInTheDocument();
160
+ });
161
+
162
+ it("renders inline (not popup) when videoPopup is not provided", () => {
163
+ const { container } = render(
164
+ <VideoLink
165
+ videoLink={{
166
+ link: "https://youtube.com/watch?v=123",
167
+ image: "thumb.jpg",
168
+ }}
169
+ />
170
+ );
171
+ clickVideoSection(container);
172
+ expect(container.querySelector(".fixed")).not.toBeInTheDocument();
173
+ expect(screen.getByTestId("youtube-embed")).toBeInTheDocument();
174
+ });
175
+
176
+ it("closes popup on overlay click", () => {
177
+ const { container } = render(
178
+ <VideoLink
179
+ videoLink={{
180
+ link: "https://youtube.com/watch?v=123",
181
+ image: "thumb.jpg",
182
+ videoPopup: true,
183
+ }}
184
+ />
185
+ );
186
+ clickVideoSection(container);
187
+ fireEvent.click(container.querySelector(".fixed")!);
188
+ expect(container.querySelector(".fixed")).not.toBeInTheDocument();
189
+ });
190
+
191
+ it("does not close popup when clicking inside the popup content", () => {
192
+ const { container } = render(
193
+ <VideoLink
194
+ videoLink={{
195
+ link: "https://youtube.com/watch?v=123",
196
+ image: "thumb.jpg",
197
+ videoPopup: true,
198
+ }}
199
+ />
200
+ );
201
+ clickVideoSection(container);
202
+ fireEvent.click(container.querySelector(".max-w-6xl")!);
203
+ expect(container.querySelector(".fixed")).toBeInTheDocument();
204
+ });
205
+
206
+ it("handles hover state on the video section", () => {
207
+ const { container } = render(
208
+ <VideoLink
209
+ videoLink={{
210
+ link: "https://youtube.com/watch?v=123",
211
+ image: "thumb.jpg",
212
+ }}
213
+ />
214
+ );
215
+ const videoSection = container.querySelector(".video-section")!;
216
+ fireEvent.mouseEnter(videoSection);
217
+ expect(screen.getByTestId("play-button")).toHaveAttribute(
218
+ "data-hovered",
219
+ "true"
220
+ );
221
+ fireEvent.mouseLeave(videoSection);
222
+ expect(screen.getByTestId("play-button")).toHaveAttribute(
223
+ "data-hovered",
224
+ "false"
225
+ );
226
+ });
227
+ });
@@ -0,0 +1,142 @@
1
+ import React, { useState } from "react";
2
+ import { ContentfulVideoEmbed } from "./contentful-video-embed";
3
+ import { PlayButton } from "./helper";
4
+ import { VideoLinkComponentProps } from "./types";
5
+ import { VimeoEmbed } from "./vimeo-embed";
6
+ import { YoutubeEmbed } from "./youtube-embed";
7
+
8
+ import { NextImage } from "@shared/components/next-image";
9
+ import { cx } from "@shared/utils";
10
+
11
+ // Owns all video state (active/hover) so ImagePromoBar stays state-free for video.
12
+ export const VideoLink: React.FC<VideoLinkComponentProps> = ({
13
+ videoLink,
14
+ imageWidth = 660,
15
+ imageHeight = 660,
16
+ useOriginalImageAspectRatio = false,
17
+ }) => {
18
+ const [activeVideo, setActiveVideo] = useState("");
19
+ const [isHovered, setIsHovered] = useState(false);
20
+
21
+ const videoSourceType = videoLink?.videoSourceType;
22
+ const videoHref =
23
+ (videoSourceType === "contentful"
24
+ ? videoLink?.videoAssetUrl
25
+ : videoLink?.link) ?? "";
26
+
27
+ if (!videoHref) {
28
+ return null;
29
+ }
30
+
31
+ const videoPopup = videoLink?.videoPopup ?? false;
32
+ const isPlaying = Boolean(activeVideo && activeVideo === videoHref);
33
+
34
+ const handlePlayClick = () => {
35
+ setActiveVideo(videoHref);
36
+ };
37
+
38
+ const handleCloseVideo = (e: React.MouseEvent) => {
39
+ e.stopPropagation();
40
+ setActiveVideo("");
41
+ };
42
+
43
+ const embedSelector = () => {
44
+ if (videoSourceType === "contentful") {
45
+ return (
46
+ <ContentfulVideoEmbed
47
+ videoAssetUrl={videoLink?.videoAssetUrl}
48
+ videoAssetType={videoLink?.videoAssetType}
49
+ image={videoLink?.image}
50
+ title={videoLink?.title}
51
+ description={videoLink?.description}
52
+ autoplay={isPlaying}
53
+ />
54
+ );
55
+ }
56
+ if (videoHref.includes("vimeo")) {
57
+ return <VimeoEmbed link={videoHref} autoplay={isPlaying} />;
58
+ }
59
+ return <YoutubeEmbed link={videoHref} autoplay={isPlaying} />;
60
+ };
61
+ const contentVideo = embedSelector();
62
+
63
+ return (
64
+ <>
65
+ <div
66
+ className={cx(
67
+ "video-section relative w-full cursor-pointer rounded-image transition-all duration-300 lg:w-[486px]",
68
+ !isPlaying && "hover:ring-2 hover:ring-border-focus"
69
+ )}
70
+ onClick={handlePlayClick}
71
+ onMouseEnter={() => setIsHovered(true)}
72
+ onMouseLeave={() => setIsHovered(false)}
73
+ >
74
+ {/* Preview Image & Play Button */}
75
+ <div
76
+ className={cx(
77
+ isPlaying && !videoPopup && "hidden",
78
+ isPlaying && !videoPopup ? "opacity-0" : "opacity-100",
79
+ "relative w-full transition-opacity duration-300"
80
+ )}
81
+ >
82
+ {videoLink?.image && (
83
+ <NextImage
84
+ src={videoLink.image}
85
+ alt="Video preview"
86
+ width={imageWidth || 660}
87
+ height={imageHeight || 660}
88
+ sizes="(max-width: 430px) 100vw, (max-width: 834px) 100vw, 50vw"
89
+ className="h-auto w-full rounded-image"
90
+ />
91
+ )}
92
+ <div className="absolute inset-0 flex items-center justify-center">
93
+ <PlayButton isHovered={isHovered} />
94
+ </div>
95
+ </div>
96
+
97
+ {/* Inline Player (if not popup) */}
98
+ {!videoPopup && isPlaying && (
99
+ <div
100
+ className={cx(
101
+ "aspect-[16/9] w-full overflow-hidden rounded-image transition-opacity duration-300",
102
+ isPlaying ? "opacity-100" : "opacity-0",
103
+ !useOriginalImageAspectRatio &&
104
+ "xl:aspect-square xl:max-h-[486px] xl:w-[486px]"
105
+ )}
106
+ >
107
+ {contentVideo}
108
+ </div>
109
+ )}
110
+ </div>
111
+
112
+ {/* Video Popup Overlay */}
113
+ {videoPopup && isPlaying && (
114
+ <div
115
+ className="fixed inset-0 top-20 z-[100] flex items-center justify-center bg-black/80 p-4 transition-all duration-300"
116
+ onClick={handleCloseVideo}
117
+ >
118
+ <div
119
+ className="max-w-6xl aspect-video w-full overflow-hidden rounded-3xl bg-black shadow-2xl md:w-4/6"
120
+ onClick={e => e.stopPropagation()}
121
+ >
122
+ {contentVideo}
123
+ </div>
124
+ </div>
125
+ )}
126
+ </>
127
+ );
128
+ };
129
+
130
+ export default VideoLink;
131
+
132
+ export { ContentfulVideoEmbed } from "./contentful-video-embed";
133
+ export { PlayButton } from "./helper";
134
+ export { VimeoEmbed } from "./vimeo-embed";
135
+ export { YoutubeEmbed } from "./youtube-embed";
136
+
137
+ export type {
138
+ PlayButtonProps,
139
+ VideoEmbedProps,
140
+ VideoLinkComponentProps,
141
+ VideoLinkProps,
142
+ } from "./types";
@@ -0,0 +1,28 @@
1
+ export type VideoLinkProps = {
2
+ image?: string;
3
+ videoPopup?: boolean;
4
+ link?: string;
5
+ videoSourceType?: "youtube" | "contentful";
6
+ videoAssetUrl?: string;
7
+ videoAssetType?: string;
8
+ title?: string;
9
+ description?: string;
10
+ };
11
+
12
+ export type VideoEmbedProps = {
13
+ containerClassName?: string;
14
+ autoplay?: boolean;
15
+ debug?: boolean;
16
+ } & VideoLinkProps;
17
+
18
+ export type VideoLinkComponentProps = {
19
+ videoLink?: VideoLinkProps;
20
+ imageWidth?: number;
21
+ imageHeight?: number;
22
+ useOriginalImageAspectRatio?: boolean;
23
+ };
24
+
25
+ export type PlayButtonProps = {
26
+ isHovered?: boolean;
27
+ containerClassName?: string;
28
+ };
@@ -7,6 +7,7 @@ import { BlogCard } from "@shared/contentful/blocks/cards/blog-card";
7
7
 
8
8
  export function BlogGridBase({
9
9
  title = "recent articles",
10
+ description,
10
11
  paginatedArticles,
11
12
  totalArticles,
12
13
  currentPage,
@@ -37,10 +38,14 @@ export function BlogGridBase({
37
38
  >
38
39
  {/* Controls bar */}
39
40
  <div className="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-3 px-6 pb-6">
41
+ {description && (
42
+ <Text as="p" className="body1 mb-4 w-full text-center md:mb-6">
43
+ {description}
44
+ </Text>
45
+ )}
40
46
  <Text className="w-full text-center text-heading5 font-black lowercase text-text-secondary md:w-auto md:text-left">
41
47
  {title}
42
48
  </Text>
43
-
44
49
  {categoryOptions.length > 0 && (
45
50
  <div className="flex w-full flex-col-reverse gap-3 md:w-auto md:flex-row md:items-center md:gap-6">
46
51
  {/* Showing count */}
@@ -18,6 +18,7 @@ export interface BlogCardProps {
18
18
 
19
19
  export interface BlogGridBaseProps {
20
20
  title?: string;
21
+ description?: string;
21
22
  paginatedArticles: BlogCardProps[];
22
23
  totalArticles: number;
23
24
  currentPage: number;
@@ -283,6 +283,58 @@ describe("Button", () => {
283
283
  fireEvent.click(screen.getByTestId("link"));
284
284
  expect(onModalButtonClick).toHaveBeenCalledWith("tab1");
285
285
  });
286
+
287
+ it("prevents anchor navigation and opens modal when href is present and clickToOpen is 'modal'", () => {
288
+ const onModalButtonClick = jest.fn();
289
+ render(
290
+ <Button
291
+ buttonLabel="Open"
292
+ href="https://example.com"
293
+ clickToOpen="modal"
294
+ tabmodalNameToOpen="my-modal"
295
+ onModalButtonClick={onModalButtonClick}
296
+ />
297
+ );
298
+ // fireEvent.click returns false when a handler called preventDefault().
299
+ const notCancelled = fireEvent.click(screen.getByTestId("brand-button"));
300
+ expect(notCancelled).toBe(false);
301
+ expect(onModalButtonClick).toHaveBeenCalledWith("my-modal");
302
+ });
303
+
304
+ it("prevents anchor navigation when href is present and clickToOpen is 'clickId'", () => {
305
+ const scrollIntoView = jest.fn();
306
+ const targetEl = document.createElement("div");
307
+ targetEl.scrollIntoView = scrollIntoView;
308
+ const getElementByIdSpy = jest
309
+ .spyOn(document, "getElementById")
310
+ .mockReturnValue(targetEl);
311
+ render(
312
+ <Button
313
+ buttonLabel="Scroll"
314
+ href="https://example.com"
315
+ clickToOpen="clickId"
316
+ idNameToOpen="section-1"
317
+ />
318
+ );
319
+ const notCancelled = fireEvent.click(screen.getByTestId("brand-button"));
320
+ expect(notCancelled).toBe(false);
321
+ expect(scrollIntoView).toHaveBeenCalled();
322
+ getElementByIdSpy.mockRestore();
323
+ });
324
+
325
+ it("does not prevent anchor navigation for a normal href link", () => {
326
+ const onModalButtonClick = jest.fn();
327
+ render(
328
+ <Button
329
+ buttonLabel="Go"
330
+ href="https://example.com"
331
+ onModalButtonClick={onModalButtonClick}
332
+ />
333
+ );
334
+ const notCancelled = fireEvent.click(screen.getByTestId("brand-button"));
335
+ expect(notCancelled).toBe(true);
336
+ expect(onModalButtonClick).not.toHaveBeenCalled();
337
+ });
286
338
  });
287
339
 
288
340
  describe("preserveQueryParameters", () => {
@@ -84,22 +84,27 @@ export const Button: React.FC<ButtonProps> = props => {
84
84
  }
85
85
  }
86
86
 
87
- function handleModalClick() {
87
+ function handleModalClick(
88
+ event?: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>
89
+ ) {
88
90
  if (clickToOpen === "modal") {
91
+ // Prevent the anchor's href from navigating so the CTA only opens the modal.
92
+ event?.preventDefault();
89
93
  onModalButtonClick?.(tabmodalNameToOpen);
90
94
  }
91
95
  if (clickToOpen === "clickId") {
96
+ event?.preventDefault();
92
97
  handleClickId();
93
98
  }
94
99
  }
95
100
 
96
101
  function linkClick(event: React.MouseEvent<HTMLAnchorElement>) {
97
- handleModalClick();
102
+ handleModalClick(event);
98
103
  onClick?.(event);
99
104
  }
100
105
 
101
106
  function buttonClick(event: React.MouseEvent<HTMLButtonElement>) {
102
- handleModalClick();
107
+ handleModalClick(event);
103
108
  onClick?.(event);
104
109
  }
105
110