GrFileUpload

Package: @feugene/granularitycoreGroup: forms

Accepts files via selection or drag-and-drop and shows their state.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the file leaves for the server at onceaction sends the multipart itself, request connects an uploader of your own;
  • there are many filesconcurrency limits the parallel requests, limit their number;
  • the upload is long — progress per file, hideProgressOnSuccess removes the bar after a success;
  • a drop zone is needed — drag & drop from the system plus a choice with a button and from the keyboard;
  • the look is entirely your own — the slot gives the zone to the consumer while leaving them all of the mechanics.

When to take something else

NeedTake
The file is the value of a form and leaves with itGrFormFile
The image has to be examinedGrImageViewer
Only a progress indicator is neededGrProgressBar
A server error answerGrResponseErrorBanner

`accept` filters the dialog, a validator filters the drop

accept goes both to the <input type="file"> and into the chain as the first validator. The attribute alone is not enough: it limits the system dialog, but anything at all can be dragged into the zone — without the validator an extra file would reach the server.

<GrFileUpload accept="image/*,.pdf" :request="upload" multiple show-file-list />

Beside it are capture (the camera on mobile) and directory (webkitdirectory, Chromium and Safari).

The component does not pass fallthrough attributes on to the input: class and style belong to the zone, and inheritAttrs: false would take them off it. The attributes of the input itself are declared as props explicitly.

The set of files: remove and retry

showFileList shows the chosen files; each has a remove button, and retry() repeats the upload of the current set — after an error there is no need to choose the files again.

const uploader = ref<GrFileUploadInstance>()

uploader.value?.retry()
uploader.value?.removeFile(file) // removing aborts the upload in flight: it was about the previous set
uploader.value?.abort()

Removing the last file returns the state to idle: there is no point showing an error about a set that no longer exists.

The upload modes

uploadMode decides what exactly the set of files is:

  • batch (the default) — the whole set leaves in one request (request(files, ctx)). There is one state for the set: there is nothing to say about an individual file, and it has no status;
  • per-file — a request of its own for every file. request is then called with an array of one file: the contract does not change, and the consumer’s uploader (axios and the rest) keeps working with no edits.

In the per-file mode a row gets a status (pending/uploading/success/error) and a percentage, and the component gets retryFile(file) and abortFile(file):

<GrFileUpload
  upload-mode="per-file"
  :concurrency="3"
  :request="upload"
  multiple
  show-file-list
/>

concurrency (3 by default) limits the number of simultaneous requests: “per file” without it would mean “all at once”, and a hundred files would open a hundred connections.

The summary state is computed by the worst outcome: uploading while at least one is uploading; an error if after the finish there is at least one; a success when all are successful. The percentage is the mean across the files: a bar weighted by bytes would jerk backwards when a large file starts after a small one.

Cancelling one file is not an error: the row returns to the queue, and it can be retried or removed.

The success, error and progress emits receive an optional trailing file argument — it exists only in the per-file mode.

The progress does not disappear instantly

The bar built on GrProgressBar shows itself while the sending is in progress, and after a success it holds for another hideProgressOnSuccess milliseconds — 800 by default. The delay is not cosmetic: a bar that vanishes in the same frame in which it reached the end reads as “the upload failed” rather than as “done”. 0 leaves the bar forever — that is the mode for a UI of your own that removes it itself.

showProgress: false switches the default bar off entirely: it is not needed when the progress is drawn by the #progress slot or when the files are small and the bar only has time to blink. progressTone colours it during the sending phase, and progressLabel gives it a name for a screen reader.

The server's answer

The component is generic over the answer: TResponse is inferred from request and types the payload of the success event — the any has left the public signature.

async function upload(files: File[]): Promise<UploadedFile> { /* … */ }

function onUploaded(file: UploadedFile) { /* the payload is typed */ }
<GrFileUpload :request="upload" @success="onUploaded" />

The type parameter is set exactly this way, through request: in an SFC template the syntax <GrFileUpload<UploadedFile>> does not exist — that is TSX, not a Vue template. The action branch does not govern the type of the answer: only the consumer knows what the server will return.

The preview

