Kosmesis
Components

File Upload List

Per-file upload rows with progress, cancel, retry and remove, driven by FileUpload.

Installation

npx kosmesis add file-upload-list
pnpm dlx kosmesis add file-upload-list
yarn dlx kosmesis add file-upload-list
bunx kosmesis add file-upload-list

Install the following dependencies:

npm install @praxisjs/composables @morphos/icons
pnpm add @praxisjs/composables @morphos/icons
yarn add @praxisjs/composables @morphos/icons
bun add @praxisjs/composables @morphos/icons

Copy and paste the following code into your project.

file-upload-list.tsx
import { formatFileSize, type FileUpload } from "@praxisjs/composables";import { StatelessComponent } from "@praxisjs/core";import { Component } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";const STATUS_LABEL = {  pending: "Waiting…",  uploading: "Uploading…",  success: "Uploaded",  error: "Failed",  aborted: "Cancelled",} as const;const ICON_BUTTON =  "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50";export interface FileUploadListProps {  upload: FileUpload;  class?: string;}@Component()export class FileUploadList extends StatelessComponent<FileUploadListProps> {  render() {    const { upload, class: cls } = this.props;    return (      <ul class={cn("flex w-full flex-col gap-2", cls)}>        <For each={() => upload.items} by="id">          {(item) => {            const { id, file } = item();            return (              <li data-status={() => item().status} class="group flex flex-col gap-2 rounded-md border p-3 text-sm">                <div class="flex items-center gap-3">                  <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground group-data-[status=success]:text-primary group-data-[status=error]:text-destructive">                    {() => {                      const { status } = item();                      return <Icon name={status === "success" ? "CircleCheck" : status === "error" ? "CircleAlert" : "File"} size={16} />;                    }}                  </span>                  <div class="min-w-0 flex-1">                    <p class="truncate font-medium">{file.name}</p>                    <p class="text-xs text-muted-foreground group-data-[status=error]:text-destructive">                      {formatFileSize(file.size)} · {() => item().error ?? STATUS_LABEL[item().status]}                    </p>                  </div>                  {() =>                    item().status === "uploading" && (                      <button type="button" aria-label={`Cancel ${file.name}`} class={ICON_BUTTON} onClick={() => { upload.cancel(id); }}>                        <Icon name="X" size={14} />                      </button>                    )                  }                  {() =>                    (item().status === "error" || item().status === "aborted") && (                      <button type="button" aria-label={`Retry ${file.name}`} class={ICON_BUTTON} onClick={() => { upload.retry(id); }}>                        <Icon name="RotateCw" size={14} />                      </button>                    )                  }                  {() =>                    item().status !== "uploading" && (                      <button type="button" aria-label={`Remove ${file.name}`} class={ICON_BUTTON} onClick={() => { upload.remove(id); }}>                        <Icon name="Trash2" size={14} />                      </button>                    )                  }                </div>                <div                  role="progressbar"                  aria-valuemin={0}                  aria-valuemax={100}                  aria-valuenow={() => item().progress}                  hidden={() => item().status !== "uploading" && item().status !== "pending"}                  class="h-1.5 w-full overflow-hidden rounded-full bg-secondary"                >                  <div class="h-full rounded-full bg-primary transition-[width]" style={() => `width:${String(item().progress)}%`} />                </div>              </li>            );          }}        </For>      </ul>    );  }}export interface FileUploadSummaryProps {  upload: FileUpload;  class?: string;}@Component()export class FileUploadSummary extends StatelessComponent<FileUploadSummaryProps> {  render() {    const { upload, class: cls } = this.props;    return (      <div class={cn("flex w-full flex-col gap-2 text-sm", cls)}>        <div class="flex items-center justify-between">          <span class="font-medium">            {() => (upload.uploading ? "Uploading…" : `${String(upload.items.length)} file(s)`)}          </span>          <span class="text-muted-foreground">{() => `${String(upload.progress)}%`}</span>        </div>        <div role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={() => upload.progress} class="h-2 w-full overflow-hidden rounded-full bg-secondary">          <div class="h-full rounded-full bg-primary transition-[width]" style={() => `width:${String(upload.progress)}%`} />        </div>      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/composables @morphos/icons @praxisjs/css
pnpm add @praxisjs/composables @morphos/icons @praxisjs/css
yarn add @praxisjs/composables @morphos/icons @praxisjs/css
bun add @praxisjs/composables @morphos/icons @praxisjs/css

Copy and paste the following code into your project.

file-upload-list.tsx
import { formatFileSize, type FileUpload } from "@praxisjs/composables";import { StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);const STATUS_LABEL = {  pending: "Waiting…",  uploading: "Uploading…",  success: "Uploaded",  error: "Failed",  aborted: "Cancelled",} as const;class FileUploadStyles extends Stylesheet {  $list = this.css({ display: "flex", width: "100%", flexDirection: "column", gap: "0.5rem", margin: 0, padding: 0, listStyle: "none" });  $row = this.css({    display: "flex",    flexDirection: "column",    gap: "0.5rem",    borderRadius: `calc(${t.radius} - 2px)`,    border: `1px solid ${t.border}`,    padding: "0.75rem",    fontSize: "0.875rem",  });  $header = this.css({ display: "flex", alignItems: "center", gap: "0.75rem" });  $icon = this.css({    display: "flex",    width: "2rem",    height: "2rem",    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    borderRadius: `calc(${t.radius} - 2px)`,    backgroundColor: t.muted,    color: t.mutedForeground,  })    .on("&[data-status='success']", { color: t.primary })    .on("&[data-status='error']", { color: t.destructive });  $meta = this.css({ minWidth: 0, flex: 1 });  $name = this.css({ margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 500 });  $status = this.css({ margin: 0, fontSize: "0.75rem", color: t.mutedForeground });  $statusError = this.css({ margin: 0, fontSize: "0.75rem", color: t.destructive });  $iconButton = this.css({    display: "inline-flex",    width: "2rem",    height: "2rem",    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    borderRadius: `calc(${t.radius} - 2px)`,    color: t.mutedForeground,    outline: "none",  })    .hover({ backgroundColor: t.accent, color: t.accentForeground })    .focusVisible({ boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` });  $track = this.css({ height: "0.375rem", width: "100%", overflow: "hidden", borderRadius: "9999px", backgroundColor: t.secondary });  $bar = this.css({ height: "100%", borderRadius: "9999px", backgroundColor: t.primary, transition: "width 120ms ease" });  $summary = this.css({ display: "flex", width: "100%", flexDirection: "column", gap: "0.5rem", fontSize: "0.875rem" });  $summaryHeader = this.css({ display: "flex", alignItems: "center", justifyContent: "space-between" });  $summaryTitle = this.css({ fontWeight: 500 });  $summaryValue = this.css({ color: t.mutedForeground });  $summaryTrack = this.css({ height: "0.5rem", width: "100%", overflow: "hidden", borderRadius: "9999px", backgroundColor: t.secondary });}export interface FileUploadListProps {  upload: FileUpload;  class?: string;}@Component()export class FileUploadList extends StatelessComponent<FileUploadListProps> {  @Styled(FileUploadStyles) $s!: FileUploadStyles;  render() {    const { upload, class: cls } = this.props;    return (      <ul class={cx(this.$s.$list, cls)}>        <For each={() => upload.items} by="id">          {(item) => {            const { id, file } = item();            return (              <li class={this.$s.$row}>                <div class={this.$s.$header}>                  <span data-status={() => item().status} class={this.$s.$icon}>                    {() => {                      const { status } = item();                      return <Icon name={status === "success" ? "CircleCheck" : status === "error" ? "CircleAlert" : "File"} size={16} />;                    }}                  </span>                  <div class={this.$s.$meta}>                    <p class={this.$s.$name}>{file.name}</p>                    <p class={() => (item().status === "error" ? this.$s.$statusError : this.$s.$status)}>                      {formatFileSize(file.size)} · {() => item().error ?? STATUS_LABEL[item().status]}                    </p>                  </div>                  {() =>                    item().status === "uploading" && (                      <button type="button" aria-label={`Cancel ${file.name}`} class={this.$s.$iconButton} onClick={() => { upload.cancel(id); }}>                        <Icon name="X" size={14} />                      </button>                    )                  }                  {() =>                    (item().status === "error" || item().status === "aborted") && (                      <button type="button" aria-label={`Retry ${file.name}`} class={this.$s.$iconButton} onClick={() => { upload.retry(id); }}>                        <Icon name="RotateCw" size={14} />                      </button>                    )                  }                  {() =>                    item().status !== "uploading" && (                      <button type="button" aria-label={`Remove ${file.name}`} class={this.$s.$iconButton} onClick={() => { upload.remove(id); }}>                        <Icon name="Trash2" size={14} />                      </button>                    )                  }                </div>                <div                  role="progressbar"                  aria-valuemin={0}                  aria-valuemax={100}                  aria-valuenow={() => item().progress}                  hidden={() => item().status !== "uploading" && item().status !== "pending"}                  class={this.$s.$track}                >                  <div class={this.$s.$bar} style={() => `width:${String(item().progress)}%`} />                </div>              </li>            );          }}        </For>      </ul>    );  }}export interface FileUploadSummaryProps {  upload: FileUpload;  class?: string;}@Component()export class FileUploadSummary extends StatelessComponent<FileUploadSummaryProps> {  @Styled(FileUploadStyles) $s!: FileUploadStyles;  render() {    const { upload, class: cls } = this.props;    return (      <div class={cx(this.$s.$summary, cls)}>        <div class={this.$s.$summaryHeader}>          <span class={this.$s.$summaryTitle}>            {() => (upload.uploading ? "Uploading…" : `${String(upload.items.length)} file(s)`)}          </span>          <span class={this.$s.$summaryValue}>{() => `${String(upload.progress)}%`}</span>        </div>        <div role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={() => upload.progress} class={this.$s.$summaryTrack}>          <div class={this.$s.$bar} style={() => `width:${String(upload.progress)}%`} />        </div>      </div>    );  }}

Examples

Usage

These components are presentation for the file upload composables in @praxisjs/composables (installed automatically by the CLI). The composable is attached with @Compose in your component, so validation options (accept, maxSize, maxFiles, …) stay yours, and is passed to the Kosmesis component as a prop.

import { FileUpload } from "@praxisjs/composables";
import { FileUploadList, FileUploadSummary } from "@/components/ui/file-upload-list";

@Component()
class Uploader extends StatefulComponent {
  @Compose(FileUpload, { url: "/api/upload", maxSize: 5 * 1024 * 1024, concurrency: 2 })
  upload!: FileUpload;

  render() {
    return (
      <>
        <FileUploadSummary upload={this.upload} />
        <FileUploadList upload={this.upload} />
      </>
    );
  }
}

Add files with upload.add(files) — for example from a File Dropzone's drop.files. Each row shows the status and progress, with cancel while uploading, retry after a failure or cancellation, and remove otherwise.

Props

ComponentPropType
FileUploadListuploadFileUpload — required
FileUploadSummaryuploadFileUpload — required
bothclassstring

On this page