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:
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user