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:
@@ -0,0 +1,33 @@
|
||||
// Every full-screen overlay hosts its own toasts (see ui/Toaster).
|
||||
//
|
||||
// The app's Toaster lives in the content column, a stacking context: anything that covers the
|
||||
// viewport at z-overlay paints over it, so a toast raised while that overlay is open is unreadable and
|
||||
// a click on it lands on the overlay's backdrop. The fix is a convention — the overlay mounts
|
||||
// `<Toaster overlay />` inside itself — and a convention stated in a comment is one the next overlay
|
||||
// forgets (deep-review round 3 found every ui/Modal and the onboarding wizard without one). So it is
|
||||
// checked here: a component that renders a viewport-covering z-overlay layer mounts an overlay host.
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const sources = import.meta.glob("../**/*.tsx", { query: "?raw", import: "default", eager: true });
|
||||
|
||||
// Covers the viewport for a moment with nothing a toast could be about (a lazy chunk's black
|
||||
// placeholder while the player loads).
|
||||
const EXEMPT = new Set(["../PlexBrowse.tsx"]);
|
||||
|
||||
describe("full-screen overlays host their own toasts", () => {
|
||||
it("every one that covers the viewport at z-overlay mounts <Toaster overlay />", () => {
|
||||
const missing = Object.entries(sources)
|
||||
.filter(([path]) => !path.endsWith(".test.tsx") && !EXEMPT.has(path))
|
||||
.filter(([, src]) => /fixed inset-0 z-overlay/.test(src as string))
|
||||
.filter(([, src]) => !(src as string).includes("<Toaster overlay />"))
|
||||
.map(([path]) => path);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the overlays it is meant to check", () => {
|
||||
const covered = Object.entries(sources).filter(([, src]) =>
|
||||
/fixed inset-0 z-overlay/.test(src as string),
|
||||
);
|
||||
expect(covered.length).toBeGreaterThanOrEqual(3); // Modal, PlayerModal, the wizard at least
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user