Ir al contenido
Formularios

DropZone

Un recuadro para soltar o elegir archivos, con la lista abajo: miniatura, nombre, tamaño, el progreso que pase la app y quitar.

import { DropZone } from "sebs7n-ui/drop-zone"

Ejemplos

Comprobantes de un pago

PDF o imagen, hasta 5 MB y tres archivos. Con `name`, los archivos viajan con el `<form>` como un `<input type="file">`.

import { Button, Label } from "sebs7n-ui"
import { DropZone } from "sebs7n-ui/drop-zone"
import { useState } from "react"

function Attachments() {
  const [sent, setSent] = useState<string | null>(null)
  return (
    <form
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const files = new FormData(event.currentTarget).getAll("receipts") as File[]
        setSent(files.map((file) => file.name).join(", ") || "ningún archivo")
      }}
    >
      <Label htmlFor="receipts">Comprobantes</Label>
      <DropZone accept=".pdf,image/*" id="receipts" maxFiles={3} maxSize={5 * 1024 * 1024} multiple name="receipts" />
      <Button className="self-start" type="submit">
        Enviar
      </Button>
      {sent && <p className="text-callout text-label-secondary">Se enviaría: {sent}</p>}
    </form>
  )
}

Con progreso

La subida es de la app: acá se simula y `fileProgress` le pasa a cada fila su porcentaje.

import { DropZone } from "sebs7n-ui/drop-zone"
import { useEffect, useState } from "react"

function WithProgress() {
  const [files, setFiles] = useState<File[]>([])
  const [progress, setProgress] = useState(new Map<File, number>())
  useEffect(() => {
    const pending = files.filter((file) => (progress.get(file) ?? 0) < 100)
    if (!pending.length) return
    const timer = setTimeout(() => {
      setProgress((current) => {
        const next = new Map(current)
        for (const file of pending) next.set(file, Math.min(100, (next.get(file) ?? 0) + 20))
        return next
      })
    }, 400)
    return () => clearTimeout(timer)
  }, [files, progress])
  return (
    <DropZone
      aria-label="Facturas para importar"
      className="w-full max-w-md"
      fileProgress={(file) => progress.get(file) ?? 0}
      files={files}
      multiple
      onFilesChange={setFiles}
    />
  )
}

La ventana entera

`scope="window"`: mientras se arrastra un archivo, toda la ventana es la zona. Probalo arrastrando un PDF sobre la página.

import { DropZone } from "sebs7n-ui/drop-zone"

function WholeWindow() {
  return <DropZone accept=".pdf" aria-label="Factura para convertir" className="w-full max-w-md" scope="window" />
}

Validación propia

`validate` corre después de tipo y tamaño; puede ser asíncrona. Acá lee los primeros bytes: una imagen renombrada a `.pdf` queda afuera con su error en línea.

import { DropZone } from "sebs7n-ui/drop-zone"

function Validate() {
  return <DropZone accept=".pdf" aria-label="Facturas en PDF" className="w-full max-w-md" multiple validate={isPdf} />
}

Compacta

`compact`: con un archivo elegido, el recuadro grande pasa a una fila «Elegir otro» y el protagonista es el archivo. El botón de abajo abre el selector con `actionsRef` (`open()`).

import { Button } from "sebs7n-ui"
import { DropZone, type DropZoneHandle } from "sebs7n-ui/drop-zone"
import { useRef } from "react"

function Compact() {
  const zone = useRef<DropZoneHandle>(null)
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <DropZone accept=".pdf" aria-label="Factura para convertir" actionsRef={zone} compact />
      {/* El `ref` abre el selector desde un botón propio (`open()`) o vuelve al recuadro (`focus()`). */}
      <Button className="self-start" onClick={() => zone.current?.open()} type="button" variant="secondary">
        Elegir PDF…
      </Button>
    </div>
  )
}

Dentro de un Field

La etiqueta, la ayuda y el error salen del `Field`, como en cualquier otro campo: el recuadro se nombra con el `FieldLabel`.

import { DropZone } from "sebs7n-ui/drop-zone"
import { Field, FieldDescription, FieldLabel } from "sebs7n-ui"

function InField() {
  return (
    <Field className="w-full max-w-md">
      <FieldLabel>Comprobante de pago</FieldLabel>
      <DropZone accept=".pdf,image/*" compact maxSize={5 * 1024 * 1024} />
      <FieldDescription>PDF o imagen, hasta 5 MB.</FieldDescription>
    </Field>
  )
}

Props

Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».

DropZone

Hereda las props de <div>.

