File List
Selected-file list, thumbnail grid and validation errors, with image previews via FilePreview.
Installation
npx kosmesis add file-listpnpm dlx kosmesis add file-listyarn dlx kosmesis add file-listbunx kosmesis add file-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 { FilePreview, formatFileSize, type FileError } from "@praxisjs/composables";import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, Compose, getter, Prop } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface FileListSource { files: File[]; remove: (file: File) => void;}export interface FileThumbnailProps { file: File; class?: string;}@Component()export class FileThumbnail extends StatefulComponent { @Prop() file!: File; @Prop() class?: string; @Compose(FilePreview, getter("file")) preview!: FilePreview; render() { return ( <span class={cn("flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted text-muted-foreground", this.class)} > {() => this.file.type.startsWith("image/") && this.preview.url ? ( <img src={this.preview.url} alt={this.file.name} class="size-full object-cover" /> ) : ( <Icon name="File" size={18} /> ) } </span> ); }}export interface FileListProps { source: FileListSource; class?: string;}@Component()export class FileList extends StatelessComponent<FileListProps> { render() { const { source, class: cls } = this.props; return ( <ul class={cn("flex w-full flex-col gap-2", cls)}> <For each={() => source.files} by={(file) => `${file.name}-${String(file.lastModified)}-${String(file.size)}`}> {(file) => ( <li class="flex items-center gap-3 rounded-md border p-2 text-sm"> <FileThumbnail file={file()} /> <div class="min-w-0 flex-1"> <p class="truncate font-medium">{file().name}</p> <p class="text-xs text-muted-foreground">{formatFileSize(file().size)}</p> </div> <button type="button" aria-label={`Remove ${file().name}`} class="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" onClick={() => { source.remove(file()); }} > <Icon name="X" size={14} /> </button> </li> )} </For> </ul> ); }}export interface FileThumbnailGridProps { source: FileListSource; class?: string;}@Component()export class FileThumbnailGrid extends StatelessComponent<FileThumbnailGridProps> { render() { const { source, class: cls } = this.props; return ( <ul class={cn("grid grid-cols-3 gap-2 sm:grid-cols-4", cls)}> <For each={() => source.files} by={(file) => `${file.name}-${String(file.lastModified)}-${String(file.size)}`}> {(file) => ( <li class="group relative aspect-square"> <FileThumbnail file={file()} class="size-full" /> <button type="button" aria-label={`Remove ${file().name}`} class="absolute right-1 top-1 inline-flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground opacity-0 shadow-xs outline-none transition-opacity hover:bg-background focus-visible:opacity-100 group-hover:opacity-100" onClick={() => { source.remove(file()); }} > <Icon name="X" size={12} /> </button> </li> )} </For> </ul> ); }}export interface FileErrorsProps { source: { errors: FileError[] }; class?: string;}@Component()export class FileErrors extends StatelessComponent<FileErrorsProps> { render() { const { source, class: cls } = this.props; return ( <ul role="alert" class={cn("flex flex-col gap-1 text-sm text-destructive empty:hidden", cls)}> <For each={() => source.errors} by="message"> {(error) => <li>{error().message}</li>} </For> </ul> ); }}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 { FilePreview, formatFileSize, type FileError } from "@praxisjs/composables";import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Compose, getter, Prop } from "@praxisjs/decorators";import { For } from "@praxisjs/runtime";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class FileListStyles extends Stylesheet { $thumb = this.css({ display: "flex", width: "2.5rem", height: "2.5rem", flexShrink: 0, alignItems: "center", justifyContent: "center", overflow: "hidden", borderRadius: `calc(${t.radius} - 2px)`, border: `1px solid ${t.border}`, backgroundColor: t.muted, color: t.mutedForeground, }); $thumbImg = this.css({ width: "100%", height: "100%", objectFit: "cover" }); $thumbFill = this.css({ width: "100%", height: "100%" }); $list = this.css({ display: "flex", width: "100%", flexDirection: "column", gap: "0.5rem", margin: 0, padding: 0, listStyle: "none" }); $row = this.css({ display: "flex", alignItems: "center", gap: "0.75rem", borderRadius: `calc(${t.radius} - 2px)`, border: `1px solid ${t.border}`, padding: "0.5rem", fontSize: "0.875rem", }); $meta = this.css({ minWidth: 0, flex: 1 }); $name = this.css({ margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 500 }); $size = this.css({ margin: 0, fontSize: "0.75rem", color: t.mutedForeground }); $removeButton = 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)` }); $grid = this.css({ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "0.5rem", margin: 0, padding: 0, listStyle: "none" }); $cell = this.css({ position: "relative", aspectRatio: "1 / 1" }).on("&:hover > button, & > button:focus-visible", { opacity: 1 }); $overlayRemove = this.css({ position: "absolute", top: "0.25rem", right: "0.25rem", display: "inline-flex", width: "1.5rem", height: "1.5rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: `color-mix(in oklab, ${t.background} 90%, transparent)`, color: t.foreground, boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", outline: "none", opacity: 0, transition: "opacity 120ms ease", }).hover({ backgroundColor: t.background }); $errors = this.css({ display: "flex", flexDirection: "column", gap: "0.25rem", margin: 0, padding: 0, listStyle: "none", fontSize: "0.875rem", color: t.destructive }) .on("&:empty", { display: "none" });}export interface FileListSource { files: File[]; remove: (file: File) => void;}export interface FileThumbnailProps { file: File; class?: string;}@Component()export class FileThumbnail extends StatefulComponent { @Styled(FileListStyles) $s!: FileListStyles; @Prop() file!: File; @Prop() class?: string; @Compose(FilePreview, getter("file")) preview!: FilePreview; render() { return ( <span class={cx(this.$s.$thumb, this.class)}> {() => this.file.type.startsWith("image/") && this.preview.url ? ( <img src={this.preview.url} alt={this.file.name} class={this.$s.$thumbImg} /> ) : ( <Icon name="File" size={18} /> ) } </span> ); }}export interface FileListProps { source: FileListSource; class?: string;}@Component()export class FileList extends StatelessComponent<FileListProps> { @Styled(FileListStyles) $s!: FileListStyles; render() { const { source, class: cls } = this.props; return ( <ul class={cx(this.$s.$list, cls)}> <For each={() => source.files} by={(file) => `${file.name}-${String(file.lastModified)}-${String(file.size)}`}> {(file) => ( <li class={this.$s.$row}> <FileThumbnail file={file()} /> <div class={this.$s.$meta}> <p class={this.$s.$name}>{file().name}</p> <p class={this.$s.$size}>{formatFileSize(file().size)}</p> </div> <button type="button" aria-label={`Remove ${file().name}`} class={this.$s.$removeButton} onClick={() => { source.remove(file()); }}> <Icon name="X" size={14} /> </button> </li> )} </For> </ul> ); }}export interface FileThumbnailGridProps { source: FileListSource; class?: string;}@Component()export class FileThumbnailGrid extends StatelessComponent<FileThumbnailGridProps> { @Styled(FileListStyles) $s!: FileListStyles; render() { const { source, class: cls } = this.props; return ( <ul class={cx(this.$s.$grid, cls)}> <For each={() => source.files} by={(file) => `${file.name}-${String(file.lastModified)}-${String(file.size)}`}> {(file) => ( <li class={this.$s.$cell}> <FileThumbnail file={file()} class={this.$s.$thumbFill} /> <button type="button" aria-label={`Remove ${file().name}`} class={this.$s.$overlayRemove} onClick={() => { source.remove(file()); }}> <Icon name="X" size={12} /> </button> </li> )} </For> </ul> ); }}export interface FileErrorsProps { source: { errors: FileError[] }; class?: string;}@Component()export class FileErrors extends StatelessComponent<FileErrorsProps> { @Styled(FileListStyles) $s!: FileListStyles; render() { const { source, class: cls } = this.props; return ( <ul role="alert" class={cx(this.$s.$errors, cls)}> <For each={() => source.errors} by="message"> {(error) => <li>{error().message}</li>} </For> </ul> ); }}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 { FileErrors, FileList, FileThumbnailGrid } from "@/components/ui/file-list";
<FileErrors source={this.selection} />
<FileList source={this.selection} />
<FileThumbnailGrid source={this.selection} />source is anything with files and remove(file) — a FileSelection or a DropZone. Image
thumbnails are produced with the FilePreview composable, which revokes each object URL when the file
is removed or the component unmounts. FileErrors reads errors from the same source.
Props
| Component | Prop | Type |
|---|---|---|
FileList / FileThumbnailGrid | source | { files: File[]; remove: (file: File) => void } |
FileErrors | source | { errors: FileError[] } |
FileThumbnail | file | File |
| all | class | string |