Kosmesis
Components

File Dropzone

A drag-and-drop file area driven by the DropZone composable.

Installation

npx kosmesis add file-dropzone
pnpm dlx kosmesis add file-dropzone
yarn dlx kosmesis add file-dropzone
bunx kosmesis add file-dropzone

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-dropzone.tsx
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/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-dropzone.tsx
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

PropTypeDefault
dropDropZone— required
zoneRefRef<HTMLDivElement>— required
titlestring"Drop files here or click to browse"
descriptionstring—
disabledboolean | (() => boolean)—
classstring—

On this page