The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
145 lines
6.3 KiB
TypeScript
145 lines
6.3 KiB
TypeScript
import { useEffect, useId, useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { X } from "lucide-react";
|
|
import { useBackToClose } from "../../lib/history";
|
|
import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
|
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
|
import { trapTab, useDialogChrome } from "./dialogFocus";
|
|
import { Button } from "./Button";
|
|
import Toaster from "./Toaster";
|
|
import { useOverlayOrder } from "./overlayOrder";
|
|
|
|
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
|
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
|
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
|
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
|
|
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
|
|
// download dialog) would otherwise open invisibly.
|
|
export default function Modal({
|
|
title,
|
|
onClose,
|
|
children,
|
|
maxWidth = "max-w-lg",
|
|
placement = "center",
|
|
}: {
|
|
title: ReactNode;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
maxWidth?: string;
|
|
/** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top
|
|
* of the viewport (where a search-first dialog like the command palette belongs, so the list grows
|
|
* downwards instead of the whole thing jumping as results change). Everything else about the shell
|
|
* — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A
|
|
* placement is not a second dialog implementation: duplicating that behaviour is exactly how two
|
|
* shells drift apart. */
|
|
placement?: "center" | "drawer" | "top";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
|
useBackToClose(onClose);
|
|
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
|
// element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child
|
|
// still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
|
const host = useFullscreenPortalHost();
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
// Focus lands in the panel; OWNERSHIP is the whole dialog — the same element the Tab trap below
|
|
// is scoped to, toast host included. Tracking ownership over the panel alone meant tabbing to an
|
|
// in-dialog toast counted as leaving, and the close-time restore was then skipped (round 6).
|
|
useDialogChrome(panelRef, rootRef);
|
|
const overlayOrder = useOverlayOrder();
|
|
|
|
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
|
// 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);
|
|
|
|
// Keep a stable handler across renders so the stack id is assigned once per mount.
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
useEffect(() => {
|
|
const id = pushLayer();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && isTopmost(id)) {
|
|
e.stopPropagation();
|
|
onCloseRef.current();
|
|
return;
|
|
}
|
|
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
|
// page behind the backdrop. A nested modal or a click-opened menu above traps for itself; a hover
|
|
// card open under the pointer (a passive layer) does not, so it must not switch the trap off.
|
|
if (e.key === "Tab" && !anyBlockingLayerAbove(id)) {
|
|
const panel = panelRef.current;
|
|
if (!panel) return;
|
|
// 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.
|
|
trapTab(e, rootRef.current ?? panel, panel);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
removeLayer(id);
|
|
};
|
|
}, []);
|
|
|
|
const drawer = placement === "drawer";
|
|
const top = placement === "top";
|
|
// Only on a server render, where the hook has no `document` to build a host from.
|
|
if (!host) return null;
|
|
return createPortal(
|
|
<div
|
|
ref={rootRef}
|
|
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
|
drawer
|
|
? "justify-end"
|
|
: top
|
|
? "items-start justify-center p-4 pt-[8vh] sm:p-6 sm:pt-[10vh]"
|
|
: "items-center justify-center p-4 sm:p-6"
|
|
}`}
|
|
onMouseDown={(e) => {
|
|
pressedOnBackdrop.current = e.target === e.currentTarget;
|
|
}}
|
|
onClick={(e) => {
|
|
if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose();
|
|
}}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
tabIndex={-1}
|
|
className={
|
|
drawer
|
|
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
|
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
|
}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
|
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
|
{title}
|
|
</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0"
|
|
onClick={onClose}
|
|
title={t("common.close")}
|
|
aria-label={t("common.close")}
|
|
icon={<X className="w-4 h-4" />}
|
|
/>
|
|
</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 order={overlayOrder} />
|
|
</div>,
|
|
host,
|
|
);
|
|
}
|