File Upload

A dropzone over a real file input, plus the upload queue nobody ships.

Any file, up to 10 MB

Installation

Terminal
pnpm dlx @dowel-ui/cli add file-upload

No npm packages are needed beyond what Dowel already requires.

Accessibility

The APG has no dropzone pattern, and inventing one is the usual failure — a div with role="button", a keydown handler, and a picker keyboard users never reach. The control here is a real input[type=file] with a real label, which is already operable, already announced, and opens the picker on Enter and Space with nothing added. Drag and drop is a pointer convenience layered on top; every drop can also be done from the input. Each uploading file gets a progressbar named after it, status is always stated in words as well as drawn, and one polite live region summarises the whole queue rather than six per-file regions talking over each other. The optional dropzone icon is aria-hidden. With animateExit, a removed row lingers only as an aria-hidden, inert copy with no buttons while it animates out.

Props

FileUpload

PropTypeDefault
label (required)

Names the control.

string
onFiles (required)(files: File[]) => void
acceptstring
disabledbooleanfalse
hint

Shown under the prompt: accepted types, size limit.

ReactNode
icon

A decorative icon above the prompt, hidden from assistive technology. It lifts while a drag is over the dropzone.

ReactNode
multiplebooleantrue
childrenReactNode

Plus every attribute of <div> except onDrop.

FileUploadList

PropTypeDefault
files (required)QueuedFile[]
animateExit

Lets a removed file slide out before it leaves the DOM. The departing row is aria-hidden, inert and has no buttons. Off by default, because the old row briefly remains in the markup.

booleanfalse
onCancel(id: string) => void
onRemove(id: string) => void
onRetry(id: string) => void

Plus every attribute of <ul>.

FileUploadItem

PropTypeDefault
entry (required)QueuedFile
onCancel(id: string) => void
onRemove(id: string) => void
onRetry(id: string) => void

Plus every attribute of <li> except children.

FileUploadStatus

PropTypeDefault
stats (required){ total: number; active: number; done: number; failed: number }

Plus every attribute of <p>.

Quality

9/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedfails
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add file-upload writes into your project, with imports rewritten to your own path alias.

ui/upload-queue.ts
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

/**
 * The upload queue.
 *
 * The dropzone is the most duplicated component in the React ecosystem and the
 * least valuable half: it is a styled rectangle over `<input type="file">`.
 * What almost nobody ships is this — progress, cancel, retry with backoff, and
 * a concurrency limit — so every team writes it again, usually twice, because
 * the first version has no cancel and no retry.
 *
 * Transport is injected. This never calls `fetch` or constructs a request,
 * because the request is the part that differs everywhere: presigned S3 PUT,
 * multipart POST, tus, an internal gateway with its own auth. `upload` receives
 * the file, a progress callback and an AbortSignal, and returns a promise. That
 * is the whole contract.
 *
 * Progress needs XHR, not fetch. `fetch` still cannot report upload progress in
 * any shipping browser — there is no readable stream for the request body — so
 * a transport that wants a real progress bar has to use XMLHttpRequest. That is
 * the consumer's choice to make, and `xhrUpload` below is a working example
 * rather than a dependency.
 */

export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "cancelled";

export interface QueuedFile {
  /** Stable across retries, so React keys and announcements do not jump. */
  id: string;
  file: File;
  status: UploadStatus;
  /** 0–1, or null when the transport cannot report it. */
  progress: number | null;
  /** Why it failed or was refused. Kept so it can be read and acted on. */
  error?: string;
  attempts: number;
}

export interface UploadContext {
  onProgress: (fraction: number) => void;
  signal: AbortSignal;
}

export type UploadFn = (file: File, context: UploadContext) => Promise<void>;

export interface UploadQueueOptions {
  upload: UploadFn;
  /** Uploads running at once. More is not faster once the link is saturated. */
  concurrency?: number;
  /** Automatic retries per file before it is left failed. */
  maxAttempts?: number;
  /** Largest accepted file, in bytes. */
  maxSize?: number;
  /** Accepted types, as an `accept` attribute value: ".pdf,image/*". */
  accept?: string;
  /** Total files the queue will hold. */
  maxFiles?: number;
  onComplete?: (file: QueuedFile) => void;
}

