- NotificationsPanel per-row .glass -> glass-card (the rows are cards, not panel surface). - New .overlay-backdrop recipe (4px blur = backdrop-blur-xs) with a data-perf override that DROPS the blur. The viewport-sized modal scrim blur (Modal/PlayerModal/Welcome) was a raw backdrop-blur-xs utility that survived perf mode while every .glass had its blur dropped — the most expensive blur in the app. Overlays keep their bg-black/NN. - gotcha: write ONLY the standard backdrop-filter; a manual -webkit- line makes lightningcss drop the standard prop (webkit-only output -> no blur in Firefox).
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
import { useEffect, 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 { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
|
import { useFullscreenElement } from "../lib/fullscreen";
|
|
|
|
// 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();
|
|
|
|
// 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();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
removeLayer(id);
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}, []);
|
|
|
|
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"
|
|
>
|
|
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
|
|
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
|
<h2 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,
|
|
);
|
|
}
|