unnamed-ui
列表

Attachment List

Composed attachment list with cards for chat/file scenarios

image.png
photo.jpg
需求文档.pdfPDF·1.2MB
会议纪要.docxDOCX·92KB
产品设计稿.figFIG·3.5MB
用户调研报告.xlsxXLSX·856KB
screenshot.png
技术方案.mdMD·45KB
"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。

安装

pnpm dlx shadcn@latest add http://localhost:3000/r/wuhan/attachment-list.json

概述

  • 定位:附件卡片列表(图片/文件统一展示)
  • 默认样式:默认卡片 + 删除按钮 + 横向滚动
  • 扩展能力:支持业务数据适配与插槽渲染

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 圆环)。

image.png
photo.jpg
需求文档.pdfPDF·1.2MB
会议纪要.docxDOCX·92KB
产品设计稿.figFIG·3.5MB
用户调研报告.xlsxXLSX·856KB
screenshot.png
技术方案.mdMD·45KB
"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>
  );
}

附件预览

点击卡片即可预览图片或文件。

点击卡片可预览图片或文件(内置弹层)
preview.png
spec.pdfPDF·1.2MB
screenshot.jpg
"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 与删除按钮样式
design.png
spec.pdfPDF·1.2MB
roadmap.xlsxXLSX·856KB
"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)

数据与适配

属性类型默认值说明
itemsAttachmentItem[]-直接传 UI 结构数据。
attachmentsT[]-业务数据数组,配合 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-自定义默认图标。

预览

属性类型默认值说明
previewEnabledbooleanfalse启用内置预览弹层。
previewOnClickbooleantrue点击卡片时打开预览。
previewItemAttachmentItem | null-受控预览项。
onPreviewChange(item | null) => void-预览项变更回调。
renderPreview(item) => ReactNode-自定义预览内容。
renderPreviewTitle(item) => ReactNode-自定义预览标题。
getItemPreviewable(item) => boolean-自定义“是否可预览”。

Behavior Notes

  • 同时传 itemsattachments 时优先使用 items
  • 使用 renderItem 时,其它局部插槽不会生效。
  • 内置预览默认支持:图片(previewUrl/thumbnail)和文件 iframe(url)。