GrFileUpload

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда файл уходит на сервер сразу.

Когда брать

  • файл уходит на сервер сразуaction шлёт multipart сам, request подключает свой загрузчик;
  • файлов многоconcurrency ограничивает параллельные запросы, limit — их число;
  • загрузка длинная — прогресс на файл, hideProgressOnSuccess убирает полосу после успеха;
  • нужна зона перетаскивания — drag&drop из системы плюс выбор кнопкой и с клавиатуры;
  • вид полностью свой — слот отдаёт зону потребителю, оставляя ему всю механику.

Когда взять другое

НужноБерите
Файл — значение формы и уезжает с нейGrFormFile
Изображение нужно рассмотретьGrImageViewer
Нужен только индикатор процессаGrProgressBar
Ошибка ответа сервераGrResponseErrorBanner

`accept` фильтрует диалог, валидатор — перетаскивание

accept уходит и на <input type="file">, и первым валидатором в цепочку. Только атрибута мало: он ограничивает системный диалог, но перетащить в зону можно что угодно — без валидатора лишний файл дошёл бы до сервера.

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

Рядом — capture (камера на мобильном) и directory (webkitdirectory, Chromium и Safari).

Fallthrough-атрибуты компонент не перекладывает на input: class и style принадлежат зоне, и inheritAttrs: false увёл бы их с неё. Атрибуты самого input объявлены пропами явно.

Набор файлов: убрать и повторить

showFileList показывает выбранные файлы; у каждого есть кнопка удаления, а retry() повторяет загрузку текущего набора — после ошибки выбирать файлы заново не нужно.

const uploader = ref<GrFileUploadInstance>()

uploader.value?.retry()
uploader.value?.removeFile(file) // удаление обрывает идущую загрузку: она была про прежний набор
uploader.value?.abort()

Удаление последнего файла возвращает состояние в idle: показывать ошибку от набора, которого больше нет, не о чем.

Режимы загрузки

uploadMode решает, чем именно является набор файлов:

  • batch (по умолчанию) — весь набор уходит одним запросом (request(files, ctx)). Состояние одно на набор: про отдельный файл сказать нечего, и статуса у него нет;
  • per-file — на каждый файл свой запрос. request при этом зовётся с массивом из одного файла: контракт не меняется, и загрузчик потребителя (axios и прочие) продолжает работать без правок.

В пофайловом режиме у строки появляются статус (pending/uploading/ success/error) и процент, а у компонента — retryFile(file) и abortFile(file):

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

concurrency (по умолчанию 3) ограничивает число одновременных запросов: «пофайлово» без него означало бы «все сразу», и сотня файлов открыла бы сотню соединений.

Сводное состояние считается по худшему исходу: грузимся, пока грузится хоть один; ошибка, если после завершения есть хоть одна; успех — когда успешны все. Процент — среднее по файлам: взвешенная по байтам полоса дёргалась бы назад, когда большой файл стартует после маленького.

Отмена одного файла — не ошибка: строка возвращается в очередь, и её можно повторить или убрать.

Эмиты success, error и progress получают необязательный хвостовой аргумент file — он есть только в пофайловом режиме.

Прогресс не исчезает мгновенно

Полоса на GrProgressBar показывается сама, пока идёт отправка, и после успеха держится ещё hideProgressOnSuccess миллисекунд — по умолчанию 800. Задержка не косметическая: полоса, пропадающая в тот же кадр, в котором дошла до конца, читается как «загрузка сорвалась», а не «готово». 0 оставляет полосу навсегда — это режим для своего UI, который убирает её сам.

showProgress: false выключает дефолтную полосу целиком: она не нужна, когда прогресс рисует слот #progress или когда файлы мелкие и полоса успевает только мигнуть. progressTone красит её в фазе отправки, progressLabel даёт имя для скринридера.

Ответ сервера

Компонент дженерик по ответу: TResponse выводится из request и типизирует payload события successany из публичной сигнатуры ушёл.

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

function onUploaded(file: UploadedFile) { /* payload типизирован */ }
<GrFileUpload :request="upload" @success="onUploaded" />

Параметр типа задаётся именно так, через request: в шаблоне SFC синтаксиса <GrFileUpload<UploadedFile>> не существует — это TSX, а не Vue-шаблон. Ветка action типом ответа не управляет: что вернёт сервер, знает только потребитель.

Превью

preview рисует миниатюру для файлов image/* в списке. Ссылка (URL.createObjectURL) отзывается при удалении файла, смене набора и размонтировании: без этого blob висит в памяти вкладки до перезагрузки.

Миниатюра декоративна (alt="") — имя файла рядом уже есть, и дублировать его диктору незачем.

Контролируемый набор

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

modelValue необязателен: без него компонент держит набор сам. Передан — набор следует за пропом, и его можно подменить или очистить снаружи, например после того как форма отправлена.

update:modelValue эмитится, когда меняется сам набор: новый выбор или удаление файла. Не путать с change — тот значит «загрузка завершилась», и это другой момент.

Разница с GrFormFile осталась в назначении, а не в наличии модели: GrFileUpload про отправку (action/request, прогресс, повтор, пофайловые статусы), GrFormFile — про поле формы.

action и request — граница другого рода: заданы оба, работает request; не задан ни один — dev-сборка предупреждает при монтировании, а не на первом выборе файла. Выразить требование типом (discriminated union) в SFC нельзя: defineProps принимает только объектный тип или интерфейс.

Guard и уведомления

beforeUpload — проп-колбэк: он обязан вернуть «пускать или нет», а эмит значения не возвращает. Всё остальное — эмиты: exceed (файлов больше limit), progress, success, error, change, stateChange.

:on-exceed="fn" продолжает работать: у Vue эмит и приходит пропом-слушателем.

Гонки и хвосты

  • Два быстрых выбора подряд идут внахлёст (валидаторы асинхронны). Актуален всегда последний: у запуска есть номер, и отставший тихо сходит с дистанции, не обрывая загрузку соседа.
  • При размонтировании компонент абортит активный XHR и снимает таймер скрытия успеха — иначе загрузка продолжалась бы, а таймер дёргал бы состояние уничтоженного инстанса.
  • Кастомный request не обязан звать onProgress. Тогда итоговый объём берётся из размеров файлов, а не из нуля: «100%» при total: 0 потребитель прочитал бы как «загружено ноль».

Доступность

Доступный контрол — сам нативный <input type="file">; зона роли-виджета не получает, иначе input внутри неё теряется для скринридера (nested-interactive). Фокус input’а зона показывает через focus-within, disabled гасится фоном, а не opacity: прозрачность разбавляет выверенные на AA токены текста.

disabled и readonly приходят и от GrFormField, и от GrForm — не только из собственных пропов. readonly при этом действительно запрещает ввод: набор виден и уходит в форму, но диалог не откроется и drop не примут. Атрибута readonly у <input type="file"> в HTML нет, поэтому системный диалог гасится отменой действия по умолчанию — input остаётся в порядке Tab и объявляется как «только чтение».

Фазы загрузки объявляет живой регион (role="status"), существующий с первого рендера: регион, появляющийся сразу с текстом, часть AT не объявляет вовсе. Прогресс в процентах туда не идёт — диктор захлебнётся.

Playground 22

Загружается…

Код
<GrFileUpload />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
modelValueFile[] | undefinedundefinedНабор файлов — тот, что показан в списке и уйдёт в `retry`. Проп **необязателен**: без него компонент держит набор сам. Передан — набор следует за ним, и потребитель может очистить или подменить список снаружи.
actionstring | undefinedundefined
requestGrFileUploadRequest<TResponse> | undefinedundefined
namestring | undefined"file"
multipleboolean | undefinedfalse
limitnumber | undefinedundefined
beforeUpload((file: File) => boolean | Promise<unknown>) | undefinedundefinedGuard перед отправкой. Остаётся пропом, а не эмитом, осознанно: эмит не возвращает значения, а этот колбэк обязан ответить «пускать или нет». Уведомления — `exceed`, `success`, `error`, `progress` — эмиты.
validatorsFileValidator[] | undefinedundefined
acceptstring | undefinedundefinedW3C `accept` для `<input type="file">` — и sugar к `acceptValidator(...)`. Только атрибута мало: он фильтрует системный диалог, но не drag&drop — перетащить можно что угодно. Поэтому то же значение уходит и в валидаторы, как в `GrFormFile`.
capture"user" | "environment" | undefinedundefined`capture` для мобильной камеры/микрофона.
directoryboolean | undefinedfalseВыбор каталога целиком (`webkitdirectory`). Поддержка — Chromium и Safari.
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
ariaLabelstring | undefinedundefinedДоступное имя вне `GrFormField`.
headersRecord<string, string> | undefinedundefined
withCredentialsboolean | undefinedfalse
showFileListboolean | undefinedfalse
uploadExtraData((files: File[]) => GrFileUploadExtraData | undefined) | undefinedundefined
placeholderstring | undefinedundefinedi18n: надпись-подсказка в дефолтном UI.
showProgressboolean | undefinedtrueПоказывать дефолтный прогресс-бар (если не используется слот `progress`).
progressTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"primary"Цветовой тон полосы прогресса в фазе `uploading`.
progressLabelstring | undefinedundefinedaria-label для прогресс-бара.
hideProgressOnSuccessnumber | undefined800Через сколько мс после `success` скрыть прогресс-бар. `0` — не скрывать.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер дефолтного UI: поля дроп-зоны, плитка иконки, кегль подписей.
uploadModeGrFileUploadMode | undefined"batch"Как уходит набор файлов: - `batch` (по умолчанию) — весь набор одним запросом. Про отдельный файл сказать нечего, поэтому и статуса у него нет; - `per-file` — на каждый файл свой запрос (`request` зовётся с массивом из одного файла, контракт не меняется). Появляются статус и процент строки, `retryFile` и `abortFile`.
concurrencynumber | undefined3Сколько файлов грузится одновременно в режиме `per-file`.
previewboolean | undefinedfalseМиниатюры для `image/*` в списке файлов.

Slots

SlotTypeОписание
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; }Полностью своя зона загрузки: контрол отдаёт наружу всё своё состояние.
labelanyЗаголовок зоны вместо `placeholder`.
tipanyПодпись под заголовком: ограничения по типу и размеру.
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; }Свой индикатор прогресса вместо встроенного.

Events

EventTypeОписание
update:modelValue[File[]]Набор файлов сменился: новый выбор или удаление. Не путать с `change`.
exceed[File[], number]Выбрано больше файлов, чем разрешает `limit`. Загрузка не стартует.
success[TResponse, File | undefined]`file` приходит только в режиме `per-file`: в батче отчитываться нечем.
error[unknown, File | undefined]
progress[number, GrUploadProgressInfo | undefined, File | undefined]
change[File[]]
stateChange[GrUploadState]
focus[FocusEvent]
blur[FocusEvent]

Примеры 9

Валидаторы вместе с отправкой

Главный сценарий для GrFileUpload: validators, upload lifecycle и понятное отображение последнего результата загрузки.

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>

Покрывает основной integration-case между компонентом и utility-слоем fileValidation.

Своя зона выбора

Показываем режим без стандартной dropzone-разметки: GrFileUpload остаётся orchestrator-слоем, а 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>

Выключенное состояние и ограничения

Отдельно фиксируем не happy-path режимы: disabled, limit guard и обратную связь через onExceed.

Limit guard
Перетащите файлы сюда или нажмите для выбора
Limit is 1 file
Disabled state
Перетащите файлы сюда или нажмите для выбора
Interactions are blocked in disabled mode
Readonly state
Перетащите файлы сюда или нажмите для выбора
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>

Не-happy-path нужен отдельно, чтобы быстро проверить доступность, disable-state и защиту от превышения лимита.

Прогресс загрузки штатной полосой

Дефолтный GrProgressBar в зарезервированной зоне: переключение idle ↔ uploading ↔ success без layout shift. Прогресс приходит из ctx.onProgress, который вызывает пользовательский request — этот контракт совместим с axios.onUploadProgress.

Перетащите файлы сюда или нажмите для выбора
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>

Покрывает связку ctx.onProgressstate-change → дефолтный GrProgressBar. Без слотов.

Свой прогресс через слот

Кастомный круговой индикатор и кнопка отмены — через scoped-слот progress. Дефолтный бар выключен через :show-progress="false".

Перетащите файлы сюда или нажмите для выбора

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>

Demonstrates #progress slot payload: percent, indeterminate, phase, files, abort, state.

Точка приёма и настоящий прогресс XHR

Сценарий action: компонент сам формирует multipart/form-data и отправляет POST через XMLHttpRequest, давая реальный upload.onprogress без какого-либо кода пользователя. Отмена — внутренний AbortController.

Перетащите файлы сюда или нажмите для выбора
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>

Подтверждает миграцию с fetch на XMLHttpRequest: для action-режима теперь доступен реальный процент. Для просмотра прогресса используй файлы >1 МБ.

Шкала размеров

Меняются поля дроп-зоны, плитка иконки и кегль подписей; вложенный GrProgressBar получает толщину из того же размера.

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 фильтрует и диалог, и перетаскивание; набор файлов после ошибки остаётся, лишний убирается из списка, а retry() повторяет загрузку без повторного выбора.

Перетащите файлы сюда или нажмите для выбора
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>

Пофайловая загрузка с превью

uploadMode="per-file" отправляет каждый файл своим запросом (request при этом зовётся с массивом из одного файла — контракт не меняется), concurrency ограничивает число одновременных соединений, а у строки появляются статус, процент, отмена и повтор именно её. preview рисует миниатюры для image/* и честно отзывает object URL при удалении и размонтировании.

Перетащите файлы сюда или нажмите для выбора
Загружено:

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>

Доступность

Паттерн APG
Клавиши
остановка Tab — сам <input type="file"> (визуально скрыт, фокус показывает обёртка через focus-within); зона сброса в таб-порядок не входит. Кнопки строки файла — отмена, повтор, удаление — идут дальше по обходу

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты