HextaUI

ベータVercel の UI SDK 0.1.0-beta.2 上に構築。API は変わる可能性があります。

File input

Vercel の UI SDK で作るファイル入力。ドロップゾーン、貼り付け、種類とサイズのチェック、理由つきの却下ファイル、画像プレビュー、フォーム対応。

依存関係
  • @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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

UI SDK は ui-sdk というカスケードレイヤーに少しだけ独自の CSS を追加します。自分のクラスが優先されるよう、グローバルスタイルシートの先頭、Tailwind の import より上でこのレイヤーを宣言してください。

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

ファイル入力は、選んだ・ドロップした・貼り付けたファイルを保持し、フォームと一緒に送信します。アップロードはアプリ側の仕事です。子要素なしの FileInputItem は、リストのファイルごとにプレビュー・名前・サイズ・削除ボタンのそろった行を描きます。

accept、サイズ、件数、validate のチェックはブラウザだけで動き、ファイルが外された理由を早めに伝えます。誰でも回避できるので、アップロードはすべてサーバーで改めて確認してください。

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

accept には MIME タイプ、ワイルドカード、拡張子を指定できます。maxFiles、minSize、maxSize で上限を決め、validate で独自のチェックを足します。FileInputRejections は、外されたファイルをすべての理由とともに、次に受け入れられるファイルまで一覧します。

"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 は、ファイルがなくなると破棄するオブジェクト URL から画像を、ほかの種類にはアイコンを表示します。layout="grid" では各ファイルが正方形のタイルになります。

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 }

トリガーだけでもシステムのファイル選択を開けます。multiple がなければ新しいファイルが今のファイルを置き換え、ルートの子要素は状態を受け取る関数にできます。

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

variant="composer" ではドロップゾーンがメッセージ欄を囲むカードになり、layout="chips" でファイルをコンパクトなチップとして並べます。pasteTarget はテキストエリアに貼り付けたスクリーンショットを受け取り、貼り付けたテキストはテキストのままです。

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

target="document" と variant="overlay" を指定すると、ファイルがウィンドウ上でドラッグされている間ゾーンがページを覆い、どこにドロップしても受け取ります。別のゾーンへのドロップはそのゾーンのものです。

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 はフォルダごと選び、multiple を有効にします。項目名にはフォルダ内のパスが表示されます。

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 }

value と onValueChange を渡します。createFileInputController() のコントローラーなら、どのボタンからでも選択画面を開き、ファイルを消したり足したりできます。

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

name があれば、ページのハイドレーション前でもネイティブのファイル入力からファイルが送信されます。却下されたファイルは送信されず、フォームのリセットでリストは空になります。

"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 はドロップゾーンとトリガーに印を付け、トリガーがスクリーンリーダーに伝えます。トリガーの aria-describedby をメッセージに向けてください。

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

無効なファイル入力は選択・ドロップ・貼り付けを無視し、何も送信しません。

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 }

行とドロップゾーンは左右反転し、ファイル名は自身の向きを保ち、サイズは渡したロケールで書式化されます。

"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 }
キーアクション
Tabトリガーへ移り、次にリストの、最後にフォーカスしたファイルか先頭のファイルへ移ります。
EnterSpaceトリガーからファイル選択を開きます。
↑↓←→リスト内のファイル間を移動します。
HomeEnd最初または最後のファイルへ移動します。
DeleteBackspaceフォーカス中のファイルを削除し、次のファイルにフォーカスします。
⌘Vフォーカスがドロップゾーンかその貼り付け先にあるとき、ファイルを貼り付けます。

ドロップゾーンはコントロールではないので、キーボードやスクリーンリーダーの利用者のために、必ず横にトリガーを置いてください。リストはひとつのタブストップです。削除ボタンは「Remove photo.png」のようにファイル名で名付けられ、項目上の Delete で同じことができるため、タブ順には入りません。