preview draws a thumbnail for image/* files in the list. The link (URL.createObjectURL) is revoked when a file is removed, when the set changes and on unmount: without that the blob hangs in the memory of the tab until a reload.

The thumbnail is decorative (alt="") — the name of the file is already beside it, and there is no point duplicating it for a screen reader.

A controlled set

<GrFileUpload v-model="files" :request="upload" show-file-list />

modelValue is optional: without it the component keeps the set itself. If it is passed, the set follows the prop and can be replaced or cleared from the outside, for instance after the form has been submitted.

update:modelValue is emitted when the set itself changes: a new choice or the removal of a file. It is not to be confused with change — that one means “the upload has finished”, and that is a different moment.

The difference from GrFormFile remains one of purpose rather than of having a model: GrFileUpload is about sending (action/request, progress, retry, per-file statuses), GrFormFile is about a form field.

action and request are a boundary of another kind: if both are set, request works; if neither is set, a dev build warns on mounting rather than at the first choice of a file. Expressing the requirement with a type (a discriminated union) is impossible in an SFC: defineProps accepts only an object type or an interface.

The guard and the notifications

beforeUpload is a prop callback: it has to return “let it through or not”, and an emit returns no value. Everything else is emits: exceed (there are more files than limit), progress, success, error, change, stateChange.

:on-exceed="fn" keeps working: in Vue an emit also arrives as a listener prop.

Races and loose ends

  • Two quick choices in a row overlap (the validators are asynchronous). The last one is always the current one: a run has a number, and the one that fell behind quietly leaves the race without cutting its neighbour’s upload short.
  • On unmount the component aborts the active XHR and clears the timer that hides the success — otherwise the upload would keep going and the timer would jerk the state of a destroyed instance.
  • A custom request is not obliged to call onProgress. The total volume is then taken from the sizes of the files rather than from zero: “100%” with total: 0 would be read by the consumer as “zero uploaded”.

Accessibility

The accessible control is the native <input type="file"> itself; the zone gets no widget role, otherwise the input inside it is lost for a screen reader (nested-interactive). The zone shows the focus of the input through focus-within, and disabled is dimmed with a background rather than with opacity: transparency dilutes text tokens tuned to AA.

disabled and readonly arrive both from GrFormField and from GrForm — not only from the props of the component. readonly really does forbid input: the set is visible and leaves with the form, but the dialog will not open and a drop will not be accepted. HTML has no readonly attribute for <input type="file">, so the system dialog is suppressed by cancelling the default action — the input stays in the Tab order and is announced as read-only.

The phases of the upload are announced by a live region (role="status") that exists from the first render: a region that appears already carrying text is not announced at all by some AT. The progress as a percentage does not go there — a screen reader would choke.

Playground 22

Loading…

Code
<GrFileUpload />

Install

npm i @feugene/granularity

Import

import { GrFileUpload } from '@feugene/granularity/components/GrFileUpload'

API

Props

PropTypedefaultDescription
modelValueFile[] | undefinedundefinedThe set of files — the one shown in the list and the one that will go into `retry`. The prop is **optional**: without it the component keeps the set itself. If it is passed, the set follows it, and the consumer can clear or replace the list from the outside.
actionstring | undefinedundefined
requestGrFileUploadRequest<TResponse> | undefinedundefined
namestring | undefined"file"
multipleboolean | undefinedfalse
limitnumber | undefinedundefined
beforeUpload((file: File) => boolean | Promise<unknown>) | undefinedundefinedA guard before sending. It stays a prop rather than an emit deliberately: an emit returns no value, and this callback is obliged to answer "let it through or not". The notifications — `exceed`, `success`, `error`, `progress` — are emits.
validatorsFileValidator[] | undefinedundefined
acceptstring | undefinedundefinedThe W3C `accept` for the `<input type="file">` — and sugar over `acceptValidator(...)`. The attribute alone is not enough: it filters the system dialog but not drag & drop — anything at all can be dragged in. The same value therefore goes into the validators as well, as in `GrFormFile`.
capture"user" | "environment" | undefinedundefined`capture` for the mobile camera or microphone.
directoryboolean | undefinedfalseChoosing a whole directory (`webkitdirectory`). Supported by Chromium and Safari.
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead-only: the value is visible and leaves with the form, but is not edited.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA required field (`aria-required`).
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
headersRecord<string, string> | undefinedundefined
withCredentialsboolean | undefinedfalse
showFileListboolean | undefinedfalse
uploadExtraData((files: File[]) => GrFileUploadExtraData | undefined) | undefinedundefined
placeholderstring | undefinedundefinedi18n: the hint text in the default UI.
showProgressboolean | undefinedtrueWhether to show the default progress bar (if the `progress` slot is not used).
progressTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"The colour tone of the progress bar in the `uploading` phase.
progressLabelstring | undefinedundefinedThe aria-label for the progress bar.
hideProgressOnSuccessnumber | undefined800How many ms after `success` to hide the progress bar. `0` — do not hide it.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the default UI: the padding of the drop zone, the icon tile, the type size of the labels.
uploadModeGrFileUploadMode | undefined"batch"How the set of files leaves: - `batch` (the default) — the whole set in one request. There is nothing to say about an individual file, so it has no status either; - `per-file` — a request of its own for every file (`request` is called with an array of one file, the contract does not change). A status and a percentage of the row appear, along with `retryFile` and `abortFile`.
concurrencynumber | undefined3How many files are uploaded simultaneously in the `per-file` mode.
previewboolean | undefinedfalseThumbnails for `image/*` in the list of files.

Slots

SlotTypeDescription
default{ openDialog: () => void; abort: () => void; disabled: boolean; files: File[]; isOver: boolean; state: GrUploadState; retry: () => Promise<void>; removeFile: (file: File) => void; fileEntries: GrFileUploadEntry[]; retryFile: (file: File) => Promise<void>; abortFile: (file: File) => void; }A completely custom upload zone: the control gives away all of its state.
labelanyThe heading of the zone instead of `placeholder`.
tipanyA caption under the heading: the limits on type and size.
progress{ state: GrUploadState; percent: number; indeterminate: boolean; phase: "success" | "error" | "idle" | "uploading"; files: File[]; abort: () => void; retry: () => Promise<void>; fileEntries: GrFileUploadEntry[]; retryFile: (file: File) => Promise<void>; abortFile: (file: File) => void; }A progress indicator of your own instead of the built-in one.

Events

EventTypeDescription
update:modelValue[File[]]The set of files has changed: a new choice or a removal. Not to be confused with `change`.
exceed[File[], number]More files were chosen than `limit` allows. The upload does not start.
success[TResponse, File | undefined]`file` arrives only in the `per-file` mode: in a batch there is nothing to report about.
error[unknown, File | undefined]
progress[number, GrUploadProgressInfo | undefined, File | undefined]
change[File[]]
stateChange[GrUploadState]
focus[FocusEvent]
blur[FocusEvent]

Examples 9

Validation bridge with upload request

Upload a file for validation demo
image/* or .pdf · max 2 Mb
No uploads yet

Validation
<script setup lang="ts">
import { ref } from 'vue'

import type { GrFileUploadExtraData, GrFileUploadRequestCtx } from '@feugene/granularity'
import { GrFileUpload } from '@feugene/granularity'
import { acceptValidator, maxFileSize } from '@feugene/granularity/fileValidation'

const lastResult = ref('No uploads yet')

async function request(files: File[], ctx: GrFileUploadRequestCtx) {
  await new Promise(resolve => window.setTimeout(resolve, 250))

  return {
    count: files.length,
    names: files.map(file => file.name),
    extraData: ctx.extraData,
  }
}

function onSuccess(payload: { count: number, names: string[], extraData?: GrFileUploadExtraData }) {
  const bucketValue = payload.extraData?.bucket
  const bucketLabel = typeof bucketValue === 'string' ? bucketValue : 'n/a'
  lastResult.value = `uploaded ${payload.count} file(s): ${payload.names.join(', ') || ''} · bucket=${bucketLabel}`
}

function onError(error: unknown) {
  lastResult.value = error instanceof Error ? error.message : String(error)
}
</script>

<template>
  <div class="grid gap-3">
    <GrFileUpload
      :request="request"
      :validators="[acceptValidator('image/*,.pdf'), maxFileSize({ mb: 2 })]"
      :upload-extra-data="() => ({ bucket: 'showcase' })"
      show-file-list
      @success="onSuccess"
      @error="onError"
    >
      <template #label>
        Upload a file for validation demo
      </template>

      <template #tip>
        image/* or .pdf · max 2 Mb
      </template>
    </GrFileUpload>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      {{ lastResult }}
    </div>
  </div>
</template>

Custom trigger UI

No files selected yet
В этом режиме библиотека отвечает за file-handling, а триггер можно строить из любых UI primitives пакета.

Custom Ui
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrFileUpload, type GrFileUploadInstance } from '@feugene/granularity'

const uploader = ref<GrFileUploadInstance | null>(null)
const files = ref<string[]>([])

async function request(selected: File[]) {
  files.value = selected.map(file => file.name)
  return { uploaded: selected.length }
}

function openFileDialog() {
  uploader.value?.openDialog()
}
</script>

<template>
  <div class="grid gap-3">
    <GrFileUpload ref="uploader" :request="request">
      <div class="flex flex-wrap items-center gap-3">
        <GrButton type="button" @click="openFileDialog">
          Select files
        </GrButton>
        <span class="text-sm text-[var(--gr-muted-fg)]">
          {{ files.length ? files.join(', ') : 'No files selected yet' }}
        </span>
      </div>
    </GrFileUpload>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      В этом режиме библиотека отвечает за file-handling, а триггер можно строить из любых UI primitives пакета.
    </div>
  </div>
</template>

Disabled and guarded states

Limit guard
Drag files here or click to select
Limit is 1 file
Disabled state
Drag files here or click to select
Interactions are blocked in disabled mode
Readonly state
Drag files here or click to select
The set stays visible and reaches the form, but cannot be changed
  • contract.pdf · 1 KB
Try selecting more than one file in the active uploader

Disabled
<script setup lang="ts">
import { ref } from 'vue'

import { GrFileUpload } from '@feugene/granularity'

const message = ref('Try selecting more than one file in the active uploader')
const submitted = ref([new File(['contract'], 'contract.pdf', { type: 'application/pdf' })])

async function request(files: File[]) {
  message.value = `Uploaded ${files.length} file(s)`
  return { ok: true }
}

function onExceed(files: File[], limit: number) {
  message.value = `Received ${files.length} files, limit is ${limit}`
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-3">
    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Limit guard
      </div>
      <GrFileUpload
        :request="request"
        multiple
        :limit="1"
        :on-exceed="onExceed"
      >
        <template #tip>
          Limit is 1 file
        </template>
      </GrFileUpload>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Disabled state
      </div>
      <GrFileUpload disabled :request="request">
        <template #tip>
          Interactions are blocked in disabled mode
        </template>
      </GrFileUpload>
    </div>

    <div class="grid gap-2">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Readonly state
      </div>
      <GrFileUpload
        v-model="submitted"
        readonly
        show-file-list
        :request="request"
      >
        <template #tip>
          The set stays visible and reaches the form, but cannot be changed
        </template>
      </GrFileUpload>
    </div>

    <div class="lg:col-span-3 text-sm text-[var(--gr-muted-fg)]">
      {{ message }}
    </div>
  </div>
</template>

Upload progress with default bar

Drag files here or click to select
phase: idle · last progress: 0%
Дефолтный `GrProgressBar` рендерится в зарезервированной зоне — переключение `idle ↔ uploading ↔ success` не вызывает layout shift. Прогресс приходит из `ctx.onProgress`, который пользователь сам вызывает в своём `request`.

Progress
<script setup lang="ts">
import { ref } from 'vue'

import { GrFileUpload } from '@feugene/granularity'
import type { GrFileUploadRequestCtx } from '@feugene/granularity'

const lastPercent = ref(0)
const phase = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')

/**
 * Имитация загрузки с реальным прогрессом: пользовательский `request` вызывает
 * `ctx.onProgress` так же, как это делает `axios.onUploadProgress` или `xhr.upload.onprogress`.
 */
