HextaUI

BetaFeito sobre o UI SDK 0.1.0-beta.2 da Vercel. A API pode mudar.

File input

Um campo de arquivos no UI SDK da Vercel com área para soltar, colagem, verificação de tipo e tamanho, arquivos recusados com motivos, prévias de imagem e suporte a formulários.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele depende.

O UI SDK adiciona um pouco de CSS próprio em uma camada de cascata chamada ui-sdk. Declare essa camada no topo da sua folha de estilos global, acima do import do Tailwind, para que suas classes vençam.

@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>

O campo guarda os arquivos que as pessoas escolhem, soltam ou colam, e os envia com um formulário. Fazer o upload fica a cargo do seu app. FileInputItem sem filhos renderiza uma linha pronta com prévia, nome, tamanho e botão de remover, uma vez para cada arquivo da lista.

As verificações de accept, tamanhos, quantidades e validate rodam só no navegador, para dizer cedo por que um arquivo ficou de fora. Qualquer um pode pulá-las, então verifique cada upload de novo no seu servidor.

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

accept aceita tipos MIME, curingas e extensões. maxFiles, minSize e maxSize definem os limites, e validate adiciona verificações próprias. FileInputRejections lista cada arquivo que ficou de fora com todos os motivos, até o próximo arquivo aceito.

"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 mostra uma imagem a partir de uma URL de objeto que revoga quando o arquivo sai, e um ícone para outros tipos. Com layout="grid", cada arquivo é um bloco quadrado.

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 }

Um gatilho sozinho abre o seletor de arquivos do sistema. Sem multiple, um arquivo novo substitui o atual, e os filhos da raiz podem ser uma função do estado dela.

"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 }

Com variant="composer", a área para soltar é o card em volta de uma caixa de mensagem, e layout="chips" lista os arquivos como chips compactos. pasteTarget pega capturas coladas na área de texto, enquanto texto colado continua 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 }

Com target="document" e variant="overlay", a área cobre a página enquanto arquivos são arrastados sobre a janela, e recebe arquivos soltos em qualquer lugar. O que é solto em outra área fica nela.

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 escolhe uma pasta inteira e liga multiple. Os nomes dos itens mostram o caminho dentro da pasta.

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 }

Passe value e onValueChange. Um controller de createFileInputController() abre o seletor a partir de qualquer botão, e limpa ou adiciona arquivos.

"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 }

Com um name, os arquivos são enviados por um campo de arquivo nativo, mesmo antes de a página hidratar. Arquivos recusados nunca são enviados, e o reset do formulário esvazia a 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 a área para soltar e o gatilho, e o gatilho avisa os leitores de tela. Aponte o aria-describedby do gatilho para a sua mensagem.

"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 }

Um campo de arquivos desativado ignora seleções, arrastos e colagens, e não envia 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 }

As linhas e a área se espelham, os nomes de arquivo mantêm a própria direção e os tamanhos são formatados na localidade que você passar.

"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 }
TeclaAção
TabVai para o gatilho e depois para a lista, no último arquivo focado ou no primeiro.
EnterSpaceAbre o seletor de arquivos a partir do gatilho.
↑↓←→Move entre os arquivos da lista.
HomeEndVai para o primeiro ou o último arquivo.
DeleteBackspaceRemove o arquivo focado e foca o próximo.
⌘VCola arquivos enquanto o foco está dentro da área para soltar ou do seu destino de colagem.

A área para soltar não é um controle, então mantenha sempre um gatilho ao lado dela para quem usa teclado ou leitor de tela. A lista é uma única parada de tab, e cada botão de remover leva o nome do seu arquivo, como “Remove photo.png”, e fica fora da ordem de tab porque Delete no item faz o mesmo.

Arquivos aceitos, removidos e recusados são anunciados em inglês por uma região viva educada. Passe announcement para dizer isso no seu idioma, e getReasonLabel para FileInputRejections para os motivos. O ícone da área só sobe quando movimento é permitido, e só então as novas linhas aparecem com fade.

