diff --git a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx index ac0771fa..07d5b65c 100644 --- a/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx +++ b/frontend/src/components/ui/Modal.fullscreen.dom.test.tsx @@ -106,3 +106,39 @@ describe("a dialog whose own content goes fullscreen", () => { expect(video.closest("body > div")).toBe(host); }); }); + +describe("Escape while the dialog's own content is fullscreen", () => { + const escape = () => + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + it("only leaves fullscreen (the browser's part) — the dialog stays open", () => { + // The browser spends that Escape on leaving fullscreen and still hands it to the page, with the + // video still the fullscreen element: the dialog closed with it, and the viewer lost the player. + const onClose = vi.fn(); + render( + + , + ); + setFullscreen(screen.getByLabelText("film")); + escape(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("still closes a dialog opened over something else that is fullscreen", () => { + const stage = document.createElement("div"); + document.body.appendChild(stage); + setFullscreen(stage); + const onClose = vi.fn(); + render( + +

form

+
, + ); + escape(); + expect(onClose).toHaveBeenCalledTimes(1); + stage.remove(); + }); +}); diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index d3babaf4..9f65bfec 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { useBackToClose } from "../../lib/history"; import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack"; -import { useFullscreenPortalHost } from "../../lib/fullscreen"; +import { fullscreenElement, useFullscreenPortalHost } from "../../lib/fullscreen"; import { firstFieldIn, focusablesIn, trapTab, useDialogChrome } from "./dialogFocus"; import { IconButton } from "./IconButton"; import Toaster from "./Toaster"; @@ -151,7 +151,11 @@ export default function Modal({ const onKey = (e: KeyboardEvent) => { if (takeEscape(e, id)) { e.stopPropagation(); - close(); + // The browser spends an Escape on leaving fullscreen and still hands it to the page, the + // fullscreen element still set. When that element is the dialog's own content (the library + // player's