@windstream/react-shared-components 0.2.50 → 0.2.52

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 (35) hide show
  1. package/dist/contentful/index.d.ts +5 -1
  2. package/dist/contentful/index.esm.js +15 -3
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +15 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +4 -2
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.esm.js +18 -6
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +18 -6
  11. package/dist/index.js.map +1 -1
  12. package/dist/next/index.esm.js +16 -2
  13. package/dist/next/index.esm.js.map +1 -1
  14. package/dist/next/index.js +16 -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/image-promo-bar/index.test.tsx +2 -2
  28. package/src/contentful/blocks/image-promo-bar/index.tsx +2 -0
  29. package/src/contentful/blocks/image-promo-bar/types.ts +5 -1
  30. package/src/contentful/blocks/navigation/index.test.tsx +34 -0
  31. package/src/contentful/blocks/navigation/index.tsx +33 -4
  32. package/src/contentful/blocks/navigation/types.ts +3 -0
  33. package/src/hooks/use-fullscreen-toggle.ts +23 -0
  34. package/src/hooks/use-video-analytics.ts +126 -0
  35. 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
+ }
@@ -177,14 +177,14 @@ describe("ImagePromoBar", () => {
177
177
  expect(container.querySelector(".max-w-120")).not.toBeInTheDocument();
178
178
  });
179
179
 
