'use client' import { useEffect, useId, useRef, useState } from 'react' import { cn } from '../../lib/cn' import type { IconComponent } from '../../lib/types' import { Button } from '../Button' import { IconButton } from '../IconButton' import { Progress } from '../Progress' import { Text } from '../Text' import { CheckIcon, ChevronDownIcon, CrossIcon } from '../internal/icons' export type UploadQueueStatus = 'queued' | 'uploading' | 'paused' | 'done' | 'error' export interface UploadQueueFile { id: string name: string /** Size in bytes. */ size: number /** Share uploaded, 0 to 1. */ progress: number status: UploadQueueStatus /** Why it failed. Shown in place of the size line. */ error?: string } export interface UploadQueueProps { files: UploadQueueFile[] /** Heading for the panel. Defaults to a count of what is uploading, failed or complete. */ title?: string /** Accessible name for the region. Give each queue on a page its own. */ label?: string /** Pin the panel to the bottom-right corner of the viewport. */ floating?: boolean /** Controlled: whether the panel is collapsed to a pill. */ collapsed?: boolean /** Whether the panel starts collapsed, when uncontrolled. */ defaultCollapsed?: boolean onCollapsedChange?: (collapsed: boolean) => void onPause?: (id: string) => void onResume?: (id: string) => void onCancel?: (id: string) => void onRetry?: (id: string) => void /** Removes finished rows. Without it, the button is not shown. */ onClearCompleted?: () => void /** Merged last, so it wins. */ className?: string } function formatUploadBytes(bytes: number) { if (bytes < 1024) return `${bytes} B` const units = ['KB', 'MB', 'GB', 'TB'] let value = bytes / 1024 let unit = 0 while (value >= 1024 && unit < units.length - 1) { value /= 1024 unit += 1 } return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}` } const glyph = (path: React.ReactNode): IconComponent => function UploadGlyph({ size = 16, strokeWidth = 2, className }) { return ( ) } const PauseIcon = glyph() const PlayIcon = glyph() const RetryIcon = glyph() /** * Every upload in flight, in one panel that stays out of the way — per-file * progress and controls, the overall figure in the header, and a pill when * collapsed. * * Uploads outlive the screen that started them, so the queue is its own * surface rather than a row of progress bars in a form. The header carries the * whole-queue percentage weighted by bytes, not by file count: one 2 GB video * and nine thumbnails are not 90% done when the thumbnails finish. A failure * keeps its row with the reason and a retry, and finished rows stay until they * are cleared, so nothing leaves the list without the reader seeing it land. */ export function UploadQueue({ files, title, label = 'Uploads', floating = false, collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, onPause, onResume, onCancel, onRetry, onClearCompleted, className, }: UploadQueueProps) { const listId = useId() const pillId = useId() const collapseId = useId() const [collapsedState, setCollapsedState] = useState(defaultCollapsed) const collapsed = collapsedProp ?? collapsedState const toggled = useRef(false) const setCollapsed = (next: boolean) => { toggled.current = true if (collapsedProp === undefined) setCollapsedState(next) onCollapsedChange?.(next) } // The control that was pressed unmounts, so focus moves to the one that // replaced it rather than falling back to the page. useEffect(() => { if (!toggled.current) return toggled.current = false document.getElementById(collapsed ? pillId : collapseId)?.focus() }, [collapsed, pillId, collapseId]) const totalBytes = files.reduce((sum, file) => sum + file.size, 0) const sentBytes = files.reduce((sum, file) => sum + file.size * Math.min(1, Math.max(0, file.progress)), 0) const percent = totalBytes === 0 ? 0 : Math.round((sentBytes / totalBytes) * 100) const done = files.filter((file) => file.status === 'done').length const failed = files.filter((file) => file.status === 'error').length const remaining = files.length - done - failed const heading = title ?? (remaining > 0 ? `Uploading ${remaining} ${remaining === 1 ? 'file' : 'files'}` : failed > 0 ? `${failed} ${failed === 1 ? 'upload' : 'uploads'} failed` : `${done} ${done === 1 ? 'upload' : 'uploads'} complete`) const summary = `${done} of ${files.length} uploaded${failed ? `, ${failed} failed` : ''}` const frame = cn( floating && 'fixed bottom-4 right-4 z-[var(--z-toast)] max-w-[calc(100vw-2rem)]', 'shadow-[var(--shadow-float)]', ) return (
{collapsed ? ( ) : (
{heading} {percent}%
setCollapsed(true)} />
    {files.map((file) => { const sent = file.size * Math.min(1, Math.max(0, file.progress)) return (
  • {file.name} {file.status === 'done' && ( Uploaded )}
    {file.status !== 'done' && ( div]:bg-danger', file.status === 'paused' && '[&>div]:bg-ink-faint', )} /> )} {file.status === 'error' ? (file.error ?? 'Upload failed') : file.status === 'done' ? formatUploadBytes(file.size) : `${formatUploadBytes(sent)} of ${formatUploadBytes(file.size)} · ${ file.status === 'queued' ? 'Queued' : file.status === 'paused' ? 'Paused' : `${Math.round(file.progress * 100)}%` }`}
    {file.status === 'uploading' && onPause && ( onPause(file.id)} /> )} {file.status === 'paused' && onResume && ( onResume(file.id)} /> )} {file.status === 'error' && onRetry && ( onRetry(file.id)} /> )} {file.status !== 'done' && onCancel && ( onCancel(file.id)} /> )}
  • ) })}
{onClearCompleted && done > 0 && (
{summary}
)}
)}
{files.length ? summary : ''}
) }