Cada parte envolve a parte do UI SDK de mesmo nome e aceita todas as suas props, incluindo render e um className que pode ser uma função do estado da parte.

PropTipoPadrão
value
File[]–
defaultValue
File[][]
onValueChangedetails traz o motivo e os arquivos adicionados, removidos e recusados.
(value: File[], details) => void–
onRejectRoda quando arquivos falham nas verificações, mesmo que nenhum tenha sido aceito.
(rejected: FileRejection[], details) => void–
accept
string | string[]–
multiple
booleanfalse
maxFiles
number–
maxSizeEm bytes.
number–
minSizeEm bytes.
number–
validateRetorne um motivo para recusar o arquivo, depois das verificações embutidas.
(file: File) => string | null–
directory
booleanfalse
captureAbre a câmera nos celulares.
"user" | "environment" | boolean–
invalid
booleanfalse
disabled
booleanfalse
required
booleanfalse
name
string–
form
string–
announcementO que os leitores de tela ouvem após uma mudança.
(change) => string | null–
controllerDe createFileInputController().
FileInputController–
children
ReactNode | (state) => ReactNode–
AtributoDescrição
data-slot="file-input"Mira o campo de arquivos no CSS.
data-draggingPresente enquanto arquivos são arrastados sobre uma das suas áreas para soltar.
data-invalidPresente quando inválido.
data-requiredPresente quando um arquivo é obrigatório.
data-disabledPresente quando desabilitado.
PropTipoPadrão
variantUma área tracejada, um card que envolve um editor de mensagem ou uma sobreposição de página inteira que aparece ao arrastar.
"default" | "composer" | "overlay""default"
target
"element" | "document""element"
pasteTargetOutro lugar de onde pegar arquivos colados.
"document" | string | Element | () => Element–
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="file-input-dropzone"Mira a área para soltar no CSS.
data-variantA variante: default, composer ou overlay.
data-draggingPresente enquanto arquivos são arrastados sobre ela.
data-drag-acceptPresente enquanto os arquivos arrastrados parecem aceitáveis pelo tipo e pela quantidade.
data-drag-rejectPresente enquanto um arquivo arrastado tem um tipo não aceito, ou há arquivos demais.
data-disabledPresente quando desabilitado.
PropTipoPadrão
variant
Button variant"outline"
size
Button size"sm"
controllerConecta um gatilho fora do campo, como um numa barra de ferramentas.
FileInputController–
render
ReactElement | (props, state) => ReactElement–
PropTipoPadrão
layoutLinhas, blocos quadrados de imagem ou chips compactos. Os itens herdam a aparência daqui.
"list" | "grid" | "chips""list"
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="file-input-list"Mira a lista no CSS.
data-layoutO layout: list, grid ou chips. Os itens também o carregam.
PropTipoPadrão
childrenPor padrão, uma prévia, o nome, o tamanho e um botão de remover. O estado tem o arquivo e o índice dele.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="file-input-item"Mira a linha de um arquivo no CSS.
data-disabledPresente quando desabilitado.
AtributoDescrição
data-slot="file-input-item-preview"Mira a prévia no CSS.
data-kindimage para uma prévia de imagem, icon para um ícone conforme o tipo de arquivo.
PropTipoPadrão
localeFormata o tamanho, como 2.4 kB, nesta localidade. Por padrão, a do navegador.
string–
PropTipoPadrão
childrenPor padrão, um ícone de X.
ReactNode–
render
ReactElement | (props, state) => ReactElement–
PropTipoPadrão
getReasonLabelNomeia cada motivo. Por padrão em inglês, como “Larger than 5 MB”.
(reason, rules, rejection) => string–
AtributoDescrição
data-slot="file-input-rejections"Mira a lista de arquivos recusados no CSS.