列表
Attachment List
Composed attachment list with cards for chat/file scenarios
"use client";
import { useMemo, useState } from "react";
import {
AttachmentListComposed,
type AttachmentItem,
} from "@/components/composed/attachment-list/attachment-list";
import { FileText } from "lucide-react";
type DemoAttachment = {
key: string;
filename?: string;
ext?: string;
sizeLabel?: string;
kind?: "image" | "file";
loading?: boolean;
previewUrl?: string;
url?: string;
};
export function AttachmentListDemo() {
const initial = useMemo<DemoAttachment[]>(
() => [
{
key: "img-1",
kind: "image",
previewUrl: "https://placehold.co/400x300",
filename: "image.png",
},
{
key: "img-2",
kind: "image",
previewUrl: "https://placehold.co/420x320",
filename: "photo.jpg",
},
{
key: "img-uploading",
kind: "image",
previewUrl: "https://placehold.co/360x260",
filename: "uploading.jpg",
loading: true,
},
{
key: "doc-1",
kind: "file",
filename: "需求文档.pdf",
ext: "PDF",
sizeLabel: "1.2MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "doc-2",
kind: "file",
filename: "会议纪要.docx",
ext: "DOCX",
sizeLabel: "92KB",
loading: true,
},
{
key: "doc-3",
kind: "file",
filename: "产品设计稿.fig",
ext: "FIG",
sizeLabel: "3.5MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "doc-4",
kind: "file",
filename: "用户调研报告.xlsx",
ext: "XLSX",
sizeLabel: "856KB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "img-3",
kind: "image",
previewUrl: "https://placehold.co/380x280",
filename: "screenshot.png",
},
{
key: "doc-5",
kind: "file",
filename: "技术方案.md",
ext: "MD",
sizeLabel: "45KB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
],
[],
);
const [items, setItems] = useState<DemoAttachment[]>(initial);
const attachmentItems = useMemo<AttachmentItem[]>(
() =>
items.map((item) => ({
id: item.key,
name: item.filename,
fileType: item.ext,
fileSize: item.sizeLabel,
isImage: item.kind === "image",
loading: item.loading,
thumbnail: item.previewUrl,
previewUrl: item.previewUrl,
url: item.url,
icon:
item.kind === "image" ? undefined : <FileText className="size-4" />,
})),
[items],
);
return (
<div className="w-full max-w-2xl space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<button
type="button"
className="rounded-md border px-2 py-1 hover:bg-muted"
onClick={() => setItems([])}
>
清空列表
</button>
<button
type="button"
className="rounded-md border px-2 py-1 hover:bg-muted"
onClick={() => setItems(initial)}
>
重置数据
</button>
</div>
<AttachmentListComposed
className="w-full"
items={attachmentItems}
previewEnabled
onRemove={(id) =>
setItems((prev) => prev.filter((item) => item.key !== id))
}
renderEmpty={() => (
<div className="text-xs text-muted-foreground">
暂无附件,点击上方“重置数据”恢复示例。
</div>
)}
/>
</div>
);
}
Attachment List 是组合组件,提供附件卡片与横向滚动列表的默认布局,适用于聊天输入区、消息附件展示等场景。需要深度定制时可使用 primitives。
安装
概述
- 定位:附件卡片列表(图片/文件统一展示)
- 默认样式:默认卡片 + 删除按钮 + 横向滚动
- 扩展能力:支持业务数据适配与插槽渲染
Usage
基础用法(直接 items)
import { AttachmentListComposed } from "@/registry/wuhan/composed/attachment-list/attachment-list";
const items = [
{ id: "1", name: "design.png", isImage: true, thumbnail: "..." },
{ id: "2", name: "spec.pdf", fileType: "PDF", fileSize: "1.2MB" },
];
<AttachmentListComposed items={items} onRemove={(id) => console.log(id)} />;AttachmentItem 结构
AttachmentItem 是组件消费的标准化数据结构,如果你不想自己拼它,可以改用 attachments + attachmentAdapter。
type AttachmentItem = {
id: string;
name?: string;
fileType?: string;
fileSize?: string;
thumbnail?: string;
previewUrl?: string;
url?: string;
loading?: boolean;
isImage?: boolean;
icon?: React.ReactNode;
onClick?: () => void;
};数据适配(推荐)
const attachments = [
{ key: "1", filename: "design.png", sizeLabel: "1.8MB", kind: "image" },
];
<AttachmentListComposed
attachments={attachments}
// 适配函数:把你的业务数据转换成组件认识的 AttachmentItem
attachmentAdapter={(item) => ({
id: item.key,
name: item.filename,
fileSize: item.sizeLabel,
isImage: item.kind === "image",
})}
/>;为什么需要 attachments + attachmentAdapter
- 你的业务数据结构通常不是
AttachmentItem(字段名、结构不同) - 通过
attachmentAdapter,你不需要改数据结构,只需要做一次映射 - 这样组件保持“通用”,业务保持“独立”
最小可运行示例(带注释)
const attachments = [
{ key: "1", filename: "design.png", sizeLabel: "1.8MB", kind: "image" },
];
<AttachmentListComposed
// 原始业务数据
attachments={attachments}
// 把业务数据转成 AttachmentItem
attachmentAdapter={(item) => ({
id: item.key, // 必填:唯一 id
name: item.filename, // 可选:展示的文件名
fileSize: item.sizeLabel, // 可选:展示的文件大小
isImage: item.kind === "image", // 可选:是否是图片
})}
/>;插槽渲染(高级)
<AttachmentListComposed
items={items}
renderMeta={({ meta }) => <span className="text-xs text-muted-foreground">{meta}</span>}
renderDelete={({ onRemove }) => (
<button onClick={onRemove} className="text-xs">删除</button>
)}
/>;预览能力(图片/文件)
<AttachmentListComposed
items={[
{
id: "img-1",
name: "preview.png",
thumbnail: "https://placehold.co/56x56",
previewUrl: "https://placehold.co/600x400",
isImage: true,
},
{
id: "file-1",
name: "spec.pdf",
fileType: "PDF",
fileSize: "1.2MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
]}
previewEnabled
/>;代码演示
基本
包含图片附件、文件附件,以及上传中的 loading 状态(图片区域居中显示 20px 圆环)。
"use client";
import { useMemo, useState } from "react";
import {
AttachmentListComposed,
type AttachmentItem,
} from "@/components/composed/attachment-list/attachment-list";
import { FileText } from "lucide-react";
type DemoAttachment = {
key: string;
filename?: string;
ext?: string;
sizeLabel?: string;
kind?: "image" | "file";
loading?: boolean;
previewUrl?: string;
url?: string;
};
export function AttachmentListDemo() {
const initial = useMemo<DemoAttachment[]>(
() => [
{
key: "img-1",
kind: "image",
previewUrl: "https://placehold.co/400x300",
filename: "image.png",
},
{
key: "img-2",
kind: "image",
previewUrl: "https://placehold.co/420x320",
filename: "photo.jpg",
},
{
key: "img-uploading",
kind: "image",
previewUrl: "https://placehold.co/360x260",
filename: "uploading.jpg",
loading: true,
},
{
key: "doc-1",
kind: "file",
filename: "需求文档.pdf",
ext: "PDF",
sizeLabel: "1.2MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "doc-2",
kind: "file",
filename: "会议纪要.docx",
ext: "DOCX",
sizeLabel: "92KB",
loading: true,
},
{
key: "doc-3",
kind: "file",
filename: "产品设计稿.fig",
ext: "FIG",
sizeLabel: "3.5MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "doc-4",
kind: "file",
filename: "用户调研报告.xlsx",
ext: "XLSX",
sizeLabel: "856KB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "img-3",
kind: "image",
previewUrl: "https://placehold.co/380x280",
filename: "screenshot.png",
},
{
key: "doc-5",
kind: "file",
filename: "技术方案.md",
ext: "MD",
sizeLabel: "45KB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
],
[],
);
const [items, setItems] = useState<DemoAttachment[]>(initial);
const attachmentItems = useMemo<AttachmentItem[]>(
() =>
items.map((item) => ({
id: item.key,
name: item.filename,
fileType: item.ext,
fileSize: item.sizeLabel,
isImage: item.kind === "image",
loading: item.loading,
thumbnail: item.previewUrl,
previewUrl: item.previewUrl,
url: item.url,
icon:
item.kind === "image" ? undefined : <FileText className="size-4" />,
})),
[items],
);
return (
<div className="w-full max-w-2xl space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<button
type="button"
className="rounded-md border px-2 py-1 hover:bg-muted"
onClick={() => setItems([])}
>
清空列表
</button>
<button
type="button"
className="rounded-md border px-2 py-1 hover:bg-muted"
onClick={() => setItems(initial)}
>
重置数据
</button>
</div>
<AttachmentListComposed
className="w-full"
items={attachmentItems}
previewEnabled
onRemove={(id) =>
setItems((prev) => prev.filter((item) => item.key !== id))
}
renderEmpty={() => (
<div className="text-xs text-muted-foreground">
暂无附件,点击上方“重置数据”恢复示例。
</div>
)}
/>
</div>
);
}
附件预览
点击卡片即可预览图片或文件。
点击卡片可预览图片或文件(内置弹层)
"use client";
import { useMemo, useState } from "react";
import {
AttachmentListComposed,
type AttachmentItem,
} from "@/components/composed/attachment-list/attachment-list";
import { FileText } from "lucide-react";
type DemoAttachment = {
key: string;
filename?: string;
ext?: string;
sizeLabel?: string;
kind?: "image" | "file";
loading?: boolean;
previewUrl?: string;
url?: string;
};
export function AttachmentListPreview() {
const initial = useMemo<DemoAttachment[]>(
() => [
{
key: "img-1",
kind: "image",
previewUrl: "https://placehold.co/520x360",
filename: "preview.png",
},
{
key: "doc-1",
kind: "file",
filename: "spec.pdf",
ext: "PDF",
sizeLabel: "1.2MB",
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
},
{
key: "img-2",
kind: "image",
previewUrl: "https://placehold.co/480x320",
filename: "screenshot.jpg",
},
],
[],
);
const [items, setItems] = useState<DemoAttachment[]>(initial);
const attachmentItems = useMemo<AttachmentItem[]>(
() =>
items.map((item) => ({
id: item.key,
name: item.filename,
fileType: item.ext,
fileSize: item.sizeLabel,
isImage: item.kind === "image",
loading: item.loading,
thumbnail: item.previewUrl,
previewUrl: item.previewUrl,
url: item.url,
icon:
item.kind === "image" ? undefined : <FileText className="size-4" />,
})),
[items],
);
return (
<div className="w-full max-w-2xl space-y-2">
<div className="text-xs text-muted-foreground">
点击卡片可预览图片或文件(内置弹层)
</div>
<AttachmentListComposed
className="w-full"
items={attachmentItems}
previewEnabled
onRemove={(id) =>
setItems((prev) => prev.filter((item) => item.key !== id))
}
/>
</div>
);
}
自定义渲染
自定义 Meta 与删除按钮。
自定义 Meta 与删除按钮样式
"use client";
import { useMemo, useState } from "react";
import { AttachmentListComposed } from "@/components/composed/attachment-list/attachment-list";
import { FileText } from "lucide-react";
export function AttachmentListCustomRender() {
const initialItems = useMemo(
() => [
{
id: "img-1",
name: "design.png",
thumbnail: "https://placehold.co/56x56",
isImage: true,
},
{
id: "doc-1",
name: "spec.pdf",
fileType: "PDF",
fileSize: "1.2MB",
},
{
id: "doc-2",
name: "roadmap.xlsx",
fileType: "XLSX",
fileSize: "856KB",
},
],
[],
);
const [items, setItems] = useState(initialItems);
return (
<div className="w-full max-w-2xl space-y-2">
<div className="text-xs text-muted-foreground">
自定义 Meta 与删除按钮样式
</div>
<AttachmentListComposed
className="w-full"
items={items.map((item) => ({
...item,
icon: item.isImage ? undefined : <FileText className="size-4" />,
}))}
onRemove={(id) =>
setItems((prev) => prev.filter((item) => item.id !== id))
}
renderMeta={({ meta }) => (
<span className="text-[11px] text-slate-500">{meta}</span>
)}
renderDelete={({ onRemove }) => (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onRemove?.();
}}
className="absolute -top-1 -right-1 inline-flex h-5 w-5 items-center justify-center rounded-full border border-slate-200 bg-white text-[11px] text-slate-500 shadow-sm hover:bg-slate-50 cursor-pointer"
aria-label="Delete attachment"
>
×
</button>
)}
/>
</div>
);
}
空状态
空列表占位。
暂无附件,先上传一份文件吧。
"use client";
import { AttachmentListComposed } from "@/components/composed/attachment-list/attachment-list";
export function AttachmentListEmpty() {
return (
<div className="w-full max-w-2xl">
<AttachmentListComposed
items={[]}
renderEmpty={() => (
<div className="text-xs text-muted-foreground">
暂无附件,先上传一份文件吧。
</div>
)}
/>
</div>
);
}
API (Composed)
数据与适配
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
items | AttachmentItem[] | - | 直接传 UI 结构数据。 |
attachments | T[] | - | 业务数据数组,配合 attachmentAdapter。 |
attachmentAdapter | (item) => AttachmentItem | - | 业务数据转 UI 数据。 |
事件
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
onRemove | (id, item?) => void | - | 删除回调。 |
onItemClick | (item) => void | - | 点击卡片回调。 |
onItemSelect | (item) => void | - | 选择卡片回调。 |
渲染与插槽
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
renderItem | (ctx) => ReactNode | - | 覆盖整张卡片渲染。 |
renderLeading | (ctx) => ReactNode | - | 覆盖图标/缩略图区域。 |
renderContent | (ctx) => ReactNode | - | 覆盖标题与 meta 区域。 |
renderMeta | (ctx) => ReactNode | - | 覆盖 meta 文本。 |
renderThumbnail | (ctx) => ReactNode | - | 覆盖图片缩略图渲染。 |
renderDelete | (ctx) => ReactNode | - | 覆盖删除按钮。 |
renderEmpty | () => ReactNode | - | 列表为空时的占位内容。 |
计算逻辑
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
getItemMeta | (item) => ReactNode | - | 自定义 meta 计算。 |
getItemIsImage | (item) => boolean | - | 自定义图片判定。 |
getItemIcon | (item) => ReactNode | - | 自定义默认图标。 |
预览
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
previewEnabled | boolean | false | 启用内置预览弹层。 |
previewOnClick | boolean | true | 点击卡片时打开预览。 |
previewItem | AttachmentItem | null | - | 受控预览项。 |
onPreviewChange | (item | null) => void | - | 预览项变更回调。 |
renderPreview | (item) => ReactNode | - | 自定义预览内容。 |
renderPreviewTitle | (item) => ReactNode | - | 自定义预览标题。 |
getItemPreviewable | (item) => boolean | - | 自定义“是否可预览”。 |
Behavior Notes
- 同时传
items与attachments时优先使用items。 - 使用
renderItem时,其它局部插槽不会生效。 - 内置预览默认支持:图片(
previewUrl/thumbnail)和文件 iframe(url)。