@windstream/react-shared-components 0.2.49 → 0.2.51
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.d.ts +12 -1
- package/dist/contentful/index.esm.js +3 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +6 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js +2 -2
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js +2 -2
- package/dist/next/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -1
- package/src/components/next-image/index.tsx +10 -1
- package/src/components/video-link/contentful-video-embed.tsx +17 -1
- package/src/components/video-link/index.test.tsx +196 -24
- package/src/components/video-link/index.tsx +84 -15
- package/src/components/video-link/types.ts +15 -1
- package/src/components/video-link/vimeo-embed.tsx +51 -2
- package/src/components/video-link/youtube-embed.test.tsx +81 -54
- package/src/components/video-link/youtube-embed.tsx +97 -16
- package/src/components/video-link/youtube-iframe-api-loader.ts +45 -0
- package/src/contentful/blocks/callout/index.test.tsx +2 -2
- package/src/contentful/blocks/callout/index.tsx +1 -0
- package/src/contentful/blocks/cards/blog-card/index.tsx +9 -2
- package/src/contentful/blocks/cards/blog-card/types.ts +4 -0
- package/src/contentful/blocks/cards/floating-image-card/index.tsx +20 -6
- package/src/contentful/blocks/cards/floating-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/full-image-card/index.tsx +2 -1
- package/src/contentful/blocks/cards/full-image-card/types.ts +4 -0
- package/src/contentful/blocks/cards/simple-card/index.tsx +2 -2
- package/src/contentful/blocks/cards/simple-card/types.ts +4 -0
- package/src/contentful/blocks/find-kinetic/index.tsx +20 -7
- package/src/contentful/blocks/find-kinetic/types.ts +1 -0
- package/src/contentful/blocks/image-promo-bar/index.test.tsx +2 -2
- package/src/contentful/blocks/image-promo-bar/index.tsx +4 -1
- package/src/contentful/blocks/image-promo-bar/types.ts +6 -1
- package/src/contentful/blocks/primary-hero/index.tsx +6 -6
- package/src/hooks/contentful/use-contentful-rich-text.test.tsx +6 -6
- package/src/hooks/use-fullscreen-toggle.ts +23 -0
- package/src/hooks/use-video-analytics.ts +126 -0
- package/src/types/vimeo-player.d.ts +11 -0
|
@@ -1,78 +1,114 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { YoutubeEmbed } from "./youtube-embed";
|
|
2
|
+
import { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
|
|
3
3
|
|
|
4
|
-
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
5
5
|
|
|
6
6
|
jest.mock("@shared/utils", () => ({
|
|
7
7
|
cx: (...args: any[]) => args.filter(Boolean).join(" "),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
const mockPlayVideo = jest.fn();
|
|
11
|
+
let capturedEvents: any;
|
|
12
|
+
let capturedVideoId: string | undefined;
|
|
13
|
+
let capturedPlayerVars: any;
|
|
14
|
+
|
|
15
|
+
jest.mock("./youtube-iframe-api-loader", () => ({
|
|
16
|
+
loadYoutubeIframeApi: () =>
|
|
17
|
+
Promise.resolve({
|
|
18
|
+
PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 },
|
|
19
|
+
Player: jest.fn().mockImplementation((_el, options) => {
|
|
20
|
+
capturedEvents = options.events;
|
|
21
|
+
capturedVideoId = options.videoId;
|
|
22
|
+
capturedPlayerVars = options.playerVars;
|
|
23
|
+
return { playVideo: mockPlayVideo, destroy: jest.fn() };
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("getYoutubeEmbedId", () => {
|
|
29
|
+
it("extracts id from watch URL", () => {
|
|
30
|
+
expect(
|
|
31
|
+
getYoutubeEmbedId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
32
|
+
).toBe("dQw4w9WgXcQ");
|
|
17
33
|
});
|
|
18
34
|
|
|
19
35
|
it("extracts id from youtu.be short URL", () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"/embed/dQw4w9WgXcQ"
|
|
36
|
+
expect(getYoutubeEmbedId("https://youtu.be/dQw4w9WgXcQ")).toBe(
|
|
37
|
+
"dQw4w9WgXcQ"
|
|
23
38
|
);
|
|
24
39
|
});
|
|
25
40
|
|
|
26
41
|
it("extracts id from embed URL", () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"/embed/dQw4w9WgXcQ"
|
|
42
|
+
expect(getYoutubeEmbedId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(
|
|
43
|
+
"dQw4w9WgXcQ"
|
|
30
44
|
);
|
|
31
45
|
});
|
|
32
46
|
|
|
33
|
-
it("returns empty
|
|
34
|
-
|
|
35
|
-
expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
|
|
36
|
-
"https://www.youtube.com/embed/"
|
|
37
|
-
);
|
|
47
|
+
it("returns empty string for invalid URL", () => {
|
|
48
|
+
expect(getYoutubeEmbedId("https://example.com/notavideo")).toBe("");
|
|
38
49
|
});
|
|
39
50
|
|
|
40
|
-
it("returns empty
|
|
41
|
-
|
|
42
|
-
expect(
|
|
43
|
-
"https://www.youtube.com/embed/"
|
|
44
|
-
);
|
|
51
|
+
it("returns empty string when link is empty or undefined", () => {
|
|
52
|
+
expect(getYoutubeEmbedId("")).toBe("");
|
|
53
|
+
expect(getYoutubeEmbedId(undefined)).toBe("");
|
|
45
54
|
});
|
|
46
55
|
|
|
47
|
-
it("returns empty
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
it("returns empty string when match length is not 11", () => {
|
|
57
|
+
expect(getYoutubeEmbedId("https://www.youtube.com/watch?v=short")).toBe("");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("YoutubeEmbed", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
mockPlayVideo.mockClear();
|
|
64
|
+
capturedEvents = undefined;
|
|
65
|
+
capturedVideoId = undefined;
|
|
66
|
+
capturedPlayerVars = undefined;
|
|
52
67
|
});
|
|
53
68
|
|
|
54
|
-
it("
|
|
69
|
+
it("renders a placeholder with an accessible title", () => {
|
|
70
|
+
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
|
|
71
|
+
expect(screen.getByTitle("Embedded youtube")).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("constructs the player with the extracted videoId and enablejsapi, autoplay/mute off by default", async () => {
|
|
75
|
+
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
|
|
76
|
+
await waitFor(() => expect(capturedVideoId).toBeDefined());
|
|
77
|
+
expect(capturedVideoId).toBe("dQw4w9WgXcQ");
|
|
78
|
+
expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 0, mute: 0 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("passes autoplay and mute declaratively via playerVars when autoplay is true", async () => {
|
|
55
82
|
render(
|
|
56
83
|
<YoutubeEmbed
|
|
57
84
|
link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
58
85
|
autoplay={true}
|
|
59
86
|
/>
|
|
60
87
|
);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
88
|
+
await waitFor(() => expect(capturedPlayerVars).toBeDefined());
|
|
89
|
+
expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 1, mute: 1 });
|
|
64
90
|
});
|
|
65
91
|
|
|
66
|
-
it("
|
|
92
|
+
it("reports play/pause/ended via onStateChange", async () => {
|
|
93
|
+
const onPlay = jest.fn();
|
|
94
|
+
const onPause = jest.fn();
|
|
95
|
+
const onEnded = jest.fn();
|
|
67
96
|
render(
|
|
68
97
|
<YoutubeEmbed
|
|
69
98
|
link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
70
|
-
|
|
99
|
+
onPlay={onPlay}
|
|
100
|
+
onPause={onPause}
|
|
101
|
+
onEnded={onEnded}
|
|
71
102
|
/>
|
|
72
103
|
);
|
|
73
|
-
expect(
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
await waitFor(() => expect(capturedEvents).toBeDefined());
|
|
105
|
+
|
|
106
|
+
capturedEvents.onStateChange({ data: 1 });
|
|
107
|
+
expect(onPlay).toHaveBeenCalled();
|
|
108
|
+
capturedEvents.onStateChange({ data: 2 });
|
|
109
|
+
expect(onPause).toHaveBeenCalled();
|
|
110
|
+
capturedEvents.onStateChange({ data: 0 });
|
|
111
|
+
expect(onEnded).toHaveBeenCalled();
|
|
76
112
|
});
|
|
77
113
|
|
|
78
114
|
it("applies containerClassName", () => {
|
|
@@ -85,20 +121,11 @@ describe("YoutubeEmbed", () => {
|
|
|
85
121
|
expect(container.firstElementChild?.className).toContain("extra");
|
|
86
122
|
});
|
|
87
123
|
|
|
88
|
-
it("
|
|
89
|
-
render(<YoutubeEmbed link="https://
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
expect(iframe).toHaveAttribute("allowFullScreen");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("returns empty embedId when match length is not 11", () => {
|
|
99
|
-
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=short" />);
|
|
100
|
-
expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
|
|
101
|
-
"https://www.youtube.com/embed/"
|
|
102
|
-
);
|
|
124
|
+
it("does not attempt to load the API when embedId is empty", async () => {
|
|
125
|
+
render(<YoutubeEmbed link="https://example.com/notavideo" />);
|
|
126
|
+
await new Promise(resolve => {
|
|
127
|
+
setTimeout(resolve, 0);
|
|
128
|
+
});
|
|
129
|
+
expect(capturedVideoId).toBeUndefined();
|
|
103
130
|
});
|
|
104
131
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import React, { useMemo } from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { VideoEmbedProps } from "./types";
|
|
3
|
+
import { loadYoutubeIframeApi } from "./youtube-iframe-api-loader";
|
|
3
4
|
|
|
5
|
+
import { useFullscreenToggle } from "@shared/hooks/use-fullscreen-toggle";
|
|
4
6
|
import { cx } from "@shared/utils";
|
|
5
7
|
|
|
6
8
|
// import styles from "./styles.module.scss";
|
|
@@ -10,22 +12,103 @@ export type Props = {
|
|
|
10
12
|
autoplay?: boolean; // Add the autoplay prop
|
|
11
13
|
} & VideoEmbedProps;
|
|
12
14
|
|
|
15
|
+
export const getYoutubeEmbedId = (link?: string): string => {
|
|
16
|
+
if (!link) return "";
|
|
17
|
+
|
|
18
|
+
const href = link.toString();
|
|
19
|
+
const regExp =
|
|
20
|
+
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
|
|
21
|
+
const match = href.match(regExp);
|
|
22
|
+
|
|
23
|
+
return match && match[7].length === 11 ? match[7] : "";
|
|
24
|
+
};
|
|
25
|
+
|
|
13
26
|
export const YoutubeEmbed: React.FC<Props> = props => {
|
|
14
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
link,
|
|
29
|
+
containerClassName,
|
|
30
|
+
autoplay = false,
|
|
31
|
+
onPlay,
|
|
32
|
+
onPause,
|
|
33
|
+
onTimeUpdate,
|
|
34
|
+
onEnded,
|
|
35
|
+
onFullscreenToggle,
|
|
36
|
+
} = props;
|
|
37
|
+
|
|
38
|
+
const embedId = useMemo(() => getYoutubeEmbedId(link), [link]);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
41
|
+
const progressInterval = useRef<ReturnType<typeof setInterval>>();
|
|
42
|
+
|
|
43
|
+
useFullscreenToggle(iframeRef, onFullscreenToggle);
|
|
15
44
|
|
|
16
|
-
|
|
17
|
-
if (!
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!embedId || !containerRef.current) return undefined;
|
|
47
|
+
let player: any;
|
|
48
|
+
let cancelled = false;
|
|
18
49
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
// The YT.Player-managed iframe is mounted into a plain DOM node created
|
|
51
|
+
// here, outside of React's own render tree/reconciliation - React only
|
|
52
|
+
// ever renders an empty container div for this component, so it never
|
|
53
|
+
// diffs/touches whatever the API puts inside it. Letting React manage
|
|
54
|
+
// the same node the API mutates is a well-known source of onReady/
|
|
55
|
+
// onStateChange silently never firing (a reconciliation pass can revert
|
|
56
|
+
// the API's DOM changes without going through this component's own
|
|
57
|
+
// effect cleanup).
|
|
58
|
+
const mountNode = document.createElement("div");
|
|
59
|
+
mountNode.style.width = "100%";
|
|
60
|
+
mountNode.style.height = "100%";
|
|
61
|
+
containerRef.current.appendChild(mountNode);
|
|
23
62
|
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
loadYoutubeIframeApi().then(YT => {
|
|
64
|
+
if (cancelled) return;
|
|
65
|
+
player = new YT.Player(mountNode, {
|
|
66
|
+
videoId: embedId,
|
|
67
|
+
playerVars: {
|
|
68
|
+
enablejsapi: 1,
|
|
69
|
+
autoplay: autoplay ? 1 : 0,
|
|
70
|
+
mute: autoplay ? 1 : 0,
|
|
71
|
+
},
|
|
72
|
+
events: {
|
|
73
|
+
onReady: () => {
|
|
74
|
+
const iframe = player.getIframe?.();
|
|
75
|
+
if (iframe) {
|
|
76
|
+
iframe.className = "absolute left-0 top-0 h-full w-full";
|
|
77
|
+
iframeRef.current = iframe;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onStateChange: (event: any) => {
|
|
81
|
+
const State = YT.PlayerState;
|
|
82
|
+
if (event.data === State.PLAYING) {
|
|
83
|
+
onPlay?.();
|
|
84
|
+
clearInterval(progressInterval.current);
|
|
85
|
+
progressInterval.current = setInterval(() => {
|
|
86
|
+
const duration = player?.getDuration?.() ?? 0;
|
|
87
|
+
const position = player?.getCurrentTime?.() ?? 0;
|
|
88
|
+
onTimeUpdate?.(position, duration);
|
|
89
|
+
}, 1000);
|
|
90
|
+
} else if (event.data === State.PAUSED) {
|
|
91
|
+
onPause?.();
|
|
92
|
+
clearInterval(progressInterval.current);
|
|
93
|
+
} else if (event.data === State.ENDED) {
|
|
94
|
+
onEnded?.();
|
|
95
|
+
clearInterval(progressInterval.current);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}).catch(() => {
|
|
101
|
+
// ignore YouTube API load failures; component will remain empty
|
|
102
|
+
});
|
|
26
103
|
|
|
27
|
-
|
|
28
|
-
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true;
|
|
106
|
+
clearInterval(progressInterval.current);
|
|
107
|
+
player?.destroy?.();
|
|
108
|
+
mountNode.remove();
|
|
109
|
+
};
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, [embedId]);
|
|
29
112
|
|
|
30
113
|
return (
|
|
31
114
|
<div
|
|
@@ -34,11 +117,9 @@ export const YoutubeEmbed: React.FC<Props> = props => {
|
|
|
34
117
|
containerClassName
|
|
35
118
|
)}
|
|
36
119
|
>
|
|
37
|
-
<
|
|
120
|
+
<div
|
|
121
|
+
ref={containerRef}
|
|
38
122
|
className="absolute left-0 top-0 h-full w-full"
|
|
39
|
-
src={`https://www.youtube.com/embed/${embedId}${autoplayParam}`}
|
|
40
|
-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
41
|
-
allowFullScreen={true}
|
|
42
123
|
title="Embedded youtube"
|
|
43
124
|
/>
|
|
44
125
|
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
let apiPromise: Promise<typeof window.YT> | null = null;
|
|
2
|
+
|
|
3
|
+
export function loadYoutubeIframeApi(): Promise<typeof window.YT> {
|
|
4
|
+
if (typeof window === "undefined") {
|
|
5
|
+
return Promise.reject(new Error("YouTube IFrame API requires a browser"));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (window.YT?.Player) {
|
|
9
|
+
return Promise.resolve(window.YT);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (apiPromise) {
|
|
13
|
+
return apiPromise;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
apiPromise = new Promise((resolve, reject) => {
|
|
17
|
+
const previousCallback = window.onYouTubeIframeAPIReady;
|
|
18
|
+
window.onYouTubeIframeAPIReady = () => {
|
|
19
|
+
previousCallback?.();
|
|
20
|
+
resolve(window.YT);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let script = document.querySelector<HTMLScriptElement>(
|
|
24
|
+
'script[src*="youtube.com/iframe_api"]'
|
|
25
|
+
);
|
|
26
|
+
if (!script) {
|
|
27
|
+
script = document.createElement("script");
|
|
28
|
+
script.src = "https://www.youtube.com/iframe_api";
|
|
29
|
+
script.onerror = () => {
|
|
30
|
+
apiPromise = null;
|
|
31
|
+
reject(new Error("Failed to load YouTube IFrame API"));
|
|
32
|
+
};
|
|
33
|
+
document.head.appendChild(script);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return apiPromise;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
declare global {
|
|
41
|
+
interface Window {
|
|
42
|
+
YT: any;
|
|
43
|
+
onYouTubeIframeAPIReady?: () => void;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -258,10 +258,10 @@ describe("Callout", () => {
|
|
|
258
258
|
expect(gridHolder?.className).not.toContain("gap-6");
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
-
it("has gap-
|
|
261
|
+
it("has gap-3 by default", () => {
|
|
262
262
|
const { container } = render(<Callout {...defaultProps} />);
|
|
263
263
|
const gridHolder = container.querySelector(".card-holder");
|
|
264
|
-
expect(gridHolder?.className).toContain("gap-
|
|
264
|
+
expect(gridHolder?.className).toContain("gap-3");
|
|
265
265
|
});
|
|
266
266
|
|
|
267
267
|
it("calculates 3 columns for 6 items (divisible by 3)", () => {
|
|
@@ -18,10 +18,17 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
18
18
|
date,
|
|
19
19
|
category,
|
|
20
20
|
image,
|
|
21
|
+
imageAltText,
|
|
21
22
|
imageComponent: ImageComponent,
|
|
22
23
|
asGrid = true,
|
|
23
24
|
index,
|
|
24
25
|
}: BlogCardProps) => {
|
|
26
|
+
const imageAlt =
|
|
27
|
+
imageAltText ??
|
|
28
|
+
image?.description ??
|
|
29
|
+
image?.alt ??
|
|
30
|
+
title ??
|
|
31
|
+
"blog-card-image";
|
|
25
32
|
const parentClassName = asGrid
|
|
26
33
|
? "group relative flex h-full flex-col overflow-hidden rounded-card-lg bg-white shadow-drop transition-all duration-200 hover:-translate-y-0.5 hover:shadow-cardDrop"
|
|
27
34
|
: `callout-card group relative w-full flex h-full flex-col overflow-hidden`;
|
|
@@ -40,7 +47,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
40
47
|
ImageComponent ? (
|
|
41
48
|
<ImageComponent
|
|
42
49
|
src={image.src}
|
|
43
|
-
alt={
|
|
50
|
+
alt={imageAlt}
|
|
44
51
|
width={image.width}
|
|
45
52
|
height={image.height}
|
|
46
53
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
|
@@ -49,7 +56,7 @@ export const BlogCard: React.FC<BlogCardProps> = ({
|
|
|
49
56
|
) : (
|
|
50
57
|
<img
|
|
51
58
|
src={image.src}
|
|
52
|
-
alt={
|
|
59
|
+
alt={imageAlt}
|
|
53
60
|
width={image.width}
|
|
54
61
|
height={image.height}
|
|
55
62
|
loading="lazy"
|
|
@@ -21,9 +21,13 @@ export interface BlogCardProps {
|
|
|
21
21
|
image?: {
|
|
22
22
|
src: string;
|
|
23
23
|
alt: string;
|
|
24
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
25
|
+
description?: string;
|
|
24
26
|
width: number;
|
|
25
27
|
height: number;
|
|
26
28
|
};
|
|
29
|
+
/** Explicit image alt text; falls back to the image description then alt. */
|
|
30
|
+
imageAltText?: string;
|
|
27
31
|
/** Pass next/image's Image component here to enable image optimization */
|
|
28
32
|
imageComponent?: React.ComponentType<BlogCardImageProps>;
|
|
29
33
|
asGrid?: boolean;
|
|
@@ -29,6 +29,7 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
29
29
|
imageLink,
|
|
30
30
|
imageClassName,
|
|
31
31
|
className,
|
|
32
|
+
imageAltText,
|
|
32
33
|
isPromoBarImg = false,
|
|
33
34
|
shadow = false,
|
|
34
35
|
disableAnimation,
|
|
@@ -51,7 +52,13 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
51
52
|
return image?.href ? (
|
|
52
53
|
<NextImage
|
|
53
54
|
src={image.href}
|
|
54
|
-
alt={
|
|
55
|
+
alt={
|
|
56
|
+
imageAltText ??
|
|
57
|
+
image.description ??
|
|
58
|
+
image.title ??
|
|
59
|
+
title ??
|
|
60
|
+
"promo-bar-image"
|
|
61
|
+
}
|
|
55
62
|
width={148}
|
|
56
63
|
height={40}
|
|
57
64
|
onClick={handleImageClick}
|
|
@@ -74,13 +81,15 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
74
81
|
{image?.href ? (
|
|
75
82
|
<div
|
|
76
83
|
className={cx(
|
|
77
|
-
"relative h-[233px] w-full overflow-hidden sm:mb-1 md:h-[274px] md:flex-none lg:h-[330px]
|
|
84
|
+
"relative h-[233px] w-full overflow-hidden sm:mb-1 md:h-[274px] md:flex-none lg:mb-0 lg:h-[330px]",
|
|
78
85
|
imageClassName
|
|
79
86
|
)}
|
|
80
87
|
>
|
|
81
88
|
<NextImage
|
|
82
89
|
src={image.href}
|
|
83
|
-
alt={
|
|
90
|
+
alt={
|
|
91
|
+
imageAltText ?? image.description ?? image.title ?? "card-image"
|
|
92
|
+
}
|
|
84
93
|
width={image.width || 400}
|
|
85
94
|
height={image.height || 330}
|
|
86
95
|
onClick={handleImageClick}
|
|
@@ -99,7 +108,10 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
99
108
|
) : null}
|
|
100
109
|
|
|
101
110
|
{body ? (
|
|
102
|
-
<Text
|
|
111
|
+
<Text
|
|
112
|
+
as="div"
|
|
113
|
+
className="body1 -mt-1 mb-0 flex-1 text-text md:-mt-2 lg:mt-2"
|
|
114
|
+
>
|
|
103
115
|
{body}
|
|
104
116
|
</Text>
|
|
105
117
|
) : null}
|
|
@@ -109,10 +121,12 @@ export const FloatingImageCard: React.FC<FloatingImageCardProps> = ({
|
|
|
109
121
|
<Button
|
|
110
122
|
{...cta}
|
|
111
123
|
showButtonAs={
|
|
112
|
-
cta.showButtonAs ||
|
|
124
|
+
cta.showButtonAs ||
|
|
125
|
+
(cta.buttonIconName ? undefined : "text-with-icon")
|
|
113
126
|
}
|
|
114
127
|
iconName={
|
|
115
|
-
cta.iconName ||
|
|
128
|
+
cta.iconName ||
|
|
129
|
+
(cta.buttonIconName ? undefined : "expand_circle_right")
|
|
116
130
|
}
|
|
117
131
|
iconFill={cta.iconFill ?? 1}
|
|
118
132
|
// Button animates ONLY when BOTH are explicitly enabled:
|
|
@@ -4,6 +4,8 @@ import type { ButtonProps } from "../../button/types";
|
|
|
4
4
|
export type FloatingImageAsset = {
|
|
5
5
|
href: string;
|
|
6
6
|
title?: string;
|
|
7
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
8
|
+
description?: string;
|
|
7
9
|
width?: number;
|
|
8
10
|
height?: number;
|
|
9
11
|
};
|
|
@@ -15,6 +17,8 @@ export type FloatingImageCardItem = {
|
|
|
15
17
|
cta?: ButtonProps;
|
|
16
18
|
ctaAlignment?: "left" | "right" | "center";
|
|
17
19
|
image?: FloatingImageAsset;
|
|
20
|
+
/** Explicit image alt text; falls back to the image asset description. */
|
|
21
|
+
imageAltText?: string;
|
|
18
22
|
imageLink?: string;
|
|
19
23
|
imageClassName?: string;
|
|
20
24
|
className?: string;
|
|
@@ -18,6 +18,7 @@ export const FullImageCard: React.FC<FullImageCardProps> = ({
|
|
|
18
18
|
description,
|
|
19
19
|
caption,
|
|
20
20
|
image,
|
|
21
|
+
imageAltText,
|
|
21
22
|
cta,
|
|
22
23
|
link,
|
|
23
24
|
className,
|
|
@@ -35,7 +36,7 @@ export const FullImageCard: React.FC<FullImageCardProps> = ({
|
|
|
35
36
|
const img = (
|
|
36
37
|
<NextImage
|
|
37
38
|
src={image.href}
|
|
38
|
-
alt={image.title ?? title ?? "card-image"}
|
|
39
|
+
alt={imageAltText ?? image.description ?? image.title ?? title ?? "card-image"}
|
|
39
40
|
width={image.width || 400}
|
|
40
41
|
height={image.height || 280}
|
|
41
42
|
className="w-full"
|
|
@@ -3,6 +3,8 @@ import type { ButtonProps } from "../../button/types";
|
|
|
3
3
|
export type FullImageAsset = {
|
|
4
4
|
href: string;
|
|
5
5
|
title?: string;
|
|
6
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
7
|
+
description?: string;
|
|
6
8
|
width?: number;
|
|
7
9
|
height?: number;
|
|
8
10
|
};
|
|
@@ -15,6 +17,8 @@ export type FullImageCardItem = {
|
|
|
15
17
|
cta?: ButtonProps;
|
|
16
18
|
link?: ButtonProps;
|
|
17
19
|
image?: FullImageAsset;
|
|
20
|
+
/** Explicit image alt text; falls back to the image asset description. */
|
|
21
|
+
imageAltText?: string;
|
|
18
22
|
className?: string;
|
|
19
23
|
contentClassName?: string;
|
|
20
24
|
imageClassName?: string;
|
|
@@ -97,7 +97,7 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
|
|
|
97
97
|
>
|
|
98
98
|
<NextImage
|
|
99
99
|
src={card.image}
|
|
100
|
-
alt={card.imageAlt ?? card.title ?? "card-image"}
|
|
100
|
+
alt={card.imageAltText ?? card.imageDescription ?? card.imageAlt ?? card.title ?? "card-image"}
|
|
101
101
|
width={card.imageWidth || 400}
|
|
102
102
|
height={card.imageHeight || 280}
|
|
103
103
|
className={cx("w-full", !margin && "rounded-t-3xl")}
|
|
@@ -124,7 +124,7 @@ export const SimpleCard: React.FC<SimpleCardProps> = ({
|
|
|
124
124
|
>
|
|
125
125
|
<NextImage
|
|
126
126
|
src={card.image}
|
|
127
|
-
alt={card.imageAlt ?? card.title ?? "card-icon"}
|
|
127
|
+
alt={card.imageAltText ?? card.imageDescription ?? card.imageAlt ?? card.title ?? "card-icon"}
|
|
128
128
|
width={imgWidth}
|
|
129
129
|
height={imgHeight}
|
|
130
130
|
className={cx(
|
|
@@ -17,6 +17,10 @@ export type Item = {
|
|
|
17
17
|
title?: string;
|
|
18
18
|
image?: string;
|
|
19
19
|
imageAlt?: string;
|
|
20
|
+
/** Explicit image alt text; takes priority over the description fallback. */
|
|
21
|
+
imageAltText?: string;
|
|
22
|
+
/** Contentful asset description — used as the alt-text fallback. */
|
|
23
|
+
imageDescription?: string;
|
|
20
24
|
imageWidth?: number;
|
|
21
25
|
imageHeight?: number;
|
|
22
26
|
imageAlignment?: "left" | "right" | "center";
|
|
@@ -10,6 +10,7 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
10
10
|
description,
|
|
11
11
|
enableHeading,
|
|
12
12
|
image,
|
|
13
|
+
imageAltText,
|
|
13
14
|
list = [],
|
|
14
15
|
localPathPrefix = "/local",
|
|
15
16
|
subTitle,
|
|
@@ -48,12 +49,12 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
48
49
|
className={`${bgColorClasses[background]} component-container overflow-hidden ${color == "dark" ? "text-text" : "text-white"}`}
|
|
49
50
|
>
|
|
50
51
|
<div
|
|
51
|
-
className={`${maxWidth ? "max-w-120 xl:mx-auto" : ""} mx-5 mb-5 ${image ? "mt-12" : "mt-0 xl:mt-0"}
|
|
52
|
+
className={`${maxWidth ? "max-w-120 xl:mx-auto" : ""} mx-5 mb-5 ${image ? "mt-12" : "mt-0 xl:mt-0"} md:px-5 xl:my-20 xl:px-10`}
|
|
52
53
|
>
|
|
53
54
|
{title && (
|
|
54
55
|
<Text
|
|
55
56
|
as={enableHeading ? "h1" : "h2"}
|
|
56
|
-
className="heading2 md:heading1
|
|
57
|
+
className="heading2 text-center md:heading1"
|
|
57
58
|
>
|
|
58
59
|
{title}
|
|
59
60
|
</Text>
|
|
@@ -71,10 +72,13 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
71
72
|
className={`mt-8 flex flex-col items-center xl:flex-row ${image ? "xl:mt-16" : "xl:mt-10"} gap-10`}
|
|
72
73
|
>
|
|
73
74
|
<div
|
|
74
|
-
className={`flex flex-col gap-10 self-stretch xl:flex-[1_0_0] ${image ? "
|
|
75
|
+
className={`flex flex-col gap-10 self-stretch xl:flex-[1_0_0] ${image ? "pl-2 md:px-4 lg:pl-12 xl:items-start xl:pl-16" : "items-center"}`}
|
|
75
76
|
>
|
|
76
77
|
{description && (
|
|
77
|
-
<Text
|
|
78
|
+
<Text
|
|
79
|
+
as="p"
|
|
80
|
+
className={`body1 text-center${image ? "lg:text-left" : ""}`}
|
|
81
|
+
>
|
|
78
82
|
{description}
|
|
79
83
|
</Text>
|
|
80
84
|
)}
|
|
@@ -122,7 +126,9 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
122
126
|
for (let row = 0; row < numRows; row++) {
|
|
123
127
|
for (let col = 0; col < numColumns; col++) {
|
|
124
128
|
const index = col * numRows + row;
|
|
125
|
-
rearranged.push(
|
|
129
|
+
rearranged.push(
|
|
130
|
+
index < sortedList.length ? sortedList[index] : null
|
|
131
|
+
); // ← holds position
|
|
126
132
|
}
|
|
127
133
|
}
|
|
128
134
|
return rearranged.map((item, index: number) =>
|
|
@@ -156,7 +162,9 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
156
162
|
for (let row = 0; row < numRows; row++) {
|
|
157
163
|
for (let col = 0; col < numColumns; col++) {
|
|
158
164
|
const index = col * numRows + row;
|
|
159
|
-
rearranged.push(
|
|
165
|
+
rearranged.push(
|
|
166
|
+
index < sortedList.length ? sortedList[index] : null
|
|
167
|
+
);
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
return rearranged.map((item, index: number) =>
|
|
@@ -184,7 +192,12 @@ export const FindKinetic: React.FC<FindKineticProps> = ({
|
|
|
184
192
|
</div>
|
|
185
193
|
{image && (
|
|
186
194
|
<aside className="hidden md:block">
|
|
187
|
-
<NextImage
|
|
195
|
+
<NextImage
|
|
196
|
+
width={472}
|
|
197
|
+
height={100}
|
|
198
|
+
src={image}
|
|
199
|
+
alt={imageAltText ?? "image"}
|
|
200
|
+
/>
|
|
188
201
|
</aside>
|
|
189
202
|
)}
|
|
190
203
|
</div>
|