async function request(files: File[], ctx: GrFileUploadRequestCtx) {
  const total = files.reduce((sum, file) => sum + file.size, 0) || 1
  let loaded = 0
  const step = Math.max(1, Math.floor(total / 20))

  while (loaded < total) {
    if (ctx.signal.aborted)
      throw new Error('aborted')
    await new Promise(resolve => setTimeout(resolve, 80))
    loaded = Math.min(total, loaded + step)
    ctx.onProgress?.({
      percent: (loaded / total) * 100,
      loaded,
      total,
      indeterminate: false,
    })
  }

  return { uploaded: files.length }
}

function onProgress(percent: number) {
  lastPercent.value = percent
}

function onStateChange(state: { phase: 'idle' | 'uploading' | 'success' | 'error' }) {
  phase.value = state.phase
}
</script>

<template>
  <div class="grid gap-3">
    <GrFileUpload
      :request="request"
      multiple
      @progress="onProgress"
      @state-change="onStateChange"
    />

    <div class="text-sm text-[var(--gr-muted-fg)] tabular-nums">
      phase: <strong>{{ phase }}</strong> · last progress: <strong>{{ Math.round(lastPercent) }}%</strong>
    </div>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      Дефолтный `GrProgressBar` рендерится в зарезервированной зоне — переключение
      `idle ↔ uploading ↔ success` не вызывает layout shift. Прогресс приходит из
      `ctx.onProgress`, который пользователь сам вызывает в своём `request`.
    </div>
  </div>
</template>

Custom progress via scoped slot

Drag files here or click to select

Progress Slot
<script setup lang="ts">
import { GrButton, GrFileUpload } from '@feugene/granularity'
import type { GrFileUploadRequestCtx, GrUploadState } from '@feugene/granularity'

/**
 * Кастомный UI прогресса через scoped-слот `progress`.
 * Полностью отключаем дефолтный `GrProgressBar` через `:show-progress="false"`.
 */
async function request(files: File[], ctx: GrFileUploadRequestCtx) {
  const total = files.reduce((sum, file) => sum + file.size, 0) || 1
  let loaded = 0
  const step = Math.max(1, Math.floor(total / 25))

  while (loaded < total) {
    if (ctx.signal.aborted)
      throw new Error('aborted')
    await new Promise(resolve => setTimeout(resolve, 60))
    loaded = Math.min(total, loaded + step)
    ctx.onProgress?.({
      percent: (loaded / total) * 100,
      loaded,
      total,
      indeterminate: false,
    })
  }

  return { uploaded: files.length }
}

function phaseLabel(state: GrUploadState): string {
  if (state.phase === 'uploading')
    return state.indeterminate ? 'Sending…' : 'Uploading'
  if (state.phase === 'success')
    return 'Done'
  if (state.phase === 'error')
    return 'Failed'
  return 'Idle'
}
</script>

