# DropZone

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

```tsx
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">`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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()`).

```tsx
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`.

```tsx
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

### DropZone

Hereda las props de `<div>`.

| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| `accept` | `string` | — | Los tipos que acepta, como el `accept` de un `<input type="file">`: `".pdf,image/*"`. |
| `actionsRef` | `React.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-describedby` | `string` | — | El `id` de una ayuda («PDF o imagen, hasta 5 MB»). Los errores se suman solos. |
| `aria-label` | `string` | — | Nombre accesible del elemento. |
| `aria-labelledby` | `string` | — | El `id` del elemento que nombra el recuadro, si no es un `<label>`. |
| `compact` | `boolean` | `false` | Con 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ó. |
| `disabled` | `boolean` | `false` | Apaga 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. |
| `files` | `File[]` | — | 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». |
| `id` | `string` | — | El `id` del recuadro, para un `<Label htmlFor>`. |
| `labels` | `Partial<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`. |
| `maxFiles` | `number` | — | Cuántos archivos como mucho, con `multiple`. |
| `maxSize` | `number` | — | El tamaño máximo de cada archivo, en bytes. |
| `multiple` | `boolean` | `false` | Má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. |
| `name` | `string` | — | 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. |
| `className` | `string` | — | Se fusiona con las clases del componente vía `cn()` (tailwind-merge): lo que pongas gana. |

## Teclado

| Tecla | Qué hace |
|---|---|
| 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

[progress](/docs/components/progress.md) · [list-row](/docs/components/list-row.md) · [field](/docs/components/field.md)
