From 121ebeebc1966a1b83b158342c6357af9d4c1e16 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 7 Oct 2026 23:57:04 +0200 Subject: [PATCH] fix(ui): an Escape while a dialog's own video is fullscreen does not close the dialog The page gets the Escape keydown with the video still the fullscreen element. When that element is the dialog's own content (the library player), closing the dialog took the player away with the fullscreen. A dialog opened over something else that is fullscreen still closes. --- .../ui/Modal.fullscreen.dom.test.tsx | 36 +++++++++++++++++++ frontend/src/components/ui/Modal.tsx | 8 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) 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