File Drop

Styled file input with drag-and-drop support, built on the @foldkit/ui FileDrop submodel.

Preview
Style
src/demo/views/file-drop.ts114 lines
import { Array } from 'effect'
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { File } from 'foldkit'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as fileDrop from '../../generated/registry/ui/file-drop'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotFileDropMessage: { message: fileDrop.Message },
  ClickedRemoveFile: { fileIndex: S.Number },
})

export const fileDropView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('w-full max-w-md')],
    [
      h.submodel({
        slotId: model.fileDrop.id,
        model: model.fileDrop,
        view: fileDrop.view,
        viewInputs: fileDrop.styledViewInputs(
          {
            multiple: true,
            accept: ['image/*'],
            content: [
              h.span([h.Class('text-base font-medium')], ['Drag and drop files here']),
              h.span(
                [h.Class('text-sm text-muted-foreground')],
                ['or click to browse — up to a few MB each.'],
              ),
            ],
          },
          h,
        ),
        toParentMessage: (message) => Message.GotFileDropMessage({ message }),
      }),
      ...model.fileDropFiles.map((_, index) =>
        h.div(
          [
            h.Class(
              'mt-2 flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2',
            ),
          ],
          [
            h.span([h.Class('truncate text-sm font-medium')], [`File ${index + 1}`]),
            h.button(
              [
                h.Class('text-sm text-muted-foreground transition-colors hover:text-destructive'),
                h.OnClick(Message.ClickedRemoveFile({ fileIndex: index })),
              ],
              ['Remove'],
            ),
          ],
        ),
      ),
    ],
  )

const foldNoOp =
  (): ((out: fileDrop.OutMessage) => Update.Step<State, unknown>) => () => (model) => ({ model })

const foldFileDropOutMessage = M.type<fileDrop.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    ReceivedFiles:
      ({ files }) =>
      (model) => ({
        model: evo(model, { fileDropFiles: () => [...model.fileDropFiles, ...files] }),
      }),
    RejectedNonFiles: foldNoOp(),
  }),
)

const foldFileDrop = Update.foldChild({
  update: fileDrop.update,
  read: (model: State) => Option.some(model.fileDrop),
  write: (model, next) => evo(model, { fileDrop: () => next }),
  toParentMessage: (message) => Message.GotFileDropMessage({ message }),
  foldOutMessage: foldFileDropOutMessage,
})

const fields = {
  fileDrop: fileDrop.Model,
  fileDropFiles: S.Array(File.File),
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    fileDrop: fileDrop.init({ id: 'file-drop-demo' }),
    fileDropFiles: [],
  },
  messages: [Message.GotFileDropMessage, Message.ClickedRemoveFile],
  handlers: (model: State) => ({
    GotFileDropMessage: (payload: typeof Message.GotFileDropMessage.Type): UpdateReturn =>
      foldFileDrop(model, payload.message),
    ClickedRemoveFile: ({ fileIndex }: typeof Message.ClickedRemoveFile.Type): UpdateReturn => ({
      model: evo(model, {
        fileDropFiles: () => Array.remove(model.fileDropFiles, fileIndex),
      }),
    }),
  }),
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/file-drop

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/file-drop.ts72 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as FileDrop from '@/components/ui/file-drop'`
 */
import { FileDrop as FoldkitFileDrop } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

import { cn } from '@/lib/utils'

// Re-export the @foldkit/ui FileDrop submodel surface.

export const init = FoldkitFileDrop.init
export const update = FoldkitFileDrop.update
export const view = FoldkitFileDrop.view
export const Model = FoldkitFileDrop.Model
export type Model = typeof Model.Type
export const Message = FoldkitFileDrop.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitFileDrop.OutMessage
export type OutMessage = typeof OutMessage.Type

export type InitConfig = FoldkitFileDrop.InitConfig
export type ViewInputs = FoldkitFileDrop.ViewInputs
export type FileDropAttributes = FoldkitFileDrop.FileDropAttributes

export const fileDropClass =
  'group/file-drop flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border bg-card px-6 py-10 text-center text-card-foreground outline-none transition-colors hover:border-primary/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[drag-over]:border-primary data-[drag-over]:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'

export const fileDropPrimaryTextClass = 'text-base font-medium'

export const fileDropSecondaryTextClass = 'text-sm text-muted-foreground'

export const fileRowClass =
  'group/file-row flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2'

export const fileNameClass = 'truncate text-sm font-medium'

export const fileSizeClass = 'text-xs text-muted-foreground'

export const fileRemoveButtonClass =
  'inline-flex items-center justify-center rounded-md text-sm text-muted-foreground outline-none transition-colors hover:text-destructive focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

export type StyledViewInputs = Readonly<{
  multiple?: boolean
  isDisabled?: boolean
  accept?: ReadonlyArray<string>
  /** Drop zone content (hint text, etc.). */
  content: ReadonlyArray<Child>
  className?: string
}>

/** Build styled `FileDrop.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs,
  h: HtmlBuilder<M>,
): ViewInputs => ({
  multiple: viewInputs.multiple,
  isDisabled: viewInputs.isDisabled,
  accept: viewInputs.accept,
  toView: (attributes) =>
    h.label(
      [
        ...attributes.root,
        h.DataAttribute('slot', 'file-drop'),
        h.Class(cn(fileDropClass, viewInputs.className)),
      ],
      [...viewInputs.content, h.input(attributes.input)],
    ),
})