WebberUI

AI 附件托盤

可掛在任何輸入框上方的附件托盤:拖入/貼上檔案成縮圖 chip、上傳進度環、失敗重試、類型圖示、超限提示與拖曳重排

獨立於對話輸入框的附件列,可以掛在任何 textarea 上方。把檔案拖進托盤、貼上截圖,或按「+」選取,都會變成一個 chip:圖片顯示縮圖,其他檔案依 MIME/副檔名顯示對應的類型圖示(PDF、文件、試算表、程式碼、音訊、影片、壓縮檔)+中間截斷的檔名+大小。上傳中在縮圖上疊一圈 SVG 進度環與百分比,失敗時紅框加重試鈕,游標移上去才出現移除 ×。chip 用 spring 彈入、收縮退場,拖曳可以重排;超過數量或大小上限時只顯示提示 chip,不會把檔案交給 onAdd

載入預覽⋯
npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.json

Playground

即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。

6
10
<AiAttachmentTray />

安裝

npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.json

或在 components.json 設定 registries 後,改用 @webberui/ai-attachment-tray 安裝。

使用

受控模式:由父層持有清單、負責上傳,並把進度寫回 files

import * as React from "react";
import {
  AiAttachmentTray,
  type AiAttachmentFile,
} from "@/components/ui/ai-attachment-tray";

function Composer() {
  const [files, setFiles] = React.useState<AiAttachmentFile[]>([]);

  const upload = (id: string, file: File) => {
    // 呼叫你的上傳 API,並在進度事件裡更新 progress;完成改 status 為 "done",失敗改 "error"
    uploadToServer(file, {
      onProgress: (p) =>
        setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, progress: p * 100 } : f))),
    })
      .then(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "done" } : f))))
      .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
  };

  return (
    <div className="rounded-2xl border p-2">
      <AiAttachmentTray
        files={files}
        maxFiles={6}
        maxSizeMB={10}
        accept="image/*,.pdf,.docx,.xlsx"
        onAdd={(incoming) => {
          const entries = incoming.map<AiAttachmentFile>((file) => ({
            id: crypto.randomUUID(),
            name: file.name,
            size: file.size,
            type: file.type,
            status: "uploading",
            progress: 0,
            previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
          }));
          setFiles((prev) => [...prev, ...entries]);
          entries.forEach((entry, i) => upload(entry.id, incoming[i]));
        }}
        onRemove={(id) => setFiles((prev) => prev.filter((f) => f.id !== id))}
        onRetry={(id) =>
          setFiles((prev) =>
            prev.map((f) => (f.id === id ? { ...f, status: "uploading", progress: 0 } : f)),
          )
        }
        onReorder={(ids) =>
          setFiles((prev) =>
            ids.flatMap((id) => prev.filter((f) => f.id === id)),
          )
        }
      />
      <textarea rows={2} placeholder="問點什麼……" className="w-full bg-transparent p-2" />
    </div>
  );
}

不傳 files 時為非受控模式:元件自己維護清單(新增的檔案直接標為 done,圖片自動產生縮圖),適合只需要收集檔案、送出時再一次上傳的場景。

<AiAttachmentTray
  defaultFiles={[]}
  onChange={(files) => console.log(files.map((f) => f.name))}
  onAdd={(incoming) => console.log("通過檢查的檔案", incoming)}
/>

Props

Prop型別預設值說明
filesAiAttachmentFile[]受控的附件清單(順序即顯示順序);不提供時為非受控模式
defaultFilesAiAttachmentFile[][]非受控模式的初始附件清單
onChange(files: AiAttachmentFile[]) => void非受控模式下清單變動(新增/移除/重排)後回呼完整清單
onAdd(files: File[]) => void使用者拖入、貼上或選取的檔案通過檢查時回呼;受控模式由父層據此建立項目並上傳
onRemove(id: string) => void按下移除鈕(或在 chip 上按 Delete)時回呼
onRetry(id: string) => void上傳失敗的 chip 按下重試時回呼
onReorder(ids: string[]) => void拖曳或鍵盤重排後回呼新的 id 順序;受控模式未提供時停用重排
onReject(file: File, reason: AiAttachmentRejectReason) => void檔案未通過數量/大小/類型檢查時回呼
maxFilesnumber附件數量上限;超過時顯示提示 chip 且不把多出的檔案交給 onAdd
maxSizeMBnumber單檔大小上限(MB)
acceptstring接受的檔案類型,同原生 accept(如 "image/*,.pdf");拖放與貼上也套用
compactbooleanfalse精簡模式:更小的縮圖與單行文字
showSizebooleantrue是否在 chip 上顯示檔案大小
showAddButtonbooleantrue是否顯示「+」新增按鈕(開啟原生檔案選取)
listenPastebooleantrue是否監聽 document 的 paste 事件;貼上帶檔案的內容時視同拖入
hintstring托盤空白時的提示文字;等同 labels.hint 的捷徑
labelsPartial<AiAttachmentTrayLabels>自訂文字(部分覆寫即可)
disabledbooleanfalse停用所有互動(拖放、貼上、移除、重排)
classNamestring透傳到最外層容器