受け入れ・削除・却下されたファイルは、英語で polite なライブリージョンから読み上げられます。自分の言語にするには announcement を、却下理由には FileInputRejections に getReasonLabel を渡します。ドロップゾーンのアイコンが浮き上がるのも、新しい行がフェードインするのも、モーションが許可されているときだけです。

各パーツは同名の UI SDK パーツをラップし、render やパーツの状態を受け取る関数にもできる className を含むすべての props を受け付けます。

プロパティ型デフォルト
value
File[]–
defaultValue
File[][]
onValueChangedetails には理由と、追加・削除・却下されたファイルが入ります。
(value: File[], details) => void–
onRejectファイルがチェックに通らなかったとき、ひとつも受け入れられなくても実行されます。
(rejected: FileRejection[], details) => void–
accept
string | string[]–
multiple
booleanfalse
maxFiles
number–
maxSizeバイト単位。
number–
minSizeバイト単位。
number–
validate組み込みのチェックの後で、ファイルを却下する理由を返します。
(file: File) => string | null–
directory
booleanfalse
captureスマートフォンではカメラを開きます。
"user" | "environment" | boolean–
invalid
booleanfalse
disabled
booleanfalse
required
booleanfalse
name
string–
form
string–
announcement変更後にスクリーンリーダーが読み上げる内容。
(change) => string | null–
controllercreateFileInputController() で作成。
FileInputController–
children
ReactNode | (state) => ReactNode–
属性説明
data-slot="file-input"CSS でファイル入力を指定します。
data-draggingファイルがいずれかのドロップゾーン上でドラッグされている間に付きます。
data-invalid無効なときに付きます。
data-requiredファイルが必須のときに付きます。
data-disabled無効のときに存在します。
プロパティ型デフォルト
variant破線のゾーン、メッセージ入力欄を包むカード、またはドラッグ中に出る全画面のオーバーレイ。
"default" | "composer" | "overlay""default"
target
"element" | "document""element"
pasteTarget貼り付けたファイルを受け取る別の場所。
"document" | string | Element | () => Element–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="file-input-dropzone"CSS でドロップゾーンを指定します。
data-variantバリアント:default、composer、overlay。
data-draggingファイルがその上でドラッグされている間に付きます。
data-drag-acceptドラッグ中のファイルが種類と数から見て受け入れられそうな間に付きます。
data-drag-rejectドラッグ中のファイルに受け入れない種類があるか、数が多すぎる間に付きます。
data-disabled無効のときに存在します。
プロパティ型デフォルト
variant
Button variant"outline"
size
Button size"sm"
controllerツールバーの中など、ファイル入力の外にあるトリガーをつなぎます。
FileInputController–
render
ReactElement | (props, state) => ReactElement–
プロパティ型デフォルト
layout行、正方形の画像タイル、またはコンパクトなチップ。項目の見た目はここから決まります。
"list" | "grid" | "chips""list"
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="file-input-list"CSS でリストを指定します。
data-layoutレイアウト:list、grid、chips。項目にも付きます。
プロパティ型デフォルト
children既定はプレビュー、名前、サイズ、削除ボタン。状態にはファイルとそのインデックスが入ります。
ReactNode | (state) => ReactNode–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="file-input-item"CSS でファイルの行を指定します。
data-disabled無効のときに存在します。
属性説明
data-slot="file-input-item-preview"CSS でプレビューを指定します。
data-kind画像プレビューなら image、ファイル種別のアイコンなら icon。
プロパティ型デフォルト
localeサイズ(2.4 kB など)をこのロケールで書式化します。既定はブラウザのロケールです。
string–
プロパティ型デフォルト
children既定は X アイコンです。
ReactNode–
render
ReactElement | (props, state) => ReactElement–
プロパティ型デフォルト
getReasonLabel各理由に名前を付けます。既定は英語で、「Larger than 5 MB」のようになります。
(reason, rules, rejection) => string–
属性説明
data-slot="file-input-rejections"CSS で却下されたファイルのリストを指定します。