/** Backoff between attempts. Bounded, because a person is waiting. */
function backoffMs(attempt: number): number {
  return Math.min(8000, 500 * 2 ** (attempt - 1));
}

/** Matches a file against an `accept` string the same way the browser does. */
export function matchesAccept(file: File, accept: string | undefined): boolean {
  if (!accept) return true;
  const patterns = accept
    .split(",")
    .map((part) => part.trim().toLowerCase())
    .filter(Boolean);
  if (patterns.length === 0) return true;

  const type = file.type.toLowerCase();
  const name = file.name.toLowerCase();

  return patterns.some((pattern) => {
    if (pattern.startsWith(".")) return name.endsWith(pattern);
    if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
    return type === pattern;
  });
}

export function formatBytes(bytes: number): string {
  if (bytes < 1000) return `${String(bytes)} B`;
  const units = ["kB", "MB", "GB", "TB"];
  let value = bytes / 1000;
  let unit = 0;
  while (value >= 1000 && unit < units.length - 1) {
    value /= 1000;
    unit += 1;
  }
  return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit] ?? "B"}`;
}

let sequence = 0;

export function useUploadQueue(options: UploadQueueOptions) {
  const {
    upload,
    concurrency = 3,
    maxAttempts = 3,
    maxSize,
    accept,
    maxFiles,
    onComplete,
  } = options;

  const [files, setFiles] = useState<QueuedFile[]>([]);

  const controllers = useRef(new Map<string, AbortController>());
  const running = useRef(new Set<string>());

  // The live option values, so an in-flight upload reads the current transport
  // without `run` being re-created — which would churn the scheduling effect
  // every time a consumer passes an inline `upload`. Written after commit
  // rather than during render, because a ref write during render is unsafe
  // under concurrent rendering; `run` only reads this once it is executing,
  // which is always after the effect has run.
  const latest = useRef({ upload, concurrency, maxAttempts, onComplete });
  useEffect(() => {
    latest.current = { upload, concurrency, maxAttempts, onComplete };
  });

  const patch = useCallback((id: string, changes: Partial<QueuedFile>) => {
    setFiles((current) =>
      current.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)),
    );
  }, []);

  const run = useCallback(
    async (entry: QueuedFile) => {
      const controller = new AbortController();
      controllers.current.set(entry.id, controller);

      const attempt = entry.attempts + 1;
      patch(entry.id, {
        status: "uploading",
        progress: 0,
        attempts: attempt,
        error: undefined,
      });

      try {
        await latest.current.upload(entry.file, {
          signal: controller.signal,
          onProgress: (fraction) => {
            patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });
          },
        });

        patch(entry.id, { status: "done", progress: 1 });
        latest.current.onComplete?.({ ...entry, status: "done", progress: 1 });
      } catch (error) {
        if (controller.signal.aborted) {
          patch(entry.id, { status: "cancelled", progress: null });
        } else if (attempt < latest.current.maxAttempts) {
          // Back off, then requeue. Releasing the slot only after the delay is
          // what makes the wait real rather than a busy retry, and the file
          // re-enters the queue like any other so retries obey concurrency too.
          setTimeout(() => {
            running.current.delete(entry.id);
            controllers.current.delete(entry.id);
            patch(entry.id, { status: "queued", progress: null });
          }, backoffMs(attempt));
          return;
        } else {
          patch(entry.id, {
            status: "failed",
            progress: null,
            error: error instanceof Error ? error.message : "Upload failed",
          });
        }
      }

      controllers.current.delete(entry.id);
      running.current.delete(entry.id);
      // No explicit pump: the scheduling effect below reacts to the state
      // change and starts whatever can start next.
      setFiles((current) => [...current]);
    },
    [patch],
  );

  // Starts whatever can start, once the state that made it startable has been
  // committed. Doing this inside a setState updater — the obvious shortcut —
  // means React may run it twice and upload the same file twice; the `running`
  // set guards that, but the effect is the honest place for it.
  useEffect(() => {
    const free = concurrency - running.current.size;
    if (free <= 0) return;

    const next = files
      .filter((entry) => entry.status === "queued" && !running.current.has(entry.id))
      .slice(0, free);

    for (const entry of next) {
      running.current.add(entry.id);
      void run(entry);
    }
  }, [files, concurrency, run]);

  /** Validates and enqueues. A rejected file is kept and told why. */
  const add = useCallback(
    (incoming: File[]) => {
      setFiles((current) => {
        const room = maxFiles === undefined ? incoming.length : maxFiles - current.length;
        const admitted: QueuedFile[] = [];

        for (const file of incoming.slice(0, Math.max(0, room))) {
          sequence += 1;
          const id = `upload-${String(sequence)}`;

          // A rejected file becomes a failed entry rather than disappearing.
          // Silently dropping a file the reader chose is how these components
          // lose work without anybody noticing.
          if (maxSize !== undefined && file.size > maxSize) {
            admitted.push({
              id,
              file,
              status: "failed",
              progress: null,
              attempts: 0,
              error: `Larger than ${formatBytes(maxSize)}`,
            });
            continue;
          }
          if (!matchesAccept(file, accept)) {
            admitted.push({
              id,
              file,
              status: "failed",
              progress: null,
              attempts: 0,
              error: "Type not accepted",
            });
            continue;
          }

          admitted.push({ id, file, status: "queued", progress: null, attempts: 0 });
        }

        return [...current, ...admitted];
      });
    },
    [accept, maxFiles, maxSize],
  );

  const cancel = useCallback((id: string) => {
    controllers.current.get(id)?.abort();
  }, []);

  const retry = useCallback((id: string) => {
    setFiles((current) =>
      current.map((entry) =>
        entry.id === id
          ? { ...entry, status: "queued" as const, error: undefined, attempts: 0 }
          : entry,
      ),
    );
  }, []);

  const remove = useCallback((id: string) => {
    controllers.current.get(id)?.abort();
    controllers.current.delete(id);
    running.current.delete(id);
    setFiles((current) => current.filter((entry) => entry.id !== id));
  }, []);

  const clearCompleted = useCallback(() => {
    setFiles((current) => current.filter((entry) => entry.status !== "done"));
  }, []);

  const stats = useMemo(() => {
    const by = (status: UploadStatus) =>
      files.filter((entry) => entry.status === status).length;
    return {
      total: files.length,
      queued: by("queued"),
      uploading: by("uploading"),
      done: by("done"),
      failed: by("failed"),
      cancelled: by("cancelled"),
      active: by("queued") + by("uploading"),
    };
  }, [files]);

  return { files, stats, add, cancel, retry, remove, clearCompleted };
}

/**
 * A working XHR transport, as an example rather than a dependency.
 *
 * XMLHttpRequest and not fetch, because fetch still cannot report upload
 * progress: there is no readable stream for a request body in any shipping
 * browser. Copy this and change the request to match your backend.
 */
export function xhrUpload(
  url: string,
  init: { method?: string; headers?: Record<string, string> } = {},
): UploadFn {
  return (file, { onProgress, signal }) =>
    new Promise<void>((resolve, reject) => {
      const request = new XMLHttpRequest();
      request.open(init.method ?? "POST", url);

      for (const [header, value] of Object.entries(init.headers ?? {})) {
        request.setRequestHeader(header, value);
      }

      request.upload.addEventListener("progress", (event) => {
        if (event.lengthComputable) onProgress(event.loaded / event.total);
      });

      request.addEventListener("load", () => {
        if (request.status >= 200 && request.status < 300) resolve();
        else reject(new Error(`Upload failed with status ${String(request.status)}`));
      });
      request.addEventListener("error", () => {
        reject(new Error("Network error"));
      });
      request.addEventListener("abort", () => {
        reject(new Error("Cancelled"));
      });

      signal.addEventListener("abort", () => {
        request.abort();
      });

      const body = new FormData();
      body.append("file", file);
      request.send(body);
    });
}
ui/file-upload.tsx
"use client";

// Motion from SmoothUI AnimatedFileUpload (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type DragEvent,
  type ReactNode,
} from "react";

import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import { formatBytes, type QueuedFile, type UploadStatus } from "./upload-queue";

/**
 * The visible half of uploading. The queue is in `upload-queue.ts` and is the
 * part worth owning.
 *
 * There is no dropzone pattern in the WAI-ARIA APG, and inventing one is the
 * usual failure: a `div` with `role="button"`, a keydown handler, and a file
 * picker that keyboard users can never reach. So the control here is a real
 * `<input type="file">` with a real `<label>`. That is already operable by
 * keyboard, already announces itself, already opens the picker on Enter and
 * Space, and needs nothing added. Drag and drop is layered on top as a pointer
 * convenience, and every drop can also be done from the input.
 */

/*
 * Motion (SmoothUI's AnimatedFileUpload): the dropzone swells slightly while a
 * drag is over it and an optional icon lifts; queued files slide in from the
 * inline start, and with `animateExit` slide out toward the inline end.
 * Keyframes ship with the component (ADR 0014) on duration tokens, so reduced
 * motion collapses them; the travel direction follows :dir().
 */
const ITEM_KEYFRAMES = `
@keyframes dowel-file-upload-in{from{opacity:0;transform:translateX(var(--dowel-file-upload-x)) scale(0.95)}}
@keyframes dowel-file-upload-out{to{opacity:0;transform:translateX(calc(-1.5 * var(--dowel-file-upload-x))) scale(0.95)}}
[data-slot="file-upload-item"]{--dowel-file-upload-x:-1rem;animation:dowel-file-upload-in var(--duration-normal) var(--ease-out-quint)}
[data-slot="file-upload-item"]:dir(rtl){--dowel-file-upload-x:1rem}
[data-slot="file-upload-item"][data-state="closed"]{animation:dowel-file-upload-out var(--duration-fast) var(--ease-in-quint) forwards}
`;

/** Clears departing rows whose animation never runs (hidden, unstyled). */
const EXIT_FALLBACK_MS = 1000;

const STATUS_LABEL: Record<UploadStatus, string> = {
  queued: "Waiting",
  uploading: "Uploading",
  done: "Uploaded",
  failed: "Failed",
  cancelled: "Cancelled",
};

export interface FileUploadProps extends Omit<ComponentPropsWithRef<"div">, "onDrop"> {
  /** Names the control. */
  label: string;
  onFiles: (files: File[]) => void;
  accept?: string;
  multiple?: boolean;
  disabled?: boolean;
  /** Shown under the prompt: accepted types, size limit. */
  hint?: ReactNode;
  /**
   * A decorative icon above the prompt, hidden from assistive technology. It
   * lifts while a drag is over the dropzone.
   */
  icon?: ReactNode;
  children?: ReactNode;
}

export function FileUpload({
  className,
  label,
  onFiles,
  accept,
  multiple = true,
  disabled = false,
  hint,
  icon,
  children,
  ...props
}: FileUploadProps) {
  const inputId = useId();
  const hintId = useId();
  const [dragging, setDragging] = useState(false);
  const depth = useRef(0);

  function handleDrop(event: DragEvent<HTMLDivElement>) {
    event.preventDefault();
    depth.current = 0;
    setDragging(false);
    if (disabled) return;

    const dropped = [...event.dataTransfer.files];
    if (dropped.length > 0) onFiles(multiple ? dropped : dropped.slice(0, 1));
  }

  return (
    <div data-slot="file-upload" className={cn("flex flex-col gap-3", className)} {...props}>
      {/* dragenter/dragleave fire for every child element, so a plain boolean
          flickers as the pointer crosses the prompt text. Counting depth is
          what makes the highlight steady. */}
      <div
        data-slot="file-upload-dropzone"
        data-dragging={dragging || undefined}
        onDragEnter={(event) => {
          event.preventDefault();
          depth.current += 1;
          if (!disabled) setDragging(true);
        }}
        onDragLeave={() => {
          depth.current -= 1;
          if (depth.current <= 0) setDragging(false);
        }}
        onDragOver={(event) => {
          event.preventDefault();
        }}
        onDrop={handleDrop}
        className={cn(
          "rounded-lg border border-dashed border-border-strong bg-muted/30 px-4 py-6 text-center",
          "transition-[color,background-color,border-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-out-quint)]",
          dragging && "scale-[1.02] border-primary bg-primary/5",
          disabled && "pointer-events-none opacity-55",
        )}
      >
        {icon ? (
          <div
            aria-hidden="true"
            data-slot="file-upload-icon"
            className={cn(
              "mx-auto mb-2 flex w-fit text-muted-foreground [&_svg:not([class*='size-'])]:size-8",
              "transition-[color,translate,scale] duration-[var(--duration-normal)] ease-[var(--ease-overshoot)]",
              dragging && "-translate-y-1 scale-115 text-foreground",
            )}
          >
            {icon}
          </div>
        ) : null}

        {/* The label is the control. Clicking it opens the picker, Enter and
            Space activate it, and assistive technology already describes it. */}
        <label
          htmlFor={inputId}
          className={cn(
            "inline-flex cursor-pointer flex-col items-center gap-1 rounded-md px-2 py-1 text-sm",
            "focus-within:ring-2 focus-within:ring-ring/55",
          )}
        >
          <span className="font-medium">{label}</span>
          <span className="text-xs text-muted-foreground">
            Drop {multiple ? "files" : "a file"} here, or choose from your device
          </span>
          <input
            id={inputId}
            type="file"
            accept={accept}
            multiple={multiple}
            disabled={disabled}
            aria-describedby={hint ? hintId : undefined}
            onChange={(event) => {
              const chosen = [...(event.target.files ?? [])];
              if (chosen.length > 0) onFiles(chosen);
              // Reset, so choosing the same file twice fires change twice.
              event.target.value = "";
            }}
            className="sr-only"
          />
        </label>

        {hint ? (
          <p id={hintId} className="mt-2 text-xs text-muted-foreground">
            {hint}
          </p>
        ) : null}
      </div>

      {children}
    </div>
  );
}

export interface FileUploadListProps extends ComponentPropsWithRef<"ul"> {
  files: QueuedFile[];
  /**
   * Lets a removed file slide out before it leaves the DOM. The departing row
   * is `aria-hidden`, inert and has no buttons. Off by default, because the old
   * row briefly remains in the markup.
   */
  animateExit?: boolean;
  onCancel?: (id: string) => void;
  onRetry?: (id: string) => void;
  onRemove?: (id: string) => void;
}

interface LeavingEntry {
  entry: QueuedFile;
  index: number;
}

export function FileUploadList({
  className,
  files,
  onCancel,
  onRetry,
  onRemove,
  animateExit = false,
  ...props
}: FileUploadListProps) {
  // Derived from the previous render rather than in an effect, so a departing
  // row is in place on the same commit its live one leaves.
  const [previous, setPrevious] = useState(files);
  const [leaving, setLeaving] = useState<LeavingEntry[]>([]);
  if (previous !== files) {
    setPrevious(files);
    const present = new Set(files.map((entry) => entry.id));
    const gone = animateExit
      ? previous.flatMap((entry, index) => (present.has(entry.id) ? [] : [{ entry, index }]))
      : [];
    setLeaving((current) => [
      ...current.filter(
        (item) =>
          !present.has(item.entry.id) && !gone.some((g) => g.entry.id === item.entry.id),
      ),
      ...gone,
    ]);
  }

  useEffect(() => {
    if (leaving.length === 0) return;
    const timer = setTimeout(() => {
      setLeaving([]);
    }, EXIT_FALLBACK_MS);
    return () => {
      clearTimeout(timer);
    };
  }, [leaving]);

  // A native listener: React maps onAnimationEnd to a vendor-prefixed name
  // wherever AnimationEvent is missing. React 19 runs the returned cleanup.
  const listenForExitEnd = useCallback(
    (id: string) => (element: HTMLLIElement | null) => {
      if (!element) return;
      const onEnd = (event: Event) => {
        if (event.target !== element) return;
        setLeaving((current) => current.filter((item) => item.entry.id !== id));
      };
      element.addEventListener("animationend", onEnd);
      return () => {
        element.removeEventListener("animationend", onEnd);
      };
    },
    [],
  );

  const rows: { entry: QueuedFile; gone: boolean }[] = files.map((entry) => ({
    entry,
    gone: false,
  }));
  for (const item of [...leaving].sort((a, b) => a.index - b.index)) {
    rows.splice(Math.min(item.index, rows.length), 0, { entry: item.entry, gone: true });
  }

  if (rows.length === 0) return null;

  return (
    <ul
      data-slot="file-upload-list"
      className={cn("flex list-none flex-col gap-2", className)}
      {...props}
    >
      {rows.map(({ entry, gone }) =>
        gone ? (
          <FileUploadItem
            key={`gone:${entry.id}`}
            ref={listenForExitEnd(entry.id)}
            entry={entry}
            data-state="closed"
            aria-hidden="true"
            inert
          />
        ) : (
          <FileUploadItem
            key={entry.id}
            entry={entry}
            onCancel={onCancel}
            onRetry={onRetry}
            onRemove={onRemove}
          />
        ),
      )}
    </ul>
  );
}

export interface FileUploadItemProps extends Omit<ComponentPropsWithRef<"li">, "children"> {
  entry: QueuedFile;
  onCancel?: (id: string) => void;
  onRetry?: (id: string) => void;
  onRemove?: (id: string) => void;
}

export function FileUploadItem({
  className,
  entry,
  onCancel,
  onRetry,
  onRemove,
  ...props
}: FileUploadItemProps) {
  const { id, file, status, progress, error } = entry;
  const percent = progress === null ? null : Math.round(progress * 100);

  return (
    <li
      data-slot="file-upload-item"
      data-status={status}
      className={cn(
        "flex items-center gap-3 rounded-lg border px-3 py-2 text-sm",
        status === "failed"
          ? "border-destructive/40 bg-destructive/5"
          : "border-border bg-card",
        className,
      )}
      {...props}
    >
      <style href="dowel-file-upload" precedence="dowel">
        {ITEM_KEYFRAMES}
      </style>
      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <div className="flex items-baseline justify-between gap-2">
          <span className="truncate font-medium">{file.name}</span>
          <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
            {formatBytes(file.size)}
          </span>
        </div>

        {/* Status in words, always. A bar at 60% with a red tint does not say
            whether it is uploading, stalled or failed. */}
        <p
          className={cn(
            "text-xs",
            status === "failed" ? "text-destructive" : "text-muted-foreground",
          )}
        >
          {STATUS_LABEL[status]}
          {status === "uploading" && percent !== null ? ` · ${String(percent)}%` : null}
          {error ? ` · ${error}` : null}
        </p>

        {status === "uploading" ? (
          <div
            role="progressbar"
            aria-label={`Uploading ${file.name}`}
            aria-valuenow={percent ?? undefined}
            aria-valuemin={0}
            aria-valuemax={100}
            className="h-1 w-full overflow-hidden rounded-full bg-muted"
          >
            <div
              data-slot="file-upload-progress"
              className="h-full rounded-full bg-primary transition-[width] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]"
              style={{ width: `${String(percent ?? 0)}%` }}
            />
          </div>
        ) : null}
      </div>

      <div className="flex shrink-0 items-center gap-1">
        {status === "uploading" && onCancel ? (
          <ItemButton onClick={() => onCancel(id)}>Cancel</ItemButton>
        ) : null}
        {(status === "failed" || status === "cancelled") && onRetry ? (
          <ItemButton onClick={() => onRetry(id)}>Retry</ItemButton>
        ) : null}
        {onRemove ? (
          <ItemButton onClick={() => onRemove(id)} aria-label={`Remove ${file.name}`}>
            Remove
          </ItemButton>
        ) : null}
      </div>
    </li>
  );
}

function ItemButton({ className, ...props }: ComponentPropsWithRef<"button">) {
  return (
    <button
      type="button"
      className={cn(
        "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
        "transition-colors hover:bg-accent hover:text-accent-foreground",
        focusRing,
        disabledStyles,
        className,
      )}
      {...props}
    />
  );
}

/**
 * One sentence covering the whole queue, announced politely.
 *
 * Per-file live regions would talk over each other the moment two uploads run
 * at once; one summary of the set is readable where six competing ones are not.
 */
export function FileUploadStatus({
  className,
  stats,
  ...props
}: ComponentPropsWithRef<"p"> & {
  stats: { total: number; active: number; done: number; failed: number };
}) {
  const { total, active, done, failed } = stats;

  // Progress through the set, not a count of what happens to be in flight.
  // "3 of 5 uploading" is wrong the moment a concurrency limit holds two back,
  // and it never tells the reader how much of the job is left.
  const message =
    total === 0
      ? ""
      : active > 0
        ? `${String(done)} of ${String(total)} uploaded`
        : failed > 0
          ? `${String(done)} uploaded, ${String(failed)} failed`
          : `${String(done)} uploaded`;

  return (
    <p
      data-slot="file-upload-status"
      aria-live="polite"
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    >
      {message}
    </p>
  );
}