AiAttachmentFile

欄位型別預設值說明
idstring唯一識別碼;移除、重試、重排都以此為準
namestring檔名(含副檔名)
sizenumber檔案大小(bytes)
typestringMIME 類型(如 image/png);空字串時改由副檔名推斷
status"uploading" | "done" | "error"上傳狀態
progressnumber上傳進度 0–100;statusuploading 且未提供時顯示不定進度環
previewUrlstring圖片縮圖來源(data URL 或 blob URL);只對圖片類型生效

AiAttachmentTrayLabels

欄位型別預設值說明
hintstring"拖曳、貼上或點「+」加入附件"托盤沒有附件時的提示文字
dropHintstring"放開以加入附件"拖曳檔案懸停在托盤上時的提示文字
addstring"加入附件"「+」新增按鈕的無障礙名稱
removestring"移除"移除鈕無障礙名稱的前綴,後面接檔名
retrystring"重試"重試按鈕文字
uploadingstring"上傳中"上傳中的進度環無障礙名稱
failedstring"上傳失敗"上傳失敗時 chip 上的說明文字
tooManystring"最多 {max} 個附件"超過附件數量上限的提示;{max} 會被替換成上限
tooLargestring"單檔上限 {size}"超過單檔大小上限的提示;{size} 會被替換成上限
unsupportedstring"不支援的檔案類型"檔案類型不在 accept 範圍內的提示
liststring"附件"附件列表(ul)的無障礙名稱
reorderHintstring"左右方向鍵切換附件;Shift 加方向鍵調整順序;Delete 移除"每個 chip 的鍵盤操作說明(僅供螢幕閱讀器)
movedstring"{name} 已移到第 {index} 位,共 {total} 個"鍵盤重排後的播報;{name}{index}{total} 會被替換
removedstring"已移除 {name}"移除後的播報;{name} 會被替換

另外具名匯出 getAttachmentKind(type, name)(由 MIME 與副檔名判斷類型)、truncateMiddle(name, max)(檔名中段截斷)、formatFileSize(bytes)(B/KB/MB/GB 格式化),以及 AiAttachmentStatusAiAttachmentKindAiAttachmentRejectReason 型別,方便在父層產生列表或訊息時重複使用同一套邏輯。

細節

  • 類型判斷:先看 MIME(image/*application/pdfaudio/*video/*、試算表、壓縮檔、文件、程式碼),再退回副檔名對照表——瀏覽器對 .md.ts 之類常給空 MIME,副檔名後援才不會全部落到「其他」
  • 進度環:SVG stroke-dashoffset 以 spring 平滑推進,progress 未提供時改為旋轉的短弧表示處理中;環中央顯示整數百分比(精簡模式省略)
  • 拖曳重排:以 motion Reorder.GroupReorder.Itemaxis="x")實作,chip 內的重試與移除鈕會攔下 pointerdown,不會誤觸拖曳;只有一個附件或受控模式未提供 onReorder 時停用拖曳
  • 拖放與貼上:整個托盤是 drop zone,拖曳懸停時虛線邊框加深並顯示 labels.dropHint;拖曳進出子元素會連發 dragenter/dragleave,內部以深度計數避免閃爍。paste 監聽掛在 document 上(只在 effect 中綁定,listenPaste 可關),剪貼簿帶檔案(例如截圖)時才動作
  • 超限處理acceptmaxSizeMBmaxFiles 依序檢查,未通過的檔案不會交給 onAdd,改觸發 onReject 並顯示琥珀色提示 chip(約 3 秒後自動消失);同一批只顯示第一個原因
  • 非受控模式的縮圖:圖片自動 URL.createObjectURL 產生縮圖,移除或卸載時 revoke,不會漏記憶體
  • 移除後的焦點:焦點在被移除的 chip 上時,自動移到相鄰 chip,不會掉回 body

可及性

  • 附件列表為 role="list"aria-label 取自 labels.list),每個 chip 為可聚焦的 role="listitem"aria-label 含檔名、大小與狀態,並以 aria-describedby 連到鍵盤操作說明
  • 鍵盤:左右方向鍵在 chip 之間移動、Home/End 跳到首尾、Shift+方向鍵調整順序、Delete/Backspace 移除;重排與移除都會透過 aria-live="polite" 播報
  • 進度環帶 role="progressbar"aria-valuenowaria-valuetext;移除鈕與重試鈕都有含檔名的 aria-label
  • 移除鈕在精準指標裝置上 hover/聚焦才顯示,觸控裝置永遠可見;所有按鈕皆為 type="button" 並有 focus-visible 外框
  • 使用者系統開啟「減少動態效果」時,chip 進出場改為純淡入淡出、拖曳時不放大、進度環直接跳至目前值、不定進度環停止旋轉

本頁目錄