119 lines
5.7 KiB
TypeScript
119 lines
5.7 KiB
TypeScript
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { Maximize2, X } from "lucide-react";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
|
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
|
import { cn } from "@/shared/ui/cn";
|
|
|
|
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
|
export function InvestigationExpandCard({
|
|
title, label = title, preview, children, wide = false, onOpen, contentRef,
|
|
triggerLabel = `展开${title}`, className, revision, disabled = false, reuseContent = false, headingAccessory
|
|
}: {
|
|
title: string;
|
|
label?: string;
|
|
preview: ReactNode;
|
|
children: ReactNode | ((close: (restoreFocus?: boolean) => void) => ReactNode);
|
|
wide?: boolean;
|
|
onOpen?: () => void;
|
|
contentRef?: Ref<HTMLElement>;
|
|
triggerLabel?: string;
|
|
className?: string;
|
|
/** Freeze versioned prose while reading; live controls must not use this. */
|
|
revision?: string;
|
|
disabled?: boolean;
|
|
/** Move one live content host between slots, preserving expensive renderers. */
|
|
reuseContent?: boolean;
|
|
headingAccessory?: ReactNode;
|
|
}) {
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const restoreFocusRef = useRef(true);
|
|
const [open, setOpen] = useState(false);
|
|
const [keyboard, setKeyboard] = useState(false);
|
|
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
|
|
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
|
|
const previewSlot = useRef<HTMLDivElement | null>(null);
|
|
const snapshot = useRef<HTMLElement | null>(null);
|
|
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
|
|
previewSlot.current = slot;
|
|
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
|
|
}, [contentHost]);
|
|
const attachDetail = useCallback((slot: HTMLDivElement | null) => {
|
|
const preview = previewSlot.current;
|
|
if (!preview || !contentHost) return;
|
|
if (slot) {
|
|
snapshot.current?.remove();
|
|
snapshot.current = createReadingPreview(contentHost);
|
|
preview.append(snapshot.current);
|
|
slot.appendChild(contentHost);
|
|
} else {
|
|
snapshot.current?.remove();
|
|
snapshot.current = null;
|
|
preview.appendChild(contentHost);
|
|
}
|
|
}, [contentHost]);
|
|
const reduced = useReducedMotion();
|
|
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
|
|
const content = reading?.content ?? children;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) {
|
|
restoreFocusRef.current = true;
|
|
setReading(revision === undefined ? null : { revision, content: children });
|
|
onOpen?.();
|
|
}
|
|
}}>
|
|
<section
|
|
ref={contentRef}
|
|
aria-label={label}
|
|
className={cn("investigation-report-section investigation-expand-card", className)}
|
|
>
|
|
<div className="investigation-card-heading">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
|
{headingAccessory}
|
|
</div>
|
|
<DialogTrigger asChild>
|
|
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
|
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
|
<Maximize2 size={15} aria-hidden />
|
|
</Button>
|
|
</DialogTrigger>
|
|
</div>
|
|
{/* Contain child margins so the occupied and vacated slots measure alike. */}
|
|
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
|
|
</section>
|
|
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
|
|
<DialogContent
|
|
className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
|
|
overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
|
|
data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
|
|
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
|
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
|
<header className="investigation-detail-heading border-b border-border">
|
|
<DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
|
<DialogClose asChild>
|
|
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
|
</DialogClose>
|
|
</header>
|
|
<div className="investigation-detail-scroll">
|
|
<motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
|
|
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
|
</motion.div>
|
|
</div>
|
|
{reading && revision !== undefined && reading.revision !== revision ? (
|
|
<div className="investigation-report-update" role="status">
|
|
<span>已有更新,当前保留正在阅读的版本。</span>
|
|
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
|
</div>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|