Drop Zone
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>.
| 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
- 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 conaria-label, con un<Label htmlFor>alido metiéndolo en unField(toma suFieldLabel, sumaFieldDescriptionyFieldErrora su descripción y sudisabled/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 quedaaria-invalid, con el error en suaria-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,nullindeterminado) y un error de la subida confileError. accept,maxSize(bytes) ymaxFilesvalidan al agregar; lo que no entra queda afuera con su error en línea, no en un toast.validatesuma 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 llevaaria-busymientras tanto; si rechaza, el archivo queda afuera con el mensaje); corre antes del cupo demaxFiles. Sinmultiple, si llega otro archivo antes de que termine, gana el último.actionsRefda unDropZoneHandle:open()abre el selector yfocus()enfoca el recuadro, para un «Elegir archivo» propio o para volver al recuadro después de un paso. Elrefes eldivde afuera, como en 2.0.- Dentro de un
Fieldconname, se registra como su control:Formmanda la lista de archivos con esenameenonFormSubmit, elvalidatedelFieldla recibe y, si queda inválido al enviar,Formenfoca el recuadro. - Los tamaños van en base 1024 (
maxSize={20 * 1024 * 1024}dice «20 MB»);formatSizelos 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. NecesitaDataTransfer(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.