AI 附件托盤
可掛在任何輸入框上方的附件托盤:拖入/貼上檔案成縮圖 chip、上傳進度環、失敗重試、類型圖示、超限提示與拖曳重排
獨立於對話輸入框的附件列,可以掛在任何 textarea 上方。把檔案拖進托盤、貼上截圖,或按「+」選取,都會變成一個 chip:圖片顯示縮圖,其他檔案依 MIME/副檔名顯示對應的類型圖示(PDF、文件、試算表、程式碼、音訊、影片、壓縮檔)+中間截斷的檔名+大小。上傳中在縮圖上疊一圈 SVG 進度環與百分比,失敗時紅框加重試鈕,游標移上去才出現移除 ×。chip 用 spring 彈入、收縮退場,拖曳可以重排;超過數量或大小上限時只顯示提示 chip,不會把檔案交給 onAdd。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add ai-attachment-trayAI Attachment Tray 是用 React、Tailwind CSS 與 Motion 做的檔案附件托盤(file attachment chips),常見於 AI 聊天輸入框上方:支援拖放、貼上截圖與檔案選取,用 SVG 進度環顯示上傳進度,並以 Motion 的 Reorder 拖曳重排。
Playground
即時調整 props、程式碼片段同步更新——直接試出你要的樣子再複製。
<AiAttachmentTray/>
完整範例包含預覽的排版與資料。先安裝元件,再把範例檔貼入你的專案。
適用情境
- AI 聊天或客服對話框,讓使用者在提問前附上圖片、PDF 或試算表。
- 工單、意見回饋表單裡需要附上螢幕截圖,直接貼上就能加入。
- Email 或訊息撰寫區,顯示附件清單並允許調整順序與移除。
- 需要逐檔回報上傳進度並支援失敗重試的上傳流程。
- 不適合:以大面積拖放區為主的上傳頁,改用 File Dropzone。
安裝
npx webberui@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)}
/>範例
用 XMLHttpRequest 回報上傳進度
fetch 無法取得上傳進度,上面「使用」範例裡的 uploadToServer 可以用 XMLHttpRequest 實作;onProgress 回傳 0–1,寫回 progress 時乘上 100。
export function uploadToServer(
file: File,
{ onProgress }: { onProgress: (ratio: number) => void },
) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
const body = new FormData();
body.append("file", file);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
};
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(String(xhr.status))));
xhr.onerror = () => reject(new Error("network"));
xhr.open("POST", "/api/attachments");
xhr.send(body);
});
}顯示拒絕原因
檔案未通過檢查時,托盤本身會顯示提示 chip;若還想在別處記錄或顯示訊息,可以依 onReject 的 reason("count"、"size"、"type")分別處理。
import {
AiAttachmentTray,
type AiAttachmentRejectReason,
} from "@/components/ui/ai-attachment-tray";
const messages: Record<AiAttachmentRejectReason, string> = {
count: "附件數量已達上限",
size: "檔案超過 10 MB",
type: "只接受圖片與 PDF",
};
<AiAttachmentTray
maxFiles={4}
maxSizeMB={10}
accept="image/*,.pdf"
onReject={(file, reason) => console.warn(file.name, messages[reason])}
/>精簡模式與自訂文字
compact 縮小縮圖、改為單行文字,適合手機或側邊欄;labels 只需覆寫要改的欄位。同一頁有多個托盤時,用 listenPaste={false} 關掉其中幾個的貼上監聽,避免一次貼上被多個托盤接收。
<AiAttachmentTray
compact
showSize={false}
listenPaste={false}
hint="拖曳檔案到這裡"
labels={{ tooMany: "最多只能附 {max} 個檔案", failed: "傳送失敗" }}
maxFiles={3}
/>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 進出場改為純淡入淡出、拖曳時不放大、進度環直接跳至目前值、不定進度環停止旋轉
常見問題
可以在 Next.js App Router 使用嗎?
可以。原始碼開頭已有 "use client",在 Server Component 頁面直接 import 即可;document 的 paste 監聽只在 effect 裡綁定,伺服器端渲染時不會存取瀏覽器 API。
元件會幫我上傳檔案嗎?
不會。元件只負責收集、檢查與顯示檔案,通過檢查的 File 會交給 onAdd,上傳由你自己處理;受控模式下把 status 與 progress 寫回 files,托盤就會更新進度環。
縮圖的 blob URL 需要自己釋放嗎?
非受控模式由元件自動建立,並在移除或卸載時 revoke。受控模式的 previewUrl 由你建立,移除項目時請自行呼叫 URL.revokeObjectURL。
鍵盤可以重排或移除附件嗎?
可以。聚焦 chip 後用左右方向鍵切換、Home/End 跳到首尾、Shift+方向鍵調整順序、Delete 或 Backspace 移除,結果會透過 aria-live 播報。受控模式沒有傳 onReorder 時會停用重排。
有哪些工具函式可以重複使用?
getAttachmentKind、truncateMiddle 與 formatFileSize 都有具名匯出,可以在訊息列表或已送出的附件裡沿用同一套檔案類型判斷與檔名、大小格式。