Deep-review round 4, the overlays. - Toast hosts were ranked by mount time, and PlayerModal swaps its host on every fullscreen change: leaving true fullscreen with the download dialog open put the player's host back on top, so toasts were drawn under that dialog's scrim and a click on one closed the dialog. Each overlay takes a rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders. - A dialog open while fullscreen is left keeps a stable portal container (moved, not re-created): React tore the portal down when `fsRoot` changed, resetting a half-filled form and dropping focus to <body>. Focus is put back after the move. - The onboarding wizard is a layer, not a raw document listener: one Escape used to dismiss the error dialog over it AND end onboarding, skipping the write-scope step. - `navigateTo` (a toast's "View", clicked inside the player's own host) goes over the overlays it unmounts, so the player's Back-to-close cleanup no longer pops the page it just opened — the same fix Shift+W already had. The access-requests toast action too. - The live region arms itself a task after it takes over inherited toasts: React flushes effects in the same task as that render, so the "silent inheritance" arming was a no-op. - The overlay-host guard matches class tokens in any order (PlexPlayer's root was never checked), globs `ui/` too (ui/Modal was not checked either), and requires the rank.
80 lines
3.6 KiB
TypeScript
80 lines
3.6 KiB
TypeScript
// 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 order={useOverlayOrder()} />` 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 layer at
|
|
// z-overlay or above mounts an overlay host, ranked by when the overlay opened.
|
|
//
|
|
// The class TOKENS are matched in any order, and the layer need not be `inset-0`: the first version
|
|
// matched the exact string "fixed inset-0 z-overlay", so PlexPlayer's own root (`fixed left-0 top-0
|
|
// z-overlay`) was never checked at all, and a new viewer copying it would have slipped past
|
|
// (deep-review round 4).
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
|
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
|
// (deep-review round 4).
|
|
const sources = import.meta.glob(["../**/*.tsx", "./*.tsx"], {
|
|
query: "?raw",
|
|
import: "default",
|
|
eager: true,
|
|
});
|
|
|
|
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
|
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
|
// there — App renders Welcome instead of the shell).
|
|
const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]);
|
|
|
|
/** Every class string in the file (quoted or templated), including multi-line ones. */
|
|
const classStrings = (src: string): string[] =>
|
|
[...src.matchAll(/className=\{?[`"']([^`"']*)[`"']/g)].map((m) => m[1]!);
|
|
|
|
const coversTheViewport = (src: string) =>
|
|
classStrings(src).some(
|
|
(cls) =>
|
|
/(^|\s)fixed(\s|$)/.test(cls) &&
|
|
/(^|\s)z-(overlay|popover|tooltip)(\s|$)/.test(cls) &&
|
|
(/(^|\s)inset-0(\s|$)/.test(cls) ||
|
|
(/(^|\s)left-0(\s|$)/.test(cls) && /(^|\s)top-0(\s|$)/.test(cls))),
|
|
);
|
|
|
|
const overlays = () =>
|
|
Object.entries(sources)
|
|
.filter(([path]) => !path.endsWith(".test.tsx") && !EXEMPT.has(path))
|
|
.filter(([, src]) => coversTheViewport(src as string));
|
|
|
|
describe("full-screen overlays host their own toasts", () => {
|
|
it("every one that covers the viewport mounts an overlay host", () => {
|
|
const missing = overlays()
|
|
.filter(([, src]) => !(src as string).includes("<Toaster overlay"))
|
|
.map(([path]) => path);
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("and ranks it by when the overlay opened, not by when the host mounted", () => {
|
|
// A host that takes its own rank jumps to the top whenever it remounts — which PlayerModal's does
|
|
// on every fullscreen change, putting it back over a dialog opened above the player.
|
|
const unranked = overlays()
|
|
.filter(([, src]) => !(src as string).includes("useOverlayOrder()"))
|
|
.map(([path]) => path);
|
|
expect(unranked).toEqual([]);
|
|
});
|
|
|
|
it("finds the overlays it is meant to check", () => {
|
|
// Modal, PlayerModal, PlexPlayer and the wizard at least — PlexPlayer is the one the exact-string
|
|
// check used to miss.
|
|
const paths = overlays().map(([p]) => p);
|
|
expect(paths).toEqual(
|
|
expect.arrayContaining([
|
|
"./Modal.tsx",
|
|
"../PlayerModal.tsx",
|
|
"../PlexPlayer.tsx",
|
|
"../OnboardingWizard.tsx",
|
|
]),
|
|
);
|
|
});
|
|
});
|