fix(ui): every full-screen overlay hosts its own toasts, above the players' controls, without re-announcing them
Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished). - Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and lets clicks between cards through: at the app's own spot it covered the Plex transport row and PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10, stop, prev/next, mute all hit the toast). - ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />. - PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it was painted under it). - A host that takes over shows the toasts it inherits silently (no second role=alert, its region aria-live=off until they are in), and each countdown bar starts as far in as its toast already is instead of restarting while the real dismiss timer does not. - The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
This commit is contained in:
@@ -6,6 +6,7 @@ import { useBackToClose } from "../../lib/history";
|
||||
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
import { Button } from "./Button";
|
||||
import Toaster from "./Toaster";
|
||||
|
||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||
@@ -56,6 +57,7 @@ export default function Modal({
|
||||
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
||||
// the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop).
|
||||
const pressedOnBackdrop = useRef(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount.
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -74,7 +76,10 @@ export default function Modal({
|
||||
if (e.key === "Tab") {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const items = focusablesIn(panel);
|
||||
// The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster),
|
||||
// and a toast's Undo or dismiss must be reachable from the keyboard like everything else.
|
||||
const scope = rootRef.current ?? panel;
|
||||
const items = focusablesIn(scope);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) {
|
||||
@@ -82,7 +87,7 @@ export default function Modal({
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (!panel.contains(active) || active === panel) {
|
||||
if (!scope.contains(active) || active === panel) {
|
||||
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
||||
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
||||
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
||||
@@ -119,6 +124,7 @@ export default function Modal({
|
||||
const top = placement === "top";
|
||||
return createPortal(
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer
|
||||
? "justify-end"
|
||||
@@ -161,6 +167,10 @@ export default function Modal({
|
||||
</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
{/* Toasts raised while a dialog is open show inside it: the app's own host is under this
|
||||
scrim, where a failed share or rename was unreadable and a click on it closed the dialog
|
||||
(see ui/Toaster). */}
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
fsRoot ?? document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user