AI 附件托盤
可掛在任何輸入框上方的附件托盤:拖入/貼上檔案成縮圖 chip、上傳進度環、失敗重試、類型圖示、超限提示與拖曳重排
獨立於對話輸入框的附件列,可以掛在任何 textarea 上方。把檔案拖進托盤、貼上截圖,或按「+」選取,都會變成一個 chip:圖片顯示縮圖,其他檔案依 MIME/副檔名顯示對應的類型圖示(PDF、文件、試算表、程式碼、音訊、影片、壓縮檔)+中間截斷的檔名+大小。上傳中在縮圖上疊一圈 SVG 進度環與百分比,失敗時紅框加重試鈕,游標移上去才出現移除 ×。chip 用 spring 彈入、收縮退場,拖曳可以重排;超過數量或大小上限時只顯示提示 chip,不會把檔案交給 onAdd。
載入預覽⋯
npx shadcn@latest add https://webberui.com/r/ai-attachment-tray.jsonPlayground
即時調整 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 | 型別 | 預設值 | 說明 |
|---|---|---|---|
files | AiAttachmentFile[] | — | 受控的附件清單(順序即顯示順序);不提供時為非受控模式 |
defaultFiles | AiAttachmentFile[] | [] | 非受控模式的初始附件清單 |
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 | — | 檔案未通過數量/大小/類型檢查時回呼 |
maxFiles | number | — | 附件數量上限;超過時顯示提示 chip 且不把多出的檔案交給 onAdd |
maxSizeMB | number | — | 單檔大小上限(MB) |
accept | string | — | 接受的檔案類型,同原生 accept(如 "image/*,.pdf");拖放與貼上也套用 |
compact | boolean | false | 精簡模式:更小的縮圖與單行文字 |
showSize | boolean | true | 是否在 chip 上顯示檔案大小 |
showAddButton | boolean | true | 是否顯示「+」新增按鈕(開啟原生檔案選取) |
listenPaste | boolean | true | 是否監聽 document 的 paste 事件;貼上帶檔案的內容時視同拖入 |
hint | string | — | 托盤空白時的提示文字;等同 labels.hint 的捷徑 |
labels | Partial<AiAttachmentTrayLabels> | — | 自訂文字(部分覆寫即可) |
disabled | boolean | false | 停用所有互動(拖放、貼上、移除、重排) |
className | string | — | 透傳到最外層容器 |
AiAttachmentFile
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
id | string | — | 唯一識別碼;移除、重試、重排都以此為準 |
name | string | — | 檔名(含副檔名) |
size | number | — | 檔案大小(bytes) |
type | string | — | MIME 類型(如 image/png);空字串時改由副檔名推斷 |
status | "uploading" | "done" | "error" | — | 上傳狀態 |
progress | number | — | 上傳進度 0–100;status 為 uploading 且未提供時顯示不定進度環 |
previewUrl | string | — | 圖片縮圖來源(data URL 或 blob URL);只對圖片類型生效 |
AiAttachmentTrayLabels
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
hint | string | "拖曳、貼上或點「+」加入附件" | 托盤沒有附件時的提示文字 |
dropHint | string | "放開以加入附件" | 拖曳檔案懸停在托盤上時的提示文字 |
add | string | "加入附件" | 「+」新增按鈕的無障礙名稱 |
remove | string | "移除" | 移除鈕無障礙名稱的前綴,後面接檔名 |
retry | string | "重試" | 重試按鈕文字 |
uploading | string | "上傳中" | 上傳中的進度環無障礙名稱 |
failed | string | "上傳失敗" | 上傳失敗時 chip 上的說明文字 |
tooMany | string | "最多 {max} 個附件" | 超過附件數量上限的提示;{max} 會被替換成上限 |
tooLarge | string | "單檔上限 {size}" | 超過單檔大小上限的提示;{size} 會被替換成上限 |
unsupported | string | "不支援的檔案類型" | 檔案類型不在 accept 範圍內的提示 |
list | string | "附件" | 附件列表(ul)的無障礙名稱 |
reorderHint | string | "左右方向鍵切換附件;Shift 加方向鍵調整順序;Delete 移除" | 每個 chip 的鍵盤操作說明(僅供螢幕閱讀器) |
moved | string | "{name} 已移到第 {index} 位,共 {total} 個" | 鍵盤重排後的播報;{name}、{index}、{total} 會被替換 |
removed | string | "已移除 {name}" | 移除後的播報;{name} 會被替換 |
另外具名匯出 getAttachmentKind(type, name)(由 MIME 與副檔名判斷類型)、truncateMiddle(name, max)(檔名中段截斷)、formatFileSize(bytes)(B/KB/MB/GB 格式化),以及 AiAttachmentStatus、AiAttachmentKind、AiAttachmentRejectReason 型別,方便在父層產生列表或訊息時重複使用同一套邏輯。
細節
- 類型判斷:先看 MIME(
image/*、application/pdf、audio/*、video/*、試算表、壓縮檔、文件、程式碼),再退回副檔名對照表——瀏覽器對.md/.ts之類常給空 MIME,副檔名後援才不會全部落到「其他」 - 進度環:SVG
stroke-dashoffset以 spring 平滑推進,progress未提供時改為旋轉的短弧表示處理中;環中央顯示整數百分比(精簡模式省略) - 拖曳重排:以 motion
Reorder.Group/Reorder.Item(axis="x")實作,chip 內的重試與移除鈕會攔下 pointerdown,不會誤觸拖曳;只有一個附件或受控模式未提供onReorder時停用拖曳 - 拖放與貼上:整個托盤是 drop zone,拖曳懸停時虛線邊框加深並顯示
labels.dropHint;拖曳進出子元素會連發 dragenter/dragleave,內部以深度計數避免閃爍。paste 監聽掛在document上(只在 effect 中綁定,listenPaste可關),剪貼簿帶檔案(例如截圖)時才動作 - 超限處理:
accept/maxSizeMB/maxFiles依序檢查,未通過的檔案不會交給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-valuenow/aria-valuetext;移除鈕與重試鈕都有含檔名的aria-label - 移除鈕在精準指標裝置上 hover/聚焦才顯示,觸控裝置永遠可見;所有按鈕皆為
type="button"並有focus-visible外框 - 使用者系統開啟「減少動態效果」時,chip 進出場改為純淡入淡出、拖曳時不放大、進度環直接跳至目前值、不定進度環停止旋轉