File Dropzone
A drag-and-drop file area driven by the DropZone composable.
Installation
npx kosmesis add file-dropzonepnpm dlx kosmesis add file-dropzoneyarn dlx kosmesis add file-dropzonebunx kosmesis add file-dropzoneInstall 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 type { DropZone } from "@praxisjs/composables";import { StatelessComponent } from "@praxisjs/core";import { Component, type Ref } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface FileDropzoneProps { drop: DropZone; /** The same `@Ref()` the `DropZone` composable was given — it needs the rendered element to attach its drag listeners. */ zoneRef: Ref<HTMLDivElement>; title?: string; description?: string; /** Pass a getter (`() => busy`) to toggle it at runtime — a plain value changing re-creates the component and drops the `DropZone` listeners. */ disabled?: boolean | (() => boolean); class?: string; children?: unknown;}@Component()export class FileDropzone extends StatelessComponent<FileDropzoneProps> { render() { const { drop, zoneRef, title = "Drop files here or click to browse", description, disabled, class: cls, children, } = this.props; const isDisabled = (): boolean => (typeof disabled === "function" ? disabled() : (disabled ?? false)); // Registered before `DropZone`'s own listeners, so stopping propagation here keeps it from adding files. const block = (e: DragEvent) => { if (!isDisabled()) return; e.preventDefault(); e.stopImmediatePropagation(); }; return ( <div ref={zoneRef} role="button" tabIndex={() => (isDisabled() ? -1 : 0)} aria-disabled={() => isDisabled()} data-dragging={() => (drop.dragging ? "" : undefined)} data-disabled={() => (isDisabled() ? "" : undefined)} class={cn( "flex min-h-32 w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-transparent px-6 py-8 text-center text-sm outline-none transition-[color,background-color,border-color,box-shadow]", "hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "data-dragging:border-primary data-dragging:bg-accent", "data-disabled:pointer-events-none data-disabled:opacity-50", cls, )} onDragEnter={block} onDragOver={block} onDrop={block} onClick={() => { if (!isDisabled()) drop.open(); }} onKeyDown={(e: KeyboardEvent) => { if (isDisabled() || (e.key !== "Enter" && e.key !== " ")) return; e.preventDefault(); drop.open(); }} > <span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground"> <Icon name="Upload" size={18} /> </span> <p class="font-medium">{title}</p> {description && <p class="text-xs text-muted-foreground">{description}</p>} {children} </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 type { DropZone } from "@praxisjs/composables";import { StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, type Ref } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class FileDropzoneStyles extends Stylesheet { $root = this.css({ display: "flex", minHeight: "8rem", width: "100%", cursor: "pointer", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "0.5rem", borderRadius: `calc(${t.radius} - 2px)`, border: `1px dashed ${t.input}`, backgroundColor: "transparent", padding: "2rem 1.5rem", textAlign: "center", fontSize: "0.875rem", outline: "none", transition: "color 120ms ease, background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease", }) .hover({ backgroundColor: `color-mix(in oklab, ${t.accent} 50%, transparent)` }) .focusVisible({ borderColor: t.ring, boxShadow: `0 0 0 3px color-mix(in oklab, ${t.ring} 50%, transparent)` }) .on("&[data-dragging]", { borderColor: t.primary, backgroundColor: t.accent }) .on("&[data-disabled]", { pointerEvents: "none", opacity: 0.5 }); $iconWrap = this.css({ display: "flex", width: "2.5rem", height: "2.5rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", backgroundColor: t.muted, color: t.mutedForeground, }); $title = this.css({ margin: 0, fontWeight: 500 }); $description = this.css({ margin: 0, fontSize: "0.75rem", color: t.mutedForeground });}export interface FileDropzoneProps { drop: DropZone; /** The same `@Ref()` the `DropZone` composable was given — it needs the rendered element to attach its drag listeners. */ zoneRef: Ref<HTMLDivElement>; title?: string; description?: string; /** Pass a getter (`() => busy`) to toggle it at runtime — a plain value changing re-creates the component and drops the `DropZone` listeners. */ disabled?: boolean | (() => boolean); class?: string; children?: unknown;}@Component()export class FileDropzone extends StatelessComponent<FileDropzoneProps> { @Styled(FileDropzoneStyles) $s!: FileDropzoneStyles; render() { const { drop, zoneRef, title = "Drop files here or click to browse", description, disabled, class: cls, children, } = this.props; const isDisabled = (): boolean => (typeof disabled === "function" ? disabled() : (disabled ?? false)); // Registered before `DropZone`'s own listeners, so stopping propagation here keeps it from adding files. const block = (e: DragEvent) => { if (!isDisabled()) return; e.preventDefault(); e.stopImmediatePropagation(); }; return ( <div ref={zoneRef} role="button" tabIndex={() => (isDisabled() ? -1 : 0)} aria-disabled={() => isDisabled()} data-dragging={() => (drop.dragging ? "" : undefined)} data-disabled={() => (isDisabled() ? "" : undefined)} class={cx(this.$s.$root, cls)} onDragEnter={block} onDragOver={block} onDrop={block} onClick={() => { if (!isDisabled()) drop.open(); }} onKeyDown={(e: KeyboardEvent) => { if (isDisabled() || (e.key !== "Enter" && e.key !== " ")) return; e.preventDefault(); drop.open(); }} > <span class={this.$s.$iconWrap}> <Icon name="Upload" size={18} /> </span> <p class={this.$s.$title}>{title}</p> {description && <p class={this.$s.$description}>{description}</p>} {children} </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 { DropZone } from "@praxisjs/composables";
import { Compose, Ref, type Ref as RefType } from "@praxisjs/decorators";
import { FileDropzone } from "@/components/ui/file-dropzone";
import { FileErrors, FileList } from "@/components/ui/file-list";
@Component()
class Attachments extends StatefulComponent {
@Ref<HTMLDivElement>() zoneRef!: RefType<HTMLDivElement>;
@Compose(DropZone, "zoneRef", { accept: [".pdf", "image/*"], maxFiles: 5 })
drop!: DropZone;
render() {
return (
<>
<FileDropzone drop={this.drop} zoneRef={this.zoneRef} description="PDF or images" />
<FileErrors source={this.drop} />
<FileList source={this.drop} />
</>
);
}
}DropZone attaches its drag listeners to the element it is given, so zoneRef must be the same
@Ref() you passed to @Compose. Clicking the zone (or pressing Enter / Space) opens the native
dialog; data-dragging is set while files are dragged over it.
To disable the zone at runtime (for example while uploading), pass a getter: disabled={() => this.upload.uploading}.
A plain boolean that changes re-creates the component, which drops the DropZone drag listeners. A disabled
zone also ignores dropped files.
Props
| Prop | Type | Default |
|---|---|---|
drop | DropZone | — required |
zoneRef | Ref<HTMLDivElement> | — required |
title | string | "Drop files here or click to browse" |
description | string | — |
disabled | boolean | (() => boolean) | — |
class | string | — |