@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.
Files changed (46) hide show
  1. package/dist/contentful/index.d.ts +12 -1
  2. package/dist/contentful/index.esm.js +3 -3
  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/core.d.ts +3 -1
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.esm.js +6 -6
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +6 -6
  11. package/dist/index.js.map +1 -1
  12. package/dist/next/index.esm.js +2 -2
  13. package/dist/next/index.esm.js.map +1 -1
  14. package/dist/next/index.js +2 -2
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/styles.css +1 -1
  17. package/package.json +2 -1
  18. package/src/components/next-image/index.tsx +10 -1
  19. package/src/components/video-link/contentful-video-embed.tsx +17 -1
  20. package/src/components/video-link/index.test.tsx +196 -24
  21. package/src/components/video-link/index.tsx +84 -15
  22. package/src/components/video-link/types.ts +15 -1
  23. package/src/components/video-link/vimeo-embed.tsx +51 -2
  24. package/src/components/video-link/youtube-embed.test.tsx +81 -54
  25. package/src/components/video-link/youtube-embed.tsx +97 -16
  26. package/src/components/video-link/youtube-iframe-api-loader.ts +45 -0
  27. package/src/contentful/blocks/callout/index.test.tsx +2 -2
  28. package/src/contentful/blocks/callout/index.tsx +1 -0
  29. package/src/contentful/blocks/cards/blog-card/index.tsx +9 -2
  30. package/src/contentful/blocks/cards/blog-card/types.ts +4 -0
  31. package/src/contentful/blocks/cards/floating-image-card/index.tsx +20 -6
  32. package/src/contentful/blocks/cards/floating-image-card/types.ts +4 -0
  33. package/src/contentful/blocks/cards/full-image-card/index.tsx +2 -1
  34. package/src/contentful/blocks/cards/full-image-card/types.ts +4 -0
  35. package/src/contentful/blocks/cards/simple-card/index.tsx +2 -2
  36. package/src/contentful/blocks/cards/simple-card/types.ts +4 -0
  37. package/src/contentful/blocks/find-kinetic/index.tsx +20 -7
  38. package/src/contentful/blocks/find-kinetic/types.ts +1 -0
  39. package/src/contentful/blocks/image-promo-bar/index.test.tsx +2 -2
  40. package/src/contentful/blocks/image-promo-bar/index.tsx +4 -1
  41. package/src/contentful/blocks/image-promo-bar/types.ts +6 -1
  42. package/src/contentful/blocks/primary-hero/index.tsx +6 -6
  43. package/src/hooks/contentful/use-contentful-rich-text.test.tsx +6 -6
  44. package/src/hooks/use-fullscreen-toggle.ts +23 -0
  45. package/src/hooks/use-video-analytics.ts +126 -0
  46. 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
- describe("YoutubeEmbed", () => {
11
- it("renders iframe with extracted video id from watch URL", () => {
12
- render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
13
- const iframe = screen.getByTitle("Embedded youtube");
14
- expect(iframe.getAttribute("src")).toBe(
15
- "https://www.youtube.com/embed/dQw4w9WgXcQ"
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
- render(<YoutubeEmbed link="https://youtu.be/dQw4w9WgXcQ" />);
21
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toContain(
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
- render(<YoutubeEmbed link="https://www.youtube.com/embed/dQw4w9WgXcQ" />);
28
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toContain(
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 embedId for invalid URL", () => {
34
- render(<YoutubeEmbed link="https://example.com/notavideo" />);
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 embedId when link is empty", () => {
41
- render(<YoutubeEmbed link="" />);
42
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
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 embedId when link is undefined", () => {
48
- render(<YoutubeEmbed link={undefined} />);
49
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
50
- "https://www.youtube.com/embed/"
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("appends autoplay param when autoplay is true", () => {
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
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
62
- "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1"
63
- );
88
+ await waitFor(() => expect(capturedPlayerVars).toBeDefined());
89
+ expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 1, mute: 1 });
64
90
  });
65
91
 
66
- it("does not append autoplay param when autoplay is false", () => {
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
- autoplay={false}
99
+ onPlay={onPlay}
100
+ onPause={onPause}
101
+ onEnded={onEnded}
71
102
  />
72
103
  );
73
- expect(
74
- screen.getByTitle("Embedded youtube").getAttribute("src")
75
- ).not.toContain("autoplay");
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("has correct iframe attributes", () => {
89
- render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
90
- const iframe = screen.getByTitle("Embedded youtube");
91
- expect(iframe).toHaveAttribute(
92
- "allow",
93
- "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
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 { link, containerClassName, autoplay = false } = props;
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
- const embedId = useMemo(() => {
17
- if (!link) return "";
45
+ useEffect(() => {
46
+ if (!embedId || !containerRef.current) return undefined;
47
+ let player: any;
48
+ let cancelled = false;
18
49
 
19
- const href = link.toString();
20
- const regExp =
21
- /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
22
- const match = href.match(regExp);
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
- return match && match[7].length === 11 ? match[7] : "";
25
- }, [link]);
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
- // Add autoplay parameter to the URL if autoplay is true
28
- const autoplayParam = autoplay ? "?autoplay=1" : "";
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
- <iframe
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-6 by default", () => {
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-6");
264
+ expect(gridHolder?.className).toContain("gap-3");
265
265
  });
266
266
 
267
267
  it("calculates 3 columns for 6 items (divisible by 3)", () => {
@@ -140,6 +140,7 @@ export const Callout: React.FC<CalloutProps> = ({
140
140
  date={blogItem.blogCreationDate}
141
141
  category={blogItem.category}
142
142
  image={blogItem.cover}
143
+ imageAltText={blogItem.imageAltText}
143
144
  asGrid={false}
144
145
  {...widthProps}
145
146
  />
@@ -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={image.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={image.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={image.title ?? title ?? "promo-bar-image"}
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] lg:mb-0",
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={image.title ?? title ?? "card-image"}
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 as="div" className="body1 -mt-1 mb-0 flex-1 text-text md:-mt-2 lg:mt-2">
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 || (cta.buttonIconName ? undefined : "text-with-icon")
124
+ cta.showButtonAs ||
125
+ (cta.buttonIconName ? undefined : "text-with-icon")
113
126
  }
114
127
  iconName={
115
- cta.iconName || (cta.buttonIconName ? undefined : "expand_circle_right")
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"} xl:my-20 md:px-5 xl:px-10`}
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 text-center"
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 ? "xl:items-start pl-2 md:px-4 lg:pl-12 xl:pl-16" : "items-center"}`}
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 as="p" className={`body1 text-center${image ? " lg:text-left" : ""}`}>
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(index < sortedList.length ? sortedList[index] : null); // ← holds position
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(index < sortedList.length ? sortedList[index] : null);
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 width={472} height={100} src={image} alt="image" />
195
+ <NextImage
196
+ width={472}
197
+ height={100}
198
+ src={image}
199
+ alt={imageAltText ?? "image"}
200
+ />
188
201
  </aside>
189
202
  )}
190
203
  </div>