mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
e2536daeb8
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
279 lines
7.9 KiB
TypeScript
279 lines
7.9 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Text, ActionIcon, ScrollArea } from "@mantine/core";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import {
|
|
CardModalPhase,
|
|
CARD_MODAL_TIMINGS,
|
|
} from "@app/hooks/useCardModalAnimation";
|
|
|
|
interface CardExpansionModalProps {
|
|
phase: CardModalPhase;
|
|
cardRect: DOMRect | null;
|
|
textExpanded: boolean;
|
|
onClose: () => void;
|
|
icon: React.ReactNode;
|
|
count: number;
|
|
labelSingular: string;
|
|
labelPlural: string;
|
|
children: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
toolbar?: React.ReactNode;
|
|
widthRem?: number;
|
|
heightRem?: number;
|
|
/** Replace ScrollArea with a flex-fill container so children can expand to full body height */
|
|
fillHeight?: boolean;
|
|
}
|
|
|
|
const MODAL_W_REM = 56;
|
|
const MODAL_H_REM = 38;
|
|
const HEADER_H_REM = 3.5;
|
|
const MODAL_TOP_FRACTION = 0.12;
|
|
const EASING = "cubic-bezier(0.22,1,0.36,1)";
|
|
|
|
export function CardExpansionModal({
|
|
phase,
|
|
cardRect,
|
|
textExpanded,
|
|
onClose,
|
|
icon,
|
|
count,
|
|
labelSingular,
|
|
labelPlural,
|
|
children,
|
|
footer,
|
|
toolbar,
|
|
widthRem,
|
|
heightRem,
|
|
fillHeight,
|
|
}: CardExpansionModalProps) {
|
|
const { t } = useTranslation();
|
|
const [viewportW, setViewportW] = useState(window.innerWidth);
|
|
const [viewportH, setViewportH] = useState(window.innerHeight);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
setViewportW(window.innerWidth);
|
|
setViewportH(window.innerHeight);
|
|
};
|
|
window.addEventListener("resize", handler);
|
|
return () => window.removeEventListener("resize", handler);
|
|
}, []);
|
|
|
|
if (phase === "closed" || !cardRect) return null;
|
|
|
|
const rootFontSize = parseFloat(
|
|
getComputedStyle(document.documentElement).fontSize,
|
|
);
|
|
const modalW = Math.min(
|
|
(widthRem ?? MODAL_W_REM) * rootFontSize,
|
|
viewportW * 0.9,
|
|
);
|
|
const modalH = Math.min(
|
|
(heightRem ?? MODAL_H_REM) * rootFontSize,
|
|
viewportH * 0.85,
|
|
);
|
|
const headerH = HEADER_H_REM * rootFontSize;
|
|
const finalLeft = (viewportW - modalW) / 2;
|
|
const finalTop = Math.min(
|
|
viewportH * MODAL_TOP_FRACTION,
|
|
viewportH - modalH - 16,
|
|
);
|
|
|
|
const isAtCard = phase === "entering" || phase === "closing-header";
|
|
const isAtHeader = phase === "header-open";
|
|
|
|
const cardH = isAtCard ? cardRect.height : isAtHeader ? headerH : modalH;
|
|
|
|
const getTransition = () => {
|
|
const s = CARD_MODAL_TIMINGS;
|
|
if (phase === "header-open")
|
|
return `top ${s.headerStretch}ms ${EASING}, left ${s.headerStretch}ms ${EASING}, width ${s.headerStretch}ms ${EASING}, height ${s.headerStretch}ms ${EASING}`;
|
|
if (phase === "open") return `height ${s.bodyDrop}ms ${EASING}`;
|
|
if (phase === "closing-body") return `height ${s.closeBody}ms ease-in`;
|
|
if (phase === "closing-header")
|
|
return `top ${s.closeStretch}ms ${EASING}, left ${s.closeStretch}ms ${EASING}, width ${s.closeStretch}ms ${EASING}, height ${s.closeStretch}ms ${EASING}, opacity ${s.closeStretch}ms ease`;
|
|
return "none";
|
|
};
|
|
|
|
const backdropOpacity =
|
|
phase === "entering" || phase === "closing-header" ? 0 : 1;
|
|
const cardOpacity = phase === "closing-header" ? 0 : 1;
|
|
|
|
const showBody = phase === "open" || phase === "closing-body";
|
|
|
|
return createPortal(
|
|
<div style={{ position: "fixed", inset: 0, zIndex: 300 }}>
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
backgroundColor: "rgba(0,0,0,0.55)",
|
|
opacity: backdropOpacity,
|
|
transition: "opacity 220ms ease",
|
|
willChange: "opacity",
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: isAtCard ? cardRect.top : finalTop,
|
|
left: isAtCard ? cardRect.left : finalLeft,
|
|
width: isAtCard ? cardRect.width : modalW,
|
|
height: cardH,
|
|
opacity: cardOpacity,
|
|
transition: getTransition(),
|
|
willChange: "top, left, width, height, opacity",
|
|
borderRadius: "var(--mantine-radius-md)",
|
|
overflow: "hidden",
|
|
backgroundColor: "var(--bg-toolbar)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
boxShadow: "0 1.5rem 3rem rgba(0,0,0,0.3)",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
position: "relative",
|
|
height: headerH,
|
|
flexShrink: 0,
|
|
borderBottom: "0.0625rem solid var(--border-subtle)",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
left: "1rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<Text
|
|
component="span"
|
|
fw={800}
|
|
style={{ fontSize: "1.375rem", lineHeight: 1, margin: 0 }}
|
|
>
|
|
{count}
|
|
</Text>
|
|
<div
|
|
style={{
|
|
maxWidth: textExpanded ? "16rem" : "0",
|
|
opacity: textExpanded ? 1 : 0,
|
|
overflow: "hidden",
|
|
whiteSpace: "nowrap",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
transition: `max-width 100ms ${EASING}, opacity 80ms ease`,
|
|
}}
|
|
>
|
|
<Text
|
|
component="span"
|
|
c="dimmed"
|
|
style={{
|
|
fontSize: "1rem",
|
|
paddingLeft: "0.5rem",
|
|
lineHeight: 1,
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{count === 1 ? labelSingular : labelPlural}
|
|
</Text>
|
|
</div>
|
|
|
|
<ActionIcon
|
|
variant="subtle"
|
|
size="lg"
|
|
color="gray"
|
|
onClick={onClose}
|
|
aria-label={t("close", "Close")}
|
|
style={{
|
|
position: "absolute",
|
|
top: "0.25rem",
|
|
right: "0.375rem",
|
|
}}
|
|
>
|
|
<CloseIcon style={{ fontSize: "1.25rem" }} />
|
|
</ActionIcon>
|
|
</div>
|
|
</div>
|
|
|
|
{showBody && (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 0,
|
|
backgroundColor: "var(--bg-toolbar)",
|
|
}}
|
|
>
|
|
{toolbar && (
|
|
<div
|
|
style={{
|
|
flexShrink: 0,
|
|
borderBottom: "0.0625rem solid var(--border-subtle)",
|
|
}}
|
|
>
|
|
{toolbar}
|
|
</div>
|
|
)}
|
|
{fillHeight ? (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
padding: "0.75rem 1rem",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
) : (
|
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
|
<div
|
|
style={{
|
|
padding: "0.75rem 1rem",
|
|
backgroundColor: "var(--bg-toolbar)",
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
{footer && (
|
|
<div
|
|
style={{
|
|
padding: "0.75rem 1rem",
|
|
borderTop: "0.0625rem solid var(--border-subtle)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|