Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all verified; fixed here. - NavigationProvider + history.ts (medium, correctness): the popstate handlers restored the open channel / Plex sub-view from history.state ONLY, but a fresh ?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's programmatic history.back() (closing a modal/player) silently closed the bookmarked channel or collapsed the sub-view to the grid. Give onPop the same URL fallback the initializers already use (history.ts extracts a shared readView so seed and popstate can't diverge again). - plexCards SeasonCard (medium, a11y): the "In progress" season badge was a reveal-idle swap-badge → display:none on touch, and a season has no progress bar, so an in-progress season was indistinguishable from unwatched on touch. Moved it to the top-left (the toggle owns top-right) and made it always visible. - plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE — CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles. - Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on the panel container itself (Node.contains(self) is true, so the pull-back branch never ran). Guard now also checks `active === panel`. - index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is revealed on hover AND focus-within → the badge overlapped the control on keyboard focus. Added the focus-within fade. - PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl guard. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and ?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal Shift+Tab from the panel stays inside; in-progress season badge visible on touch; watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
146 lines
6.3 KiB
TypeScript
146 lines
6.3 KiB
TypeScript
import { useEffect, useId, useRef, useState, 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 { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
|
import { useFullscreenElement } from "../lib/fullscreen";
|
|
|
|
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
|
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
|
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
|
const FOCUSABLE =
|
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
|
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
|
|
|
// 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",
|
|
}: {
|
|
title: ReactNode;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
maxWidth?: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
|
useBackToClose(onClose);
|
|
const fsRoot = useFullscreenElement();
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
|
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
|
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
|
// focus on <body>. useState initializer runs exactly once, on first render.
|
|
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
|
|
|
// 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 (!isTopmost(id)) return;
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
onCloseRef.current();
|
|
return;
|
|
}
|
|
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
|
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
|
|
if (e.key === "Tab") {
|
|
const panel = panelRef.current;
|
|
if (!panel) return;
|
|
const items = focusablesIn(panel);
|
|
const first = items[0];
|
|
const last = items[items.length - 1];
|
|
if (!first || !last) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
const active = document.activeElement;
|
|
if (!panel.contains(active) || active === panel) {
|
|
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
|
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
|
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
|
e.preventDefault();
|
|
(e.shiftKey ? last : first).focus();
|
|
} else if (e.shiftKey && active === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && active === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
|
// focus to the first control so the dialog opens focused, not stranded on the background.
|
|
const panel = panelRef.current;
|
|
if (panel && !panel.contains(document.activeElement)) {
|
|
(focusablesIn(panel)[0] ?? panel).focus();
|
|
}
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
removeLayer(id);
|
|
document.body.style.overflow = prev;
|
|
// Restore focus to the trigger, if it's still in the document and focusable.
|
|
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
|
};
|
|
}, [restoreTo]);
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 overlay-backdrop"
|
|
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={`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
|
|
onClick={onClose}
|
|
title={t("common.close")}
|
|
aria-label={t("common.close")}
|
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-5 pb-5">{children}</div>
|
|
</div>
|
|
</div>,
|
|
fsRoot ?? document.body,
|
|
);
|
|
}
|