智能体抽屉读 ?open= 上下文、回复链接用抽屉地址;抽屉打开不亮焦点框;语句统计重复 key。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
190 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|