180
- it("applies lg:flex-row-reverse when mediaPosition is true", () => {
180
+ it("applies xl:flex-row-reverse when mediaPosition is true", () => {
181
181
  const { container } = render(
182
182
  <ImagePromoBar {...defaultProps} mediaPosition={true} />
183
183
  );
184
184
  expect(container.innerHTML).toContain("xl:flex-row-reverse");
185
185
  });
186
186
 
187
- it("applies lg:flex-row when mediaPosition is false", () => {
187
+ it("applies xl:flex-row when mediaPosition is false", () => {
188
188
  const { container } = render(
189
189
  <ImagePromoBar {...defaultProps} mediaPosition={false} />
190
190
  );
@@ -34,6 +34,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
34
34
  isAboveTheFold = false,
35
35
  onModalButtonClick,
36
36
  onClick,
37
+ onVideoEvent,
37
38
  renderCheckPlans,
38
39
  enableAnimation = false,
39
40
  }) => {
@@ -200,6 +201,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
200
201
  imageHeight={imageHeight}
201
202
  useOriginalImageAspectRatio={useOriginalImageAspectRatio}
202
203
  isAboveTheFold={isAboveTheFold}
204
+ onVideoEvent={onVideoEvent}
203
205
  />
204
206
  </aside>
205
207
  </div>
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
 
3
- import { VideoLinkProps } from "@shared/components/video-link";
3
+ import {
4
+ VideoAnalyticsHandler,
5
+ VideoLinkProps,
6
+ } from "@shared/components/video-link";
4
7
  import { CheckPlansProps } from "@shared/types/micro-components";
5
8
 
6
9
  export type ImagePromoBarProps = {
@@ -27,6 +30,7 @@ export type ImagePromoBarProps = {
27
30
  maxWidth?: boolean;
28
31
  color: "light" | "dark";
29
32
  enableAnimation?: boolean;
33
+ onVideoEvent?: VideoAnalyticsHandler;
30
34
  onModalButtonClick?: (id?: string) => void;
31
35
  onClick?: (event: React.MouseEvent, source: "primary" | "secondary") => void;
32
36
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
@@ -149,6 +149,40 @@ const mobileMenuProps: NavigationProps = {
149
149
  describe("Navigation", () => {
150
150
  beforeEach(() => jest.clearAllMocks());
151
151
 
152
+ describe("enableHamburgerNavigationOnDesktop (desktop hamburger A/B variant)", () => {
153
+ it("keeps current behavior when the flag is off", () => {
154
+ const { container } = render(<Navigation {...defaultProps} />);
155
+
156
+ expect(
157
+ container.querySelector(".mobile-nav-section")?.className
158
+ ).toContain("lg:hidden");
159
+ expect(
160
+ container.querySelector(".desktop-nav-section")?.className
161
+ ).toContain("lg:block");
162
+ expect(
163
+ container.querySelector(".utility-container")?.className
164
+ ).toContain("lg:block");
165
+ });
166
+
167
+ it("shows the hamburger and hides desktop + utility sections when enabled", () => {
168
+ const { container } = render(
169
+ <Navigation {...defaultProps} enableHamburgerNavigationOnDesktop />
170
+ );
171
+
172
+ // mobile section no longer hidden on desktop
173
+ expect(
174
+ container.querySelector(".mobile-nav-section")?.className
175
+ ).not.toContain("lg:hidden");
176
+ // desktop + utility hidden at all widths (no lg:block to re-show them)
177
+ expect(
178
+ container.querySelector(".desktop-nav-section")?.className
179
+ ).not.toContain("lg:block");
180
+ expect(
181
+ container.querySelector(".utility-container")?.className
182
+ ).not.toContain("lg:block");
183
+ });
184
+ });
185
+
152
186
  describe("Basic rendering", () => {
153
187
  it("renders nav with utility and main nav sections", () => {
154
188
  const { container } = render(<Navigation {...defaultProps} />);
@@ -42,6 +42,7 @@ export const Navigation: React.FC<NavigationProps> = props => {
42
42
  showCallButton = true,
43
43
  showCallNowCtaInMainNav = false,
44
44
  showMobileSliderMenu = true,
45
+ enableHamburgerNavigationOnDesktop = false,
45
46
  callNowCtaIcon,
46
47
  navigationBackgroundColor,
47
48
  navigationLinkColor,
@@ -106,11 +107,28 @@ export const Navigation: React.FC<NavigationProps> = props => {
106
107
  (supportNavigationLinks?.length ?? 0) > 0 ||
107
108
  (accountNavigationLinks?.length ?? 0) > 0;
108
109
 
110
+ // A/B variant: when enabled, the hamburger (mobile section) shows on desktop
111
+ // and the inline desktop nav is hidden. Off = current behavior.
112
+ const mobileNavResponsiveClass = enableHamburgerNavigationOnDesktop
113
+ ? undefined // visible at all widths, including desktop
114
+ : "lg:hidden"; // default: mobile/tablet only
115
+ const desktopNavResponsiveClass = enableHamburgerNavigationOnDesktop
116
+ ? "hidden" // hidden at all widths
117
+ : "hidden lg:block"; // default: desktop only
118
+ const utilityNavResponsiveClass = enableHamburgerNavigationOnDesktop
119
+ ? "hidden" // match mobile/tablet: hidden on desktop too
120
+ : "hidden lg:block"; // default: desktop only
121
+
109
122
  return (
110
123
  <div className="component-container">
111
124
  <nav className={`menu-container z-[1000]`}>
112
125
  {displayUtilityNavigation ? (
113
- <div className="utility-container hidden lg:block lg:border-b lg:px-2">
126
+ <div
127
+ className={cx(
128
+ "utility-container lg:border-b lg:px-2",
129
+ utilityNavResponsiveClass
130
+ )}
131
+ >
114
132
  <div className="mx-auto flex max-w-120 justify-between">
115
133
  <ul className="flex gap-5" aria-label="Utility Navigation">
116
134
  {utilityNavigationLinks?.map((links, index) => {
@@ -181,7 +199,12 @@ export const Navigation: React.FC<NavigationProps> = props => {
181
199
  )}
182
200
  aria-label="Main Navigation"
183
201
  >
184
- <div className="mobile-nav-section flex h-14 items-center justify-between border border-b px-5 py-[10px] lg:hidden">
202
+ <div
203
+ className={cx(
204
+ "mobile-nav-section flex h-14 items-center justify-between border border-b px-5 py-[10px]",
205
+ mobileNavResponsiveClass
206
+ )}
207
+ >
185
208
  <div>
186
209
  {logoUrl ? (
187
210
  <Link href="/" className="flex">
@@ -230,7 +253,12 @@ export const Navigation: React.FC<NavigationProps> = props => {
230
253
  </div>
231
254
  </div>
232
255
 
233
- <div className="desktop-nav-section hidden lg:block lg:border-b lg:px-2">
256
+ <div
257
+ className={cx(
258
+ "desktop-nav-section lg:border-b lg:px-2",
259
+ desktopNavResponsiveClass
260
+ )}
261
+ >
234
262
  <div className="mx-auto flex h-14 max-w-120 items-center justify-between">
235
263
  <div className="flex h-full">
236
264
  {logoUrl ? (
@@ -553,7 +581,8 @@ const DesktopSearchInput = (props: {
553
581
  height={32}
554
582
  alt="Search icon"
555
583
  role="button"
556
- onClick={redirectToSearchResults} />
584
+ onClick={redirectToSearchResults}
585
+ />
557
586
  {/* Input */}
558
587
  <Input
559
588
  ref={searchInputRef}
@@ -66,6 +66,9 @@ export interface Navigation {
66
66
  * (the one next to the cart icon and hamburger). The drawer
67
67
  * still renders the CallButton. Defaults to false. */
68
68
  hideMobileCallButton?: boolean;
69
+ /** When true, renders the mobile hamburger navigation in the desktop
70
+ * viewport and hides the inline desktop nav. Defaults to false. */
71
+ enableHamburgerNavigationOnDesktop?: boolean;
69
72
  }
70
73
 
71
74
  export type NavigationProps = Navigation;
@@ -0,0 +1,23 @@
1
+ import { useEffect, type RefObject } from "react";
2
+
3
+ export function useFullscreenToggle(
4
+ ref: RefObject<Element>,
5
+ onToggle?: (isFullscreen: boolean) => void
6
+ ) {
7
+ useEffect(() => {
8
+ if (!onToggle) return;
9
+
10
+ const handleChange = () => {
11
+ const fullscreenElement =
12
+ document.fullscreenElement || (document as any).webkitFullscreenElement;
13
+ onToggle(fullscreenElement === ref.current);
14
+ };
15
+
16
+ document.addEventListener("fullscreenchange", handleChange);
17
+ document.addEventListener("webkitfullscreenchange", handleChange);
18
+ return () => {
19
+ document.removeEventListener("fullscreenchange", handleChange);
20
+ document.removeEventListener("webkitfullscreenchange", handleChange);
21
+ };
22
+ }, [onToggle, ref]);
23
+ }