UploadQueue
FormActual file selection with validation, progress, cancellation, retry, and an explicit upload adapter.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { useState } from "react";
import { UploadQueue, type UploadAdapter } from "@/components/ui/upload-queue";
import { Button } from "@dashboardpack/core/components/ui/button";
export default function UploadQueueExample() {
const [failure, setFailure] = useState(false);
const [completed, setCompleted] = useState<string[]>([]);
const upload: UploadAdapter = async (_file, { signal, onProgress }) => {
for (let percent = 0; percent <= 100; percent += 10) {
signal.throwIfAborted();
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 150);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
if (failure && percent >= 30) throw new Error("Demo connection failure");
onProgress(percent);
}
};
return (
<div className="w-full max-w-xl space-y-5">
<div>
<p className="text-xs uppercase tracking-widest text-primary">
From draft to delivery
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">
Every file accounted for.
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Pick files, watch progress, cancel, and retry. Failures keep their
place in the queue.
</p>
</div>
<UploadQueue
label="Project handoff"
upload={upload}
accept=".pdf,.txt,.png,.jpg"
maxFiles={4}
onUploaded={(file) =>
setCompleted((previous) => [...previous, file.name])
}
/>
<Button
variant="outline"
size="sm"
aria-pressed={failure}
onClick={() => setFailure(!failure)}
>
{failure ? "Restore connection" : "Simulate upload failure"}
</Button>
<p role="status" className="text-xs text-muted-foreground">
{completed.length ? `Demo completed: ${completed.join(", ")}. ` : ""}
This adapter simulates transport locally; no file bytes are sent or
stored. Replace upload with your API adapter.
</p>
</div>
);
}Usage
Select or drop File objects, then upload individual items or process the queue serially. Provide upload(file, {signal, onProgress}) to connect storage or your API. Progress is clamped below 100 until the adapter resolves; rejection keeps a retryable row. Cancel aborts the active adapter and ignores subsequent progress or success. Duplicate file fingerprints, accept rules, queue limits, and size limits are checked before admission. Client validation is a convenience; enforce permissions, content type, and limits on the server as well. Without an adapter, the queue can select files but never claims they were uploaded. The example simulates transport; the Files page records demo metadata only, not file bytes.
import { UploadQueue, type UploadAdapter } from "@/components/ui/upload-queue";Shipped as editable source in src/components/ui/upload-queue.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/upload-queue.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { FileUp, File, Check, X } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface UploadContext {
signal: AbortSignal;
onProgress: (percent: number) => void;
}
export type UploadAdapter = (
file: File,
context: UploadContext,
) => Promise<void>;
export interface UploadQueueProps {
label: string;
upload?: UploadAdapter;
onUploaded?: (file: File) => void;
accept?: string;
maxFiles?: number;
maxSize?: number;
disabled?: boolean;
className?: string;
}
type UploadState = "queued" | "uploading" | "done" | "failed" | "cancelled";
interface QueueItem {
id: string;
file: File;
status: UploadState;
progress: number;
error?: string;
}
export function acceptsUpload(file: Pick<File, "name" | "type">, accept = "") {
const rules = accept
.split(",")
.map((rule) => rule.trim().toLowerCase())
.filter(Boolean);
return (
!rules.length ||
rules.some((rule) =>
rule.startsWith(".")
? file.name.toLowerCase().endsWith(rule)
: rule.endsWith("/*")
? file.type.toLowerCase().startsWith(rule.slice(0, -1))
: file.type.toLowerCase() === rule,
)
);
}
export function UploadQueue({
label,
upload,
onUploaded,
accept,
maxFiles = 5,
maxSize = 10 * 1024 * 1024,
disabled,
className,
}: UploadQueueProps) {
const id = useId();
const sequence = useRef(0);
const input = useRef<HTMLInputElement>(null);
const controllers = useRef(new Map<string, AbortController>());
const [items, setItems] = useState<QueueItem[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [dragging, setDragging] = useState(false);
const [running, setRunning] = useState(false);
const batch = useRef(false);
useEffect(() => {
const active = controllers.current;
return () => {
active.forEach((controller) => controller.abort());
active.clear();
batch.current = false;
};
}, []);
const update = (itemId: string, patch: Partial<QueueItem>) =>
setItems((previous) =>
previous.map((item) =>
item.id === itemId ? { ...item, ...patch } : item,
),
);
const add = (files: File[]) => {
if (disabled) return;
const messages: string[] = [];
const incoming: QueueItem[] = [];
const keys = new Set(
items.map(
(item) =>
`${item.file.name}:${item.file.size}:${item.file.lastModified}`,
),
);
for (const file of files) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (keys.has(key)) {
messages.push(`${file.name}: already in this queue.`);
continue;
}
if (!acceptsUpload(file, accept)) {
messages.push(`${file.name}: file type is not allowed.`);
continue;
}
if (file.size > maxSize) {
messages.push(`${file.name}: exceeds the file size limit.`);
continue;
}
if (items.length + incoming.length >= Math.max(0, maxFiles)) {
messages.push(`${file.name}: queue is full.`);
continue;
}
keys.add(key);
incoming.push({
id: `${id}-${sequence.current++}`,
file,
status: "queued",
progress: 0,
});
}
setItems((previous) => [...previous, ...incoming]);
setErrors(messages);
};
const start = async (item: QueueItem) => {
if (
!upload ||
disabled ||
controllers.current.has(item.id) ||
item.status === "done"
)
return;
const controller = new AbortController();
controllers.current.set(item.id, controller);
const current = () =>
controllers.current.get(item.id) === controller &&
!controller.signal.aborted;
update(item.id, { status: "uploading", progress: 0, error: undefined });
try {
await new Promise<void>((resolve, reject) => {
const abort = () => reject(new DOMException("Aborted", "AbortError"));
controller.signal.addEventListener("abort", abort, { once: true });
Promise.resolve()
.then(() => {
controller.signal.throwIfAborted();
return upload(item.file, {
signal: controller.signal,
onProgress: (percent) => {
if (current() && Number.isFinite(percent))
update(item.id, {
progress: Math.max(0, Math.min(99, percent)),
});
},
});
})
.then(resolve, reject)
.finally(() => controller.signal.removeEventListener("abort", abort));
});
if (current()) {
onUploaded?.(item.file);
update(item.id, { status: "done", progress: 100 });
}
} catch {
if (current())
update(item.id, {
status: "failed",
error: "Upload failed. Your file is kept here for retry.",
});
} finally {
if (controllers.current.get(item.id) === controller)
controllers.current.delete(item.id);
}
};
const cancel = (item: QueueItem) => {
const controller = controllers.current.get(item.id);
controllers.current.delete(item.id);
controller?.abort();
update(item.id, { status: "cancelled", error: undefined });
};
const pending = items.filter(
(item) =>
item.status === "queued" ||
item.status === "failed" ||
item.status === "cancelled",
);
const startAll = async () => {
if (batch.current || disabled) return;
batch.current = true;
setRunning(true);
try {
for (const item of pending) {
if (!batch.current) break;
await start(item);
}
} finally {
if (batch.current) {
batch.current = false;
setRunning(false);
}
}
};
return (
<section
aria-label={label}
className={cn(
"min-w-0 space-y-4 rounded-xl border bg-card p-4 sm:p-5",
className,
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{label}</h3>
<p className="mt-1 text-xs text-muted-foreground">
Up to {maxFiles} files ·{" "}
{(maxSize / 1024 / 1024).toLocaleString("en-US", {
maximumFractionDigits: 1,
})}{" "}
MB each
</p>
</div>
<span className="rounded-full bg-muted px-2 py-1 text-xs tabular-nums">
{items.length}/{maxFiles}
</span>
</div>
<div
onDragOver={(event) => {
event.preventDefault();
if (!disabled) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
add([...event.dataTransfer.files]);
}}
className={cn(
"rounded-xl border-2 border-dashed p-6 text-center",
dragging
? "border-primary bg-primary/5"
: "border-border bg-muted/20",
)}
>
<FileUp aria-hidden className="mx-auto mb-2 size-7 text-primary" />
<p className="text-sm font-medium">Drop your files here</p>
<p className="mt-1 text-xs text-muted-foreground">
{accept || "Any file type"}
</p>
<input
ref={input}
id={id}
type="file"
multiple
accept={accept}
disabled={disabled}
className="sr-only"
aria-label={`Choose files for ${label}`}
onChange={(event) => {
add([...(event.target.files ?? [])]);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3"
disabled={disabled || items.length >= maxFiles}
onClick={() => input.current?.click()}
>
Browse files
</Button>
</div>
{!!errors.length && (
<div role="alert" className="space-y-1 text-sm text-destructive">
{errors.map((message, index) => (
<p key={`${message}-${index}`}>{message}</p>
))}
</div>
)}
{!items.length ? (
<p className="py-2 text-center text-xs text-muted-foreground">
Your queue is empty. Add files to get started.
</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id} className="rounded-lg border bg-background p-3">
<div className="flex items-center gap-2">
<File
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<span
className="min-w-0 flex-1 truncate text-sm font-medium"
title={item.file.name}
>
{item.file.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{Math.max(1, Math.round(item.file.size / 1024))} KB
</span>
{item.status === "done" && (
<Check
aria-label="Uploaded"
className="size-4 text-primary"
/>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="flex-1 text-xs capitalize text-muted-foreground">
{item.status === "uploading"
? `Uploading ${Math.round(item.progress)}%`
: item.status}
</span>
{item.status === "uploading" ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-label={`Cancel ${item.file.name}`}
onClick={() => cancel(item)}
>
Cancel
</Button>
) : (
item.status !== "done" && (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || !upload || running}
aria-label={`${item.status === "queued" ? "Upload" : "Retry"} ${item.file.name}`}
onClick={() => void start(item)}
>
{item.status === "queued" ? "Upload" : "Retry"}
</Button>
)
)}
{item.status !== "uploading" && (
<button
type="button"
disabled={disabled || running}
aria-label={`Remove ${item.file.name}`}
className="rounded p-1 text-muted-foreground hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
onClick={() =>
setItems((previous) =>
previous.filter((entry) => entry.id !== item.id),
)
}
>
<X className="size-4" />
</button>
)}
</div>
{item.status === "uploading" && (
<div
role="progressbar"
aria-label={`Upload progress for ${item.file.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(item.progress)}
className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full bg-primary"
style={{ width: `${item.progress}%` }}
/>
</div>
)}
{item.error && (
<p role="alert" className="mt-1 text-xs text-destructive">
{item.error}
</p>
)}
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<p role="status" className="text-xs text-muted-foreground">
{items.filter((item) => item.status === "done").length} completed ·{" "}
{items.filter((item) => item.status === "failed").length} failed
</p>
<Button
type="button"
size="sm"
disabled={
disabled ||
!upload ||
!pending.length ||
running ||
items.some((item) => item.status === "uploading")
}
onClick={() => void startAll()}
>
{running ? "Uploading queue…" : "Upload queue"}
</Button>
</div>
{!upload && (
<p className="text-xs text-muted-foreground">
Connect an upload adapter to enable uploads. Selected files have not
been uploaded.
</p>
)}
</section>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible queue title and accessible section name. |
| upload | UploadAdapterDefault: — | Async transport receiving File, AbortSignal, and progress callback. Resolve only after success; honor cancellation. |
| onUploaded | (file: File) => voidDefault: — | Notify the parent after transport resolves, for metadata registration or refresh. |
| accept | stringDefault: any type | Comma-separated MIME, wildcard MIME, or filename extension rules. |
| maxFiles / maxSize | number / numberDefault: 5 / 10485760 | Maximum queue rows and per-file bytes. Completed rows count until removed. |
| disabled / className | boolean / stringDefault: false / — | Prevent new interactions or extend layout; active uploads retain cancellation. |