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.
This commit is contained in:
2026-10-07 23:57:04 +02:00
parent ee5290ba01
commit 121ebeebc1
2 changed files with 42 additions and 2 deletions
@@ -106,3 +106,39 @@ describe("a dialog whose own content goes fullscreen", () => {
expect(video.closest("body > div")).toBe(host); 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(
<Modal initialFocus="close" title="Player" onClose={onClose}>
<video aria-label="film" />
</Modal>,
);
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(
<Modal initialFocus="close" title="Download" onClose={onClose}>
<p>form</p>
</Modal>,
);
escape();
expect(onClose).toHaveBeenCalledTimes(1);
stage.remove();
});
});
+6 -2
View File
@@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history"; import { useBackToClose } from "../../lib/history";
import { anyBlockingLayerAbove, pushLayer, removeLayer, takeEscape } from "../../lib/layerStack"; 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 { firstFieldIn, focusablesIn, trapTab, useDialogChrome } from "./dialogFocus";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import Toaster from "./Toaster"; import Toaster from "./Toaster";
@@ -151,7 +151,11 @@ export default function Modal({
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) { if (takeEscape(e, id)) {
e.stopPropagation(); 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 <video>), the Escape was the browser's: closing too lost the viewer the player.
const fs = fullscreenElement();
if (!(fs && rootRef.current?.contains(fs))) close();
return; return;
} }
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert // Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert