HextaUI

BetaHecho sobre el UI SDK 0.1.0-beta.2 de Vercel. Su API puede cambiar.

File input

Un campo de archivos sobre el UI SDK de Vercel con zona para soltar, pegado, comprobación de tipo y tamaño, archivos rechazados con motivos, vistas previas de imágenes y soporte de formularios.

Depende de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority

Drop files here

Images or PDFs, up to 5 files of 5 MB each. You can paste them too.

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputDemo() {
  return (
    <FileInput
      multiple
      accept={["image/*", ".pdf"]}
      maxFiles={5}
      maxSize={5_000_000}
      className="max-w-md"
    >
      <FileInputDropzone>
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Drop files here</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Images or PDFs, up to 5 files of 5 MB each. You can paste them too.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="Files">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-file-input.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

UI SDK añade un poco de CSS propio en una capa de cascada llamada ui-sdk. Declara esa capa al principio de tu hoja de estilos global, encima del import de Tailwind, para que tus clases ganen.

@layer ui-sdk;

@import "tailwindcss";
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"
<FileInput multiple accept="image/*" maxSize={5_000_000}>
  <FileInputDropzone>
    <FileInputDropzoneIcon />
    <FileInputDropzoneTitle>Drop files here</FileInputDropzoneTitle>
    <FileInputTrigger>Browse files</FileInputTrigger>
  </FileInputDropzone>
  <FileInputRejections />
  <FileInputList aria-label="Files">
    <FileInputItem />
  </FileInputList>
</FileInput>

El campo guarda los archivos que la gente elige, suelta o pega, y los envía con un formulario. Subirlos le corresponde a tu app. FileInputItem sin hijos dibuja una fila terminada con vista previa, nombre, tamaño y botón para quitar, una vez por cada archivo de la lista.

Las comprobaciones de accept, tamaños, cantidades y validate solo corren en el navegador, para decir pronto por qué se dejó fuera un archivo. Cualquiera puede saltárselas, así que vuelve a comprobar cada subida en tu servidor.

FileInput
├── FileInputTrigger
├── FileInputDropzone
│   ├── FileInputDropzoneIcon
│   ├── FileInputDropzoneTitle
│   ├── FileInputDropzoneDescription
│   └── FileInputTrigger
├── FileInputRejections
└── FileInputList
    └── FileInputItem
        ├── FileInputItemPreview
        ├── FileInputItemContent
        │   ├── FileInputItemName
        │   └── FileInputItemSize
        └── FileInputItemRemove

accept admite tipos MIME, comodines y extensiones. maxFiles, minSize y maxSize fijan los límites, y validate añade comprobaciones propias. FileInputRejections lista cada archivo que quedó fuera con todos sus motivos, hasta el siguiente archivo aceptado.

"use client"

import * as React from "react"

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
  createFileInputController,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputLimits() {
  const [files] = React.useState(createFileInputController)

  return (
    <FileInput
      multiple
      controller={files}
      accept=".pdf"
      maxFiles={3}
      minSize={1}
      maxSize={2_000_000}
      validate={(file) =>
        files.getValue().some((other) => other.name === file.name)
          ? "Already added"
          : null
      }
      className="max-w-md"
    >
      <FileInputDropzone className="min-h-32">
        <FileInputDropzoneTitle>Drop up to 3 PDFs</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          2 MB each, no empty files and no duplicates.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Choose PDFs</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="PDFs">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputLimits }

FileInputItemPreview muestra una imagen desde una URL de objeto que revoca cuando el archivo se va, y un icono para otros tipos. Con layout="grid", cada archivo es un mosaico cuadrado.

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputImages() {
  return (
    <FileInput
      multiple
      accept="image/*"
      maxFiles={6}
      maxSize={10_000_000}
      className="max-w-md"
    >
      <FileInputDropzone className="min-h-32">
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Add photos</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Up to 6 images, 10 MB each.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Choose photos</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="Photos" layout="grid">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputImages }

Un disparador solo abre el selector de archivos del sistema. Sin multiple, un archivo nuevo sustituye al actual, y los hijos de la raíz pueden ser una función de su estado.

"use client"

import { IconUpload } from "@tabler/icons-react"

import { FileInput, FileInputTrigger } from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputButton() {
  return (
    <FileInput accept=".pdf,.doc,.docx">
      {(state) => (
        <div className="flex min-w-0 items-center gap-3">
          <FileInputTrigger>
            <IconUpload aria-hidden="true" />
            {state.value[0] ? "Replace resume" : "Upload resume"}
          </FileInputTrigger>
          <span className="min-w-0 truncate text-sm text-muted-foreground">
            {state.value[0]?.name ?? "No file chosen"}
          </span>
        </div>
      )}
    </FileInput>
  )
}

export { UiSdkFileInputButton }

Con variant="composer", la zona para soltar es la tarjeta alrededor de un cuadro de mensaje, y layout="chips" lista los archivos como chips compactos. pasteTarget toma las capturas pegadas en el área de texto, mientras el texto pegado sigue siendo texto.

"use client"

import * as React from "react"
import { IconArrowUp, IconPaperclip } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputAttachments() {
  const message = React.useRef<HTMLTextAreaElement>(null)

  return (
    <FileInput multiple maxFiles={4} maxSize={20_000_000} className="max-w-lg">
      <FileInputDropzone
        variant="composer"
        pasteTarget={() => message.current}
      >
        <FileInputList aria-label="Attachments" layout="chips">
          <FileInputItem />
        </FileInputList>
        <textarea
          ref={message}
          aria-label="Message"
          placeholder="Ask anything, or paste a screenshot"
          rows={2}
          className="field-sizing-content max-h-40 min-h-12 w-full resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground pointer-coarse:text-touch"
        />
        <div className="flex items-center justify-between">
          <FileInputTrigger
            variant="ghost"
            size="icon-sm"
            aria-label="Attach files"
          >
            <IconPaperclip aria-hidden="true" />
          </FileInputTrigger>
          <Button type="button" size="icon-sm" shape="pill" aria-label="Send">
            <IconArrowUp aria-hidden="true" />
          </Button>
        </div>
      </FileInputDropzone>
      <FileInputRejections />
    </FileInput>
  )
}

export { UiSdkFileInputAttachments }

Con target="document" y variant="overlay", la zona cubre la página mientras se arrastran archivos sobre la ventana, y toma los archivos soltados en cualquier parte. Lo que se suelta en otra zona se queda en esa zona.

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputPageDrop() {
  return (
    <FileInput multiple className="max-w-md">
      <div className="flex items-center justify-between gap-3">
        <p className="m-0 text-sm text-muted-foreground">
          Drop files anywhere on this page.
        </p>
        <FileInputTrigger>Upload files</FileInputTrigger>
      </div>
      <FileInputDropzone target="document" variant="overlay">
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Drop to attach</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Files land in the list below.
        </FileInputDropzoneDescription>
      </FileInputDropzone>
      <FileInputList aria-label="Attachments">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputPageDrop }

directory elige una carpeta entera y activa multiple. Los nombres de los elementos muestran la ruta dentro de la carpeta.

import { IconFolder } from "@tabler/icons-react"

import {
  FileInput,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputFolders() {
  return (
    <FileInput directory className="max-w-md">
      <FileInputTrigger className="self-start">
        <IconFolder aria-hidden="true" />
        Choose a folder
      </FileInputTrigger>
      <div className="max-h-72 overflow-y-auto overscroll-contain p-1 empty:hidden">
        <FileInputList aria-label="Folder contents">
          <FileInputItem />
        </FileInputList>
      </div>
    </FileInput>
  )
}

export { UiSdkFileInputFolders }

Pasa value y onValueChange. Un controlador de createFileInputController() abre el selector desde cualquier botón, y vacía o añade archivos.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputItem,
  FileInputList,
  createFileInputController,
  formatFileSize,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputControlled() {
  const [files, setFiles] = React.useState<File[]>([])
  const [picker] = React.useState(createFileInputController)
  const total = files.reduce((sum, file) => sum + file.size, 0)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex flex-wrap items-center gap-2">
        <Button size="sm" variant="outline" onClick={() => picker.open()}>
          Choose files
        </Button>
        <Button
          size="sm"
          variant="ghost"
          disabled={files.length === 0}
          onClick={() => picker.clear()}
        >
          Clear
        </Button>
        <span className="ms-auto text-sm text-muted-foreground tabular-nums">
          {files.length === 0
            ? "No files"
            : `${files.length} ${files.length === 1 ? "file" : "files"}, ${formatFileSize(total)}`}
        </span>
      </div>
      <FileInput
        multiple
        controller={picker}
        value={files}
        onValueChange={(value) => setFiles(value)}
      >
        <FileInputList aria-label="Chosen files">
          <FileInputItem />
        </FileInputList>
      </FileInput>
    </div>
  )
}

export { UiSdkFileInputControlled }

Con un name, los archivos se envían por un campo de archivos nativo, incluso antes de que la página se hidrate. Los archivos rechazados nunca se envían, y reiniciar el formulario vacía la lista.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputForm() {
  const [submitted, setSubmitted] = React.useState<string[] | null>(null)

  return (
    <form
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSubmitted(
          data
            .getAll("attachments")
            .flatMap((entry) =>
              entry instanceof File && entry.name ? [entry.name] : []
            )
        )
      }}
      onReset={() => setSubmitted(null)}
    >
      <FileInput name="attachments" multiple maxSize={10_000_000}>
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneTitle>Attach files</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            Up to 10 MB each.
          </FileInputDropzoneDescription>
          <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
        </FileInputDropzone>
        <FileInputRejections />
        <FileInputList aria-label="Attachments">
          <FileInputItem />
        </FileInputList>
      </FileInput>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-sm text-muted-foreground">
        {submitted === null
          ? "Nothing submitted yet."
          : submitted.length
            ? `Submitted ${submitted.join(", ")}`
            : "Submitted with no files."}
      </p>
    </form>
  )
}

export { UiSdkFileInputForm }

invalid marca la zona para soltar y el disparador, y el disparador lo comunica a los lectores de pantalla. Apunta el aria-describedby del disparador a tu mensaje.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputInvalid() {
  const [invalid, setInvalid] = React.useState(false)
  const errorId = React.useId()

  return (
    <form
      noValidate
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        const proof = data.get("proof")
        setInvalid(!(proof instanceof File && proof.name))
      }}
    >
      <FileInput
        name="proof"
        accept={["image/*", ".pdf"]}
        invalid={invalid}
        onValueChange={(value) => {
          if (value.length) setInvalid(false)
        }}
      >
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneTitle>Proof of address</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            A utility bill or bank statement from the last 3 months.
          </FileInputDropzoneDescription>
          <FileInputTrigger
            className="mt-3"
            aria-describedby={invalid ? errorId : undefined}
          >
            Choose a file
          </FileInputTrigger>
        </FileInputDropzone>
        {invalid ? (
          <p id={errorId} className="m-0 text-sm text-destructive">
            Add a scan to continue.
          </p>
        ) : null}
        <FileInputList aria-label="Proof of address">
          <FileInputItem />
        </FileInputList>
      </FileInput>
      <Button type="submit" size="sm" className="self-start">
        Continue
      </Button>
    </form>
  )
}

export { UiSdkFileInputInvalid }

Un campo de archivos desactivado ignora selecciones, soltadas y pegados, y no envía nada.

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputDisabled() {
  return (
    <FileInput disabled className="max-w-md">
      <FileInputDropzone>
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Uploads are paused</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Drops and pastes are ignored while the file input is disabled.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
      </FileInputDropzone>
    </FileInput>
  )
}

export { UiSdkFileInputDisabled }

Las filas y la zona se reflejan, los nombres de archivo conservan su propia dirección y los tamaños se formatean en la configuración regional que pases.

"use client"

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputItemContent,
  FileInputItemName,
  FileInputItemPreview,
  FileInputItemRemove,
  FileInputItemSize,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <FileInput multiple accept="image/*">
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneIcon />
          <FileInputDropzoneTitle>أفلت الصور هنا</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            أو الصقها من الحافظة.
          </FileInputDropzoneDescription>
          <FileInputTrigger className="mt-3">تصفح الملفات</FileInputTrigger>
        </FileInputDropzone>
        <FileInputList aria-label="الصور">
          <FileInputItem>
            {({ file }) => (
              <>
                <FileInputItemPreview />
                <FileInputItemContent>
                  <FileInputItemName />
                  <FileInputItemSize locale="ar" />
                </FileInputItemContent>
                <FileInputItemRemove aria-label={`إزالة ${file.name}`} />
              </>
            )}
          </FileInputItem>
        </FileInputList>
      </FileInput>
    </div>
  )
}

export { UiSdkFileInputRtl }
KeyAcción
TabVa al disparador y luego a la lista, al último archivo enfocado o al primero.
EnterSpaceAbre el selector de archivos desde el disparador.
↑↓←→Se mueve entre los archivos de la lista.
HomeEndVa al primer o al último archivo.
DeleteBackspaceQuita el archivo enfocado y enfoca el siguiente.
⌘VPega archivos mientras el foco está dentro de la zona para soltar o de su destino de pegado.

La zona para soltar no es un control, así que deja siempre un disparador a su lado para quien usa teclado o lector de pantalla. La lista es una sola parada de tabulación, y cada botón para quitar lleva el nombre de su archivo, como “Remove photo.png”, y queda fuera del orden de tabulación porque Supr sobre el elemento hace lo mismo.

Los archivos aceptados, quitados y rechazados se anuncian en inglés con una región viva cortés. Pasa announcement para decirlo en tu idioma, y getReasonLabel a FileInputRejections para sus motivos. El icono de la zona solo se eleva si se permite el movimiento, y las filas nuevas solo aparecen con fundido en ese caso.

Cada parte envuelve la parte de UI SDK del mismo nombre y acepta todas sus props, incluidas render y un className que puede ser una función del estado de la parte.

PropTipoPredeterminado
value
File[]–
defaultValue
File[][]
onValueChangedetails trae el motivo y los archivos añadidos, quitados y rechazados.
(value: File[], details) => void–
onRejectSe ejecuta cuando hay archivos que no pasan las comprobaciones, aunque no se haya aceptado ninguno.
(rejected: FileRejection[], details) => void–
accept
string | string[]–
multiple
booleanfalse
maxFiles
number–
maxSizeEn bytes.
number–
minSizeEn bytes.
number–
validateDevuelve un motivo para rechazar el archivo, tras las comprobaciones integradas.
(file: File) => string | null–
directory
booleanfalse
captureAbre la cámara en los teléfonos.
"user" | "environment" | boolean–
invalid
booleanfalse
disabled
booleanfalse
required
booleanfalse
name
string–
form
string–
announcementLo que oyen los lectores de pantalla tras un cambio.
(change) => string | null–
controllerDe createFileInputController().
FileInputController–
children
ReactNode | (state) => ReactNode–
AtributoDescripción
data-slot="file-input"Apunta al campo de archivos en CSS.
data-draggingPresente mientras se arrastran archivos sobre una de sus zonas para soltar.
data-invalidPresente cuando no es válido.
data-requiredPresente cuando se exige un archivo.
data-disabledPresente cuando está deshabilitado.
PropTipoPredeterminado
variantUna zona discontinua, una tarjeta que envuelve un editor de mensajes o una capa a página completa que aparece al arrastrar.
"default" | "composer" | "overlay""default"
target
"element" | "document""element"
pasteTargetOtro lugar del que tomar archivos pegados.
"document" | string | Element | () => Element–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="file-input-dropzone"Apunta a la zona para soltar en CSS.
data-variantLa variante: default, composer u overlay.
data-draggingPresente mientras se arrastran archivos sobre ella.
data-drag-acceptPresente mientras los archivos arrastrados parecen aceptables por su tipo y cantidad.
data-drag-rejectPresente mientras un archivo arrastrado tiene un tipo no aceptado, o hay demasiados.
data-disabledPresente cuando está deshabilitado.
PropTipoPredeterminado
variant
Button variant"outline"
size
Button size"sm"
controllerConecta un disparador fuera del campo, como uno en una barra de herramientas.
FileInputController–
render
ReactElement | (props, state) => ReactElement–
PropTipoPredeterminado
layoutFilas, mosaicos cuadrados de imagen o chips compactos. Los elementos toman su aspecto de aquí.
"list" | "grid" | "chips""list"
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="file-input-list"Apunta a la lista en CSS.
data-layoutEl diseño: list, grid o chips. Los elementos también lo llevan.
PropTipoPredeterminado
childrenPor defecto, una vista previa, el nombre, el tamaño y un botón para quitar. El estado tiene el archivo y su índice.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="file-input-item"Apunta a la fila de un archivo en CSS.
data-disabledPresente cuando está deshabilitado.
AtributoDescripción
data-slot="file-input-item-preview"Apunta a la vista previa en CSS.
data-kindimage para una vista previa de imagen, icon para un icono según el tipo de archivo.
PropTipoPredeterminado
localeDa formato al tamaño, como 2.4 kB, en esta configuración regional. Por defecto, la del navegador.
string–
PropTipoPredeterminado
childrenPor defecto, un icono de X.
ReactNode–
render
ReactElement | (props, state) => ReactElement–
PropTipoPredeterminado
getReasonLabelNombra cada motivo. Por defecto en inglés, como “Larger than 5 MB”.
(reason, rules, rejection) => string–
AtributoDescripción
data-slot="file-input-rejections"Apunta a la lista de archivos rechazados en CSS.