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:
2026-09-11 18:04:54 +02:00
parent 5c5cabbe5a
commit 33fe895539
9 changed files with 228 additions and 41 deletions
@@ -20,6 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal";
import { clearAll, notify } from "../../lib/notifications";
// Fire a keydown on window (where Modal installs its handler), inside act.
const press = (key: string, shiftKey = false) =>
@@ -91,6 +92,32 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
expect(document.activeElement).toBe(screen.getByText("beta"));
});
it("Tab reaches a toast shown inside the dialog, and wraps from it", () => {
// The dialog's toast host sits beside the panel; trapped to the panel, a toast's Undo or dismiss
// could not be reached from the keyboard at all.
render(
<Modal title="t" onClose={() => {}}>
<Body />
</Modal>,
);
act(() => void notify({ level: "info", message: "Saved" }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
screen.getByText("beta").focus();
press("Tab");
expect(document.activeElement).toBe(screen.getByText("beta")); // not wrapped back to the top
// The toast is the edge now: past it, Tab wraps to the first control, and Shift+Tab reaches it.
dismissToast.focus();
press("Tab");
expect(document.activeElement).toBe(closeBtn());
press("Tab", true);
expect(document.activeElement).toBe(dismissToast);
} finally {
act(() => clearAll());
}
});
it("Tab pulls focus back inside when it has escaped to the page behind", () => {
const outside = document.createElement("button");
document.body.appendChild(outside);
@@ -189,3 +216,22 @@ describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
expect(document.activeElement).toBe(panelEl());
});
});
describe("Modal — toasts raised while it is open", () => {
afterEach(() => {
cleanup();
act(() => clearAll());
});
it("shows them inside the dialog, above its scrim", () => {
// The app's Toaster is in a stacking context under this body-level portal: a failed share or
// rename was drawn under the scrim, and clicking it landed on the backdrop and closed the dialog.
render(
<Modal title="Share" onClose={() => {}}>
<Body />
</Modal>,
);
act(() => void notify({ level: "error", message: "Couldn't share it." }));
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
});
});