PropTipoPor defectoDescripción
acceptstring—Los tipos que acepta, como el accept de un <input type="file">: ".pdf,image/*".
actionsRefReact.Ref<DropZoneHandle>—open() y focus(), para un botón «Elegir archivo» propio o para volver al recuadro después de un paso. Es actionsRef y no ref, como en Base UI: el ref sigue siendo el div de afuera.
aria-describedbystring—El id de una ayuda («PDF o imagen, hasta 5 MB»). Los errores se suman solos.
aria-labelstring—Nombre accesible del elemento.
aria-labelledbystring—El id del elemento que nombra el recuadro, si no es un <label>.
compactbooleanfalseCon archivos, el recuadro grande pasa a una fila chica: «Elegir otro» (o «Agregar más» con multiple). Para una pantalla donde el archivo es el protagonista y el recuadro ya cumplió.
disabledbooleanfalseApaga el recuadro y los botones de quitar.
fileError(file: File) => React.ReactNode—Un error de la app para un archivo («No se pudo subir»), en rojo en su fila.
fileProgress(file: File) => number—El progreso de cada archivo, de 0 a 100 (null, indeterminado). Sin valor, no hay barra.
filesFile[]—Los archivos, controlado. Sin files, el componente los guarda.
formatSize(bytes: number) => string—Cómo se escribe un tamaño, en la lista y en el error de maxSize. Por defecto, en base 1024 con las unidades de Intl en el idioma de labels.locale: «1,3 MB».
idstring—El id del recuadro, para un <Label htmlFor>.
labelsPartial<DropZoneLabels>—Textos: prompt, drop, remove, added, removed, invalidType, tooLarge, tooMany, locale (el de los tamaños), replace y addMore (la fila de compact). Los que vienen por defecto son dropZoneLabels.
maxFilesnumber—Cuántos archivos como mucho, con multiple.
maxSizenumber—El tamaño máximo de cada archivo, en bytes.
multiplebooleanfalseMás de un archivo. Sin multiple, uno nuevo reemplaza al anterior y gana la última tanda: si una elección (o un soltar) termina de validar después de otra más nueva, se descarta.
namestring—El nombre del campo en el <form>: los archivos viajan en un <input type="file">. Para que el input tenga los archivos de la lista (y no los del último diálogo) hace falta DataTransfer, el único modo de armar un FileList. Donde no existe (navegadores muy viejos, algunos entornos de test), el input queda como lo dejó el diálogo: ahí, mandá files a mano con onFilesChange en vez de confiar en el <form>.
onFilesChange(files: File[]) => void—Se llama con la lista entera cada vez que se agrega o se quita uno.
scope"area" | "window""area""window": mientras se arrastra un archivo, toda la ventana es la zona.
validate(file: File) => string | Promise<string>—Una validación propia por archivo, después de tipo y tamaño y antes del cupo de maxFiles: el texto que devuelve es el error de ese archivo («factura.pdf no es un PDF»), en línea como los otros, y el archivo no entra ni se anuncia. Puede ser asíncrona, para leer los bytes («%PDF-»): mientras tanto el recuadro lleva aria-busy, y si la promesa se rechaza, el archivo queda afuera con el mensaje del error. Sin multiple, si llega otro archivo antes de que termine, gana el último.
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

Teclado

Tab
Entra al recuadro y después a cada «Quitar».
Enter · Espacio
En el recuadro, abren el selector de archivos del sistema.

Accesibilidad

  • El recuadro es un <button>: nombralo con aria-label, con un <Label htmlFor> al id o metiéndolo en un Field (toma su FieldLabel, suma FieldDescription y FieldError a su descripción y su disabled/invalid). El <input type="file"> de adentro está fuera del orden de Tab.
  • Los errores de tipo, tamaño y cantidad aparecen abajo con role="alert" y el recuadro queda aria-invalid, con el error en su aria-describedby.
  • Agregar y quitar se anuncian por una región viva («Archivos agregados: factura.pdf»). Al quitar, el foco vuelve al recuadro.
  • El botón de quitar se llama «Quitar factura.pdf»; la miniatura es decorativa (alt=""). Cada barra de progreso se llama como su archivo.
  • Con compact, el nombre de la fila chica empieza con su texto visible («Elegir otro, Factura»), para el control por voz (WCAG 2.5.3).

Reglas de uso

  • No sube nada: la red es de la app. Pasale el progreso de cada archivo con fileProgress (de 0 a 100, null indeterminado) y un error de la subida con fileError.
  • accept, maxSize (bytes) y maxFiles validan al agregar; lo que no entra queda afuera con su error en línea, no en un toast.
  • validate suma una validación propia por archivo (leer los bytes %PDF-, pedir un mínimo): el texto que devuelve es el error en línea de ese archivo y el archivo no entra. Puede ser asíncrona (el recuadro lleva aria-busy mientras tanto; si rechaza, el archivo queda afuera con el mensaje); corre antes del cupo de maxFiles. Sin multiple, si llega otro archivo antes de que termine, gana el último.
  • actionsRef da un DropZoneHandle: open() abre el selector y focus() enfoca el recuadro, para un «Elegir archivo» propio o para volver al recuadro después de un paso. El ref es el div de afuera, como en 2.0.
  • Dentro de un Field con name, se registra como su control: Form manda la lista de archivos con ese name en onFormSubmit, el validate del Field la recibe y, si queda inválido al enviar, Form enfoca el recuadro.
  • Los tamaños van en base 1024 (maxSize={20 * 1024 * 1024} dice «20 MB»); formatSize los escribe de otra forma, en la lista y en el error.
  • Con name, los archivos viajan con el <form> en un <input type="file">, también los que llegaron arrastrando: sirve igual para una Server Action. Necesita DataTransfer (todos los navegadores actuales): sin él, el input queda como lo dejó el diálogo.
  • scope="window": mientras se arrastra un archivo, toda la ventana es la zona (una app de una sola tarea, como un conversor). Deshabilitada sigue montada como guardia: soltar un archivo no lo abre en la pestaña (el cursor dice que ahí no se suelta).
  • Sin multiple, uno nuevo reemplaza al anterior y gana la última tanda (si una elección termina de validar después de otra más nueva, se descarta); si se sueltan varios, entra el primero y el resto se avisa. accept="*/*" acepta todo. Solo por subpath (sebs7n-ui/drop-zone): no está en el barrel, por peso.

Relacionados