<template>
  <GrFileUpload
    :request="request"
    :show-progress="false"
    multiple
  >
    <template #progress="{ percent, indeterminate, phase, abort }">
      <div
        v-if="phase !== 'idle'"
        class="mt-3 flex items-center gap-3 rounded-md border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3"
      >
        <div
          class="relative h-10 w-10 shrink-0 rounded-full"
          :style="{
            background: indeterminate
              ? 'conic-gradient(var(--gr-primary) 0 25%, var(--gr-muted) 0)'
              : `conic-gradient(var(--gr-primary) 0 ${percent}%, var(--gr-muted) 0)`,
            transition: 'background 120ms linear',
          }"
        >
          <div class="absolute inset-1 rounded-full bg-[var(--gr-bg)] grid place-items-center text-[10px] tabular-nums">
            {{ indeterminate ? '…' : `${Math.round(percent)}%` }}
          </div>
        </div>

        <div class="flex-1 text-sm">
          <div class="font-medium">
            {{ phaseLabel({ phase, percent, indeterminate } as GrUploadState) }}
          </div>
          <div class="text-[var(--gr-muted-fg)]">
            Custom circular indicator via <code>#progress</code> slot
          </div>
        </div>

        <GrButton
          v-if="phase === 'uploading'"
          size="sm"
          variant="ghost"
          @click="abort"
        >
          Cancel
        </GrButton>
      </div>
    </template>
  </GrFileUpload>
</template>

Action endpoint with real XHR progress

Drag files here or click to select
phase: idle
Endpoint: https://httpbin.org/post. Прогресс приходит из XMLHttpRequest.upload.onprogress, отмена — через внутренний AbortController.

Action Xhr
<script setup lang="ts">
import { ref } from 'vue'

import { GrFileUpload } from '@feugene/granularity'
import type { GrUploadState } from '@feugene/granularity'

/**
 * Сценарий `action`: компонент сам шлёт POST `multipart/form-data` через XHR.
 * `xhr.upload.onprogress` даёт реальный процент без какого-либо кода со стороны
 * пользователя. Здесь используется публичный echo-endpoint — для просмотра
 * прогресса лучше загружать файлы потяжелее.
 */
const ENDPOINT = 'https://httpbin.org/post'

const phase = ref<GrUploadState['phase']>('idle')
const lastError = ref<string | null>(null)

function onStateChange(state: GrUploadState) {
  phase.value = state.phase
  if (state.phase !== 'error')
    lastError.value = null
}

function onError(error: unknown) {
  lastError.value = error instanceof Error ? error.message : String(error)
}
</script>

<template>
  <div class="grid gap-3">
    <GrFileUpload
      :action="ENDPOINT"
      name="file"
      multiple
      :upload-extra-data="() => ({ source: 'granularity-showcase' })"
      @state-change="onStateChange"
      @error="onError"
    />

    <div class="text-sm text-[var(--gr-muted-fg)] tabular-nums">
      phase: <strong>{{ phase }}</strong>
      <span v-if="lastError" class="text-[var(--danger)]"> · {{ lastError }}</span>
    </div>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      Endpoint: <code>{{ ENDPOINT }}</code>. Прогресс приходит из
      <code>XMLHttpRequest.upload.onprogress</code>, отмена — через
      внутренний <code>AbortController</code>.
    </div>
  </div>
