Files
UHD_Media_Tool/apps/web/app/components/console/detail-sheet.tsx
T
rootandClaude Opus 5.5 1ade49fe8e chore(release): v0.3.41
智能体抽屉读 ?open= 上下文、回复链接用抽屉地址;抽屉打开不亮焦点框;语句统计重复 key。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:39:24 +08:00

190 lines
6.3 KiB
TypeScript

import { ArrowLeft } from "lucide-react";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils.ts";
/** 桌面宽度:md ≈ 32rem,lg ≈ 44rem,xl ≈ 64rem(内容重的记录:订阅对比、站点);手机一律全宽 */
export type DetailSheetWidth = "md" | "lg" | "xl";
const WIDTH: Record<DetailSheetWidth, string> = {
md: "sm:max-w-lg",
lg: "sm:max-w-2xl",
xl: "sm:max-w-5xl",
};
/**
* 抽屉里再开的 DetailSheet 不叠第二层抽屉(console-ia.md §9):在同一个抽屉里换成子视图,
* 顶部是「返回」,关掉子视图回到原来的内容(滚动位置不丢)。
*/
type Nest = {
host: HTMLElement | null;
push: (close: () => void) => () => void;
};
const NestContext = createContext<Nest | null>(null);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: ReactNode;
description?: ReactNode;
/** 标题下面一行的操作按钮(记录抽屉的主操作、更多) */
actions?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: DetailSheetWidth;
className?: string;
};
/**
* 右侧详情抽屉(手机全宽)。内容区自带纵向滚动;footer 固定在底部放操作按钮。
* <DetailSheet open={!!id} onOpenChange={(o) => !o && setId(null)} title="详情" description="…">
* <KvList items={…} />
* </DetailSheet>
* 从列表打开记录时配合 useRecordSheet(record-sheet.tsx)把打开的记录写进地址栏。
*/
export function DetailSheet(props: Props) {
const nest = useContext(NestContext);
if (nest) return <NestedView {...props} nest={nest} />;
return <RootSheet {...props} />;
}
function RootSheet({
open,
onOpenChange,
title,
description,
actions,
children,
footer,
width = "md",
className,
}: Props) {
const [host, setHost] = useState<HTMLDivElement | null>(null);
const stack = useRef<(() => void)[]>([]);
const [depth, setDepth] = useState(0);
const push = useCallback((close: () => void) => {
stack.current.push(close);
setDepth(stack.current.length);
return () => {
stack.current = stack.current.filter((c) => c !== close);
setDepth(stack.current.length);
};
}, []);
const nest = useMemo<Nest>(() => ({ host, push }), [host, push]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className={cn("w-full gap-0 p-0", WIDTH[width], className)}
// 打开时焦点落在抽屉本身而不是第一个按钮 / 标签,避免一打开就亮焦点框;Tab 仍从抽屉内开始
onOpenAutoFocus={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement | null)?.focus({ preventScroll: true });
}}
onEscapeKeyDown={(e) => {
// 有子视图时 Esc 先回到上一层
const top = stack.current.at(-1);
if (top) {
e.preventDefault();
top();
}
}}
>
<NestContext.Provider value={nest}>
<div className={cn("flex min-h-0 flex-1 flex-col", depth > 0 && "invisible")} aria-hidden={depth > 0}>
<SheetFrame title={title} description={description} actions={actions} footer={footer}>
{children}
</SheetFrame>
</div>
<div ref={setHost} className={cn("absolute inset-0 flex flex-col", depth === 0 && "hidden")} />
</NestContext.Provider>
</SheetContent>
</Sheet>
);
}
function SheetFrame({
title,
description,
actions,
footer,
children,
back,
}: Pick<Props, "title" | "description" | "actions" | "footer" | "children"> & { back?: () => void }) {
return (
<>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
{back ? (
<Button variant="ghost" size="sm" className="-ml-2 mb-1 self-start" onClick={back}>
<ArrowLeft />
返回
</Button>
) : null}
{back ? (
// 子视图在同一个对话框里,标题不再占用对话框的 Title / Description
<>
<h2 className="line-clamp-2 text-lead font-semibold break-words text-foreground">{title}</h2>
{description ? <div className="text-sm break-words text-muted-foreground">{description}</div> : null}
</>
) : (
<>
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? (
<SheetDescription asChild>
<div className="break-words">{description}</div>
</SheetDescription>
) : null}
</>
)}
{actions ? <div className="mt-2 flex flex-wrap items-center gap-2">{actions}</div> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{footer}
</div>
) : null}
</>
);
}
/** 抽屉里的子视图:渲染进外层抽屉的同一块区域,盖住原内容 */
function NestedView({ open, onOpenChange, nest, ...frame }: Props & { nest: Nest }) {
const close = useRef(() => onOpenChange(false));
close.current = () => onOpenChange(false);
const { push, host } = nest;
useEffect(() => {
if (!open) return;
return push(() => close.current());
}, [open, push]);
if (!open || !host) return null;
return createPortal(
<div className="flex min-h-0 flex-1 flex-col">
<SheetFrame {...frame} back={() => close.current()} />
</div>,
host,
);
}
/** 抽屉/卡片内的小节:大写小标签 + 内容 */
export function DetailSection({
title,
children,
actions,
}: {
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
{actions}
</div>
{children}
</section>
);
}