Skip to content

Commit f35b027

Browse files
committed
feat: add workspace fullscreen controller
1 parent b3af113 commit f35b027

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { useRef } from "react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { useWorkspaceFullscreen } from "./use-workspace-fullscreen";
5+
6+
function installFullscreenApi() {
7+
let fullscreenElement: Element | null = null;
8+
9+
const requestFullscreen = vi.fn().mockImplementation(function (this: HTMLElement) {
10+
fullscreenElement = this;
11+
document.dispatchEvent(new Event("fullscreenchange"));
12+
return Promise.resolve();
13+
});
14+
15+
const exitFullscreen = vi.fn().mockImplementation(async () => {
16+
fullscreenElement = null;
17+
document.dispatchEvent(new Event("fullscreenchange"));
18+
});
19+
20+
Object.defineProperty(document, "fullscreenEnabled", {
21+
configurable: true,
22+
value: true,
23+
});
24+
25+
Object.defineProperty(document, "fullscreenElement", {
26+
configurable: true,
27+
get: () => fullscreenElement,
28+
});
29+
30+
Object.defineProperty(document, "exitFullscreen", {
31+
configurable: true,
32+
value: exitFullscreen,
33+
});
34+
35+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
36+
configurable: true,
37+
value: requestFullscreen,
38+
});
39+
40+
return {
41+
requestFullscreen,
42+
exitFullscreen,
43+
setFullscreenElement(next: Element | null) {
44+
fullscreenElement = next;
45+
document.dispatchEvent(new Event("fullscreenchange"));
46+
},
47+
};
48+
}
49+
50+
function clearFullscreenApi() {
51+
Object.defineProperty(document, "fullscreenEnabled", {
52+
configurable: true,
53+
value: false,
54+
});
55+
56+
Object.defineProperty(document, "fullscreenElement", {
57+
configurable: true,
58+
get: () => null,
59+
});
60+
61+
Object.defineProperty(document, "exitFullscreen", {
62+
configurable: true,
63+
value: undefined,
64+
});
65+
66+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
67+
configurable: true,
68+
value: undefined,
69+
});
70+
}
71+
72+
function HookHarness() {
73+
const targetRef = useRef<HTMLDivElement>(null);
74+
const controller = useWorkspaceFullscreen(targetRef);
75+
76+
return (
77+
<div>
78+
<div ref={targetRef} data-testid="fullscreen-target" />
79+
<output data-testid="supported">{String(controller.supported)}</output>
80+
<output data-testid="fullscreen">{String(controller.isFullscreen)}</output>
81+
<button
82+
type="button"
83+
onClick={() => {
84+
void controller.enterFullscreen();
85+
}}
86+
>
87+
enter
88+
</button>
89+
<button
90+
type="button"
91+
onClick={() => {
92+
void controller.exitFullscreen();
93+
}}
94+
>
95+
exit
96+
</button>
97+
<button
98+
type="button"
99+
onClick={() => {
100+
void controller.toggleFullscreen();
101+
}}
102+
>
103+
toggle
104+
</button>
105+
</div>
106+
);
107+
}
108+
109+
describe("useWorkspaceFullscreen", () => {
110+
afterEach(() => {
111+
clearFullscreenApi();
112+
vi.restoreAllMocks();
113+
});
114+
115+
it("reports unsupported when the browser fullscreen api is unavailable", async () => {
116+
clearFullscreenApi();
117+
118+
render(<HookHarness />);
119+
120+
await waitFor(() => {
121+
expect(screen.getByTestId("supported")).toHaveTextContent("false");
122+
});
123+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
124+
});
125+
126+
it("enters and exits fullscreen against the target element", async () => {
127+
const api = installFullscreenApi();
128+
129+
render(<HookHarness />);
130+
131+
await waitFor(() => {
132+
expect(screen.getByTestId("supported")).toHaveTextContent("true");
133+
});
134+
135+
fireEvent.click(screen.getByRole("button", { name: "enter" }));
136+
137+
await waitFor(() => {
138+
expect(api.requestFullscreen).toHaveBeenCalledTimes(1);
139+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("true");
140+
});
141+
142+
fireEvent.click(screen.getByRole("button", { name: "exit" }));
143+
144+
await waitFor(() => {
145+
expect(api.exitFullscreen).toHaveBeenCalledTimes(1);
146+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
147+
});
148+
});
149+
150+
it("tracks fullscreenchange even when the browser exits fullscreen outside the button", async () => {
151+
const api = installFullscreenApi();
152+
153+
render(<HookHarness />);
154+
155+
const target = await screen.findByTestId("fullscreen-target");
156+
157+
act(() => {
158+
api.setFullscreenElement(target);
159+
});
160+
161+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("true");
162+
163+
act(() => {
164+
api.setFullscreenElement(null);
165+
});
166+
167+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
168+
});
169+
170+
it("leaves the state in enter mode when requestFullscreen rejects", async () => {
171+
installFullscreenApi();
172+
const requestError = new Error("fullscreen denied");
173+
const requestSpy = vi
174+
.spyOn(HTMLElement.prototype, "requestFullscreen")
175+
.mockRejectedValue(requestError);
176+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177+
178+
render(<HookHarness />);
179+
180+
fireEvent.click(screen.getByRole("button", { name: "toggle" }));
181+
182+
await waitFor(() => {
183+
expect(requestSpy).toHaveBeenCalledTimes(1);
184+
expect(warnSpy).toHaveBeenCalledWith("Failed to enter fullscreen", requestError);
185+
});
186+
187+
expect(screen.getByTestId("fullscreen")).toHaveTextContent("false");
188+
});
189+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { type RefObject, useCallback, useEffect, useState } from "react";
2+
3+
export interface WorkspaceFullscreenController {
4+
supported: boolean;
5+
isFullscreen: boolean;
6+
enterFullscreen: () => Promise<void>;
7+
exitFullscreen: () => Promise<void>;
8+
toggleFullscreen: () => Promise<void>;
9+
}
10+
11+
function canEnterFullscreen(
12+
target: HTMLElement | null
13+
): target is HTMLElement & { requestFullscreen: NonNullable<HTMLElement["requestFullscreen"]> } {
14+
return Boolean(
15+
document.fullscreenEnabled &&
16+
target &&
17+
typeof target.requestFullscreen === "function" &&
18+
typeof document.exitFullscreen === "function"
19+
);
20+
}
21+
22+
export function useWorkspaceFullscreen(
23+
targetRef: RefObject<HTMLElement | null>
24+
): WorkspaceFullscreenController {
25+
const [supported, setSupported] = useState(false);
26+
const [isFullscreen, setIsFullscreen] = useState(false);
27+
28+
useEffect(() => {
29+
const syncState = () => {
30+
const target = targetRef.current;
31+
setSupported(canEnterFullscreen(target));
32+
setIsFullscreen(Boolean(target && document.fullscreenElement === target));
33+
};
34+
35+
syncState();
36+
document.addEventListener("fullscreenchange", syncState);
37+
38+
return () => {
39+
document.removeEventListener("fullscreenchange", syncState);
40+
};
41+
}, [targetRef]);
42+
43+
const enterFullscreen = useCallback(async () => {
44+
const target = targetRef.current;
45+
if (!canEnterFullscreen(target)) {
46+
return;
47+
}
48+
49+
try {
50+
await target.requestFullscreen();
51+
} catch (error) {
52+
console.warn("Failed to enter fullscreen", error);
53+
}
54+
}, [targetRef]);
55+
56+
const exitFullscreen = useCallback(async () => {
57+
if (typeof document.exitFullscreen !== "function" || !document.fullscreenElement) {
58+
return;
59+
}
60+
61+
try {
62+
await document.exitFullscreen();
63+
} catch (error) {
64+
console.warn("Failed to exit fullscreen", error);
65+
}
66+
}, []);
67+
68+
const toggleFullscreen = useCallback(async () => {
69+
if (targetRef.current && document.fullscreenElement === targetRef.current) {
70+
await exitFullscreen();
71+
return;
72+
}
73+
74+
await enterFullscreen();
75+
}, [enterFullscreen, exitFullscreen, targetRef]);
76+
77+
return {
78+
supported,
79+
isFullscreen,
80+
enterFullscreen,
81+
exitFullscreen,
82+
toggleFullscreen,
83+
};
84+
}

0 commit comments

Comments
 (0)