</template>

Sizes

size="xs"
Drag files here or click to select
PDF or PNG, up to 10 MB
size="sm"
Drag files here or click to select
PDF or PNG, up to 10 MB
size="md"
Drag files here or click to select
PDF or PNG, up to 10 MB
size="lg"
Drag files here or click to select
PDF or PNG, up to 10 MB

Sizes
<script setup lang="ts">
import { GrFileUpload } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const
</script>

<template>
  <div class="grid gap-4">
    <div v-for="size in sizes" :key="size" class="grid gap-2">
      <div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
        size="{{ size }}"
      </div>

      <GrFileUpload :size="size" placeholder="Drag files here or click to select">
        <template #tip>
          PDF or PNG, up to 10 MB
        </template>
      </GrFileUpload>
    </div>
  </div>
</template>

Accept, remove and retry

Drag files here or click to select
Status:
`accept` фильтрует и системный диалог, и перетаскивание. Лишний файл убирается крестиком в списке — повтор уйдёт уже без него.

Retry
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrFileUpload, type GrFileUploadInstance } from '@feugene/granularity'

const uploader = ref<GrFileUploadInstance>()
const failNext = ref(true)
const status = ref('')

// Первая попытка падает намеренно: показываем, что после ошибки набор файлов
// остаётся и повторить можно без повторного выбора.
async function request(files: File[]): Promise<{ ok: true }> {
  await new Promise(resolve => setTimeout(resolve, 600))

  if (failNext.value) {
    failNext.value = false
    throw new Error(`Server rejected ${files.length} file(s)`)
  }

  return { ok: true }
}
</script>

<template>
  <div class="grid gap-3">
    <GrFileUpload
      ref="uploader"
      :request="request"
      accept="image/*,.pdf"
      multiple
      show-file-list
      @error="status = String($event)"
      @success="status = 'uploaded'"
    />

    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" variant="outline" @click="uploader?.retry()">
        Retry upload
      </GrButton>
      <span class="text-sm text-[var(--gr-muted-fg)]">
        Status: <span class="font-semibold text-[var(--gr-fg)]">{{ status }}</span>
      </span>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      `accept` фильтрует и системный диалог, и перетаскивание. Лишний файл убирается крестиком в списке —
      повтор уйдёт уже без него.
    </div>
  </div>
</template>

Per-file upload with previews

Drag files here or click to select
Загружено:

Per File
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrFileUpload } from '@feugene/granularity'

type UploadedFile = { id: string, name: string }

const uploaded = ref<UploadedFile[]>([])
const failed = ref<string[]>([])

// Каждый второй файл падает с первой попытки: так видно, что повторяется
// именно упавшая строка, а соседние остаются загруженными.
const attempts = new Map<string, number>()

async function request(files: File[]): Promise<UploadedFile> {
  const file = files[0]
  const attempt = (attempts.get(file.name) ?? 0) + 1
  attempts.set(file.name, attempt)

  await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 700))

  if (attempt === 1 && file.name.length % 2 === 0) {
    failed.value = [...new Set([...failed.value, file.name])]
    throw new Error(`Server rejected ${file.name}`)
  }

  const result = { id: `${file.name}-${attempt}`, name: file.name }
  uploaded.value = [...uploaded.value, result]
  failed.value = failed.value.filter(name => name !== file.name)
  return result
}
</script>

<template>
  <div class="grid gap-3">
    <!-- `request` зовётся с массивом из одного файла: контракт тот же, что в
         батчевом режиме, поэтому загрузчик потребителя не переписывается.
         Тип ответа (`UploadedFile`) выводится из самого `request` — payload
         события `success` типизирован им же. -->
    <GrFileUpload
      :request="request"
      upload-mode="per-file"
      :concurrency="2"
      accept="image/*"
      multiple
      preview
      show-file-list
    />

    <div class="flex flex-wrap items-center gap-2 text-xs text-[var(--gr-muted-fg)]">
      <span>Загружено:</span>
      <GrBadge v-for="item in uploaded" :key="item.id" size="sm" tone="success">
        {{ item.name }}
      </GrBadge>
      <template v-if="failed.length">
        <span>· не прошли:</span>
        <GrBadge v-for="name in failed" :key="name" size="sm" tone="danger">
          {{ name }}
        </GrBadge>
      </template>
    </div>
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components