@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.
- package/dist/contentful/index.d.ts +5 -1
- package/dist/contentful/index.esm.js +15 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +15 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +18 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js +16 -2
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js +16 -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/image-promo-bar/index.test.tsx +2 -2
- package/src/contentful/blocks/image-promo-bar/index.tsx +2 -0
- package/src/contentful/blocks/image-promo-bar/types.ts +5 -1
- package/src/contentful/blocks/navigation/index.test.tsx +34 -0
- package/src/contentful/blocks/navigation/index.tsx +33 -4
- package/src/contentful/blocks/navigation/types.ts +3 -0
- 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
|
+
}
|
|
@@ -177,14 +177,14 @@ describe("ImagePromoBar", () => {
|
|
|
177
177
|
expect(container.querySelector(".max-w-120")).not.toBeInTheDocument();
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("applies
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|