File Upload List
Per-file upload rows with progress, cancel, retry and remove, driven by FileUpload.
Installation
npx kosmesis add file-upload-listpnpm dlx kosmesis add file-upload-listyarn dlx kosmesis add file-upload-listbunx kosmesis add file-upload-listInstall the following dependencies:
npm install @praxisjs/composables @morphos/iconspnpm add @praxisjs/composables @morphos/iconsyarn add @praxisjs/composables @morphos/iconsbun add @praxisjs/composables @morphos/iconsCopy and paste the following code into your project.
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/csspnpm add @praxisjs/composables @morphos/icons @praxisjs/cssyarn add @praxisjs/composables @morphos/icons @praxisjs/cssbun add @praxisjs/composables @morphos/icons @praxisjs/cssCopy and paste the following code into your project.
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
| Component | Prop | Type |
|---|---|---|
FileUploadList | upload | FileUpload — required |
FileUploadSummary | upload | FileUpload — required |
| both | class | string |