HextaUI

BêtaConstruit sur l’UI SDK 0.1.0-beta.2 de Vercel. Son API peut changer.

File input

Un champ de fichiers sur l’UI SDK de Vercel avec zone de dépôt, collage, contrôles de type et de taille, fichiers refusés avec leurs raisons, aperçus d’images et prise en charge des formulaires.

Dépend 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

Ajoute le composant, les tokens de thème HextaUI et les composants HextaUI dont il dépend.

UI SDK ajoute un peu de CSS dans une couche de cascade nommée ui-sdk. Déclarez cette couche en haut de votre feuille de style globale, au-dessus de l’import Tailwind, pour que vos classes l’emportent.

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

Le champ garde les fichiers choisis, déposés ou collés, et les envoie avec un formulaire. Les téléverser revient à votre app. FileInputItem sans enfants affiche une ligne finie avec un aperçu, le nom, la taille et un bouton de retrait, une fois par fichier de la liste.

Les contrôles de accept, des tailles, du nombre et de validate s’exécutent dans le navigateur seulement, pour dire tôt pourquoi un fichier a été écarté. N’importe qui peut les contourner, alors vérifiez chaque envoi à nouveau sur votre serveur.

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

accept prend des types MIME, des jokers et des extensions. maxFiles, minSize et maxSize fixent les limites, et validate ajoute vos propres contrôles. FileInputRejections liste chaque fichier écarté avec toutes ses raisons, jusqu’au prochain fichier accepté.

"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 affiche une image depuis une URL d’objet qu’il révoque quand le fichier part, et une icône pour les autres types. Avec layout="grid", chaque fichier est une tuile carrée.

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 déclencheur seul ouvre le sélecteur de fichiers du système. Sans multiple, un nouveau fichier remplace l’actuel, et les enfants de la racine peuvent être une fonction de son état.

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

Avec variant="composer", la zone de dépôt est la carte autour d’une zone de message, et layout="chips" liste les fichiers en puces compactes. pasteTarget prend les captures collées dans la zone de texte, tandis que le texte collé reste du texte.

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

Avec target="document" et variant="overlay", la zone couvre la page pendant qu’on glisse des fichiers sur la fenêtre, et prend les fichiers déposés n’importe où. Un dépôt sur une autre zone reste à cette zone.

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 choisit un dossier entier et active multiple. Les noms des éléments montrent le chemin dans le dossier.

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 }

Passez value et onValueChange. Un contrôleur issu de createFileInputController() ouvre le sélecteur depuis n’importe quel bouton, et vide ou ajoute des fichiers.

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

Avec un name, les fichiers sont envoyés par un champ de fichier natif, même avant l’hydratation de la page. Les fichiers refusés ne sont jamais envoyés, et réinitialiser le formulaire vide la liste.

"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 marque la zone de dépôt et le déclencheur, qui le signale aux lecteurs d’écran. Faites pointer l’aria-describedby du déclencheur vers votre message.

"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 champ de fichiers désactivé ignore les choix, dépôts et collages, et n’envoie rien.

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 }

Les lignes et la zone s’inversent, les noms de fichiers gardent leur propre sens et les tailles se formatent dans la locale passée.

"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 }
ToucheAction
TabPasse au déclencheur, puis dans la liste, sur le dernier fichier ciblé ou le premier.
EnterSpaceOuvre le sélecteur de fichiers depuis le déclencheur.
↑↓←→Passe d’un fichier à l’autre dans la liste.
HomeEndVa au premier ou au dernier fichier.
DeleteBackspaceRetire le fichier ciblé et cible le suivant.
⌘VColle des fichiers quand le focus est dans la zone de dépôt ou sa cible de collage.

La zone de dépôt n’est pas un contrôle : gardez toujours un déclencheur à côté pour les personnes au clavier ou avec un lecteur d’écran. La liste est un seul arrêt de tabulation, chaque bouton de retrait porte le nom de son fichier, comme « Remove photo.png », et reste hors de l’ordre de tabulation, puisque Suppr sur l’élément fait la même chose.

Les fichiers acceptés, retirés et refusés sont annoncés en anglais par une région live polie. Passez announcement pour le dire dans votre langue, et getReasonLabel à FileInputRejections pour ses raisons. L’icône de la zone ne se soulève que si le mouvement est autorisé, et les nouvelles lignes n’apparaissent en fondu qu’à ce moment-là.

Chaque partie enveloppe la partie UI SDK du même nom et accepte toutes ses props, y compris render et un className qui peut être une fonction de son état.

PropTypePar défaut
value
File[]–
defaultValue
File[][]
onValueChangedetails contient la raison et les fichiers ajoutés, retirés et refusés.
(value: File[], details) => void–
onRejectS’exécute quand des fichiers échouent aux contrôles, même si aucun n’a été accepté.
(rejected: FileRejection[], details) => void–
accept
string | string[]–
multiple
booleanfalse
maxFiles
number–
maxSizeEn octets.
number–
minSizeEn octets.
number–
validateRenvoyez une raison pour refuser le fichier, après les contrôles intégrés.
(file: File) => string | null–
directory
booleanfalse
captureOuvre l’appareil photo sur les téléphones.
"user" | "environment" | boolean–
invalid
booleanfalse
disabled
booleanfalse
required
booleanfalse
name
string–
form
string–
announcementCe qu’entendent les lecteurs d’écran après un changement.
(change) => string | null–
controllerIssu de createFileInputController().
FileInputController–
children
ReactNode | (state) => ReactNode–
AttributDescription
data-slot="file-input"Cible le champ de fichiers en CSS.
data-draggingPrésent pendant qu’on fait glisser des fichiers sur l’une de ses zones de dépôt.
data-invalidPrésent quand il est invalide.
data-requiredPrésent quand un fichier est requis.
data-disabledPrésent lorsque l’élément est désactivé.
PropTypePar défaut
variantUne zone en pointillés, une carte autour d’un éditeur de message, ou un calque pleine page affiché pendant le glisser.
"default" | "composer" | "overlay""default"
target
"element" | "document""element"
pasteTargetUn autre endroit d’où prendre les fichiers collés.
"document" | string | Element | () => Element–
render
ReactElement | (props, state) => ReactElement–
AttributDescription
data-slot="file-input-dropzone"Cible la zone de dépôt en CSS.
data-variantLa variante : default, composer ou overlay.
data-draggingPrésent pendant qu’on fait glisser des fichiers dessus.
data-drag-acceptPrésent tant que les fichiers glissés semblent acceptables, d’après leur type et leur nombre.
data-drag-rejectPrésent quand un fichier glissé a un type refusé, ou qu’il y en a trop.
data-disabledPrésent lorsque l’élément est désactivé.
PropTypePar défaut
variant
Button variant"outline"
size
Button size"sm"
controllerRelie un déclencheur placé hors du champ, comme dans une barre d’outils.
FileInputController–
render
ReactElement | (props, state) => ReactElement–
PropTypePar défaut
layoutDes lignes, des tuiles d’image carrées ou des puces compactes. Les éléments en prennent leur apparence.
"list" | "grid" | "chips""list"
render
ReactElement | (props, state) => ReactElement–
AttributDescription
data-slot="file-input-list"Cible la liste en CSS.
data-layoutLa disposition : list, grid ou chips. Les éléments la portent aussi.
PropTypePar défaut
childrenPar défaut, un aperçu, le nom, la taille et un bouton de retrait. L’état contient le fichier et son index.
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
AttributDescription
data-slot="file-input-item"Cible la ligne d’un fichier en CSS.
data-disabledPrésent lorsque l’élément est désactivé.
AttributDescription
data-slot="file-input-item-preview"Cible l’aperçu en CSS.
data-kindimage pour un aperçu d’image, icon pour une icône selon le type de fichier.
PropTypePar défaut
localeFormate la taille, comme 2.4 kB, dans cette locale. Par défaut, celle du navigateur.
string–
PropTypePar défaut
childrenPar défaut, une icône X.
ReactNode–
render
ReactElement | (props, state) => ReactElement–
PropTypePar défaut
getReasonLabelNomme chaque raison. En anglais par défaut, comme « Larger than 5 MB ».
(reason, rules, rejection) => string–
AttributDescription
data-slot="file-input-rejections"Cible la liste des fichiers refusés en CSS.