Files
next-tjwater-frontend/src/features/workbench/components/investigation-expand-card.tsx
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

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>
);
}