GrFileUpload
Берут, когда файл уходит на сервер сразу.
Когда брать
- файл уходит на сервер сразу —
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 события success — any из публичной сигнатуры ушёл.
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValue | File[] | undefined | undefined | Набор файлов — тот, что показан в списке и уйдёт в `retry`. Проп **необязателен**: без него компонент держит набор сам. Передан — набор следует за ним, и потребитель может очистить или подменить список снаружи. |
action | string | undefined | undefined | — |
request | GrFileUploadRequest<TResponse> | undefined | undefined | — |
name | string | undefined | "file" | — |
multiple | boolean | undefined | false | — |
limit | number | undefined | undefined | — |
beforeUpload | ((file: File) => boolean | Promise<unknown>) | undefined | undefined | Guard перед отправкой. Остаётся пропом, а не эмитом, осознанно: эмит не возвращает значения, а этот колбэк обязан ответить «пускать или нет». Уведомления — `exceed`, `success`, `error`, `progress` — эмиты. |
validators | FileValidator[] | undefined | undefined | — |
accept | string | undefined | undefined | W3C `accept` для `<input type="file">` — и sugar к `acceptValidator(...)`. Только атрибута мало: он фильтрует системный диалог, но не drag&drop — перетащить можно что угодно. Поэтому то же значение уходит и в валидаторы, как в `GrFormFile`. |
capture | "user" | "environment" | undefined | undefined | `capture` для мобильной камеры/микрофона. |
directory | boolean | undefined | false | Выбор каталога целиком (`webkitdirectory`). Поддержка — Chromium и Safari. |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и уходит в форму, но не редактируется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
ariaLabel | string | undefined | undefined | Доступное имя вне `GrFormField`. |
headers | Record<string, string> | undefined | undefined | — |
withCredentials | boolean | undefined | false | — |
showFileList | boolean | undefined | false | — |
uploadExtraData | ((files: File[]) => GrFileUploadExtraData | undefined) | undefined | undefined | — |
placeholder | string | undefined | undefined | i18n: надпись-подсказка в дефолтном UI. |
showProgress | boolean | undefined | true | Показывать дефолтный прогресс-бар (если не используется слот `progress`). |
progressTone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "primary" | Цветовой тон полосы прогресса в фазе `uploading`. |
progressLabel | string | undefined | undefined | aria-label для прогресс-бара. |
hideProgressOnSuccess | number | undefined | 800 | Через сколько мс после `success` скрыть прогресс-бар. `0` — не скрывать. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер дефолтного UI: поля дроп-зоны, плитка иконки, кегль подписей. |
uploadMode | GrFileUploadMode | undefined | "batch" | Как уходит набор файлов: - `batch` (по умолчанию) — весь набор одним запросом. Про отдельный файл сказать нечего, поэтому и статуса у него нет; - `per-file` — на каждый файл свой запрос (`request` зовётся с массивом из одного файла, контракт не меняется). Появляются статус и процент строки, `retryFile` и `abortFile`. |
concurrency | number | undefined | 3 | Сколько файлов грузится одновременно в режиме `per-file`. |
preview | boolean | undefined | false | Миниатюры для `image/*` в списке файлов. |
Slots
| Slot | Type | Описание |
|---|---|---|
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; } | Полностью своя зона загрузки: контрол отдаёт наружу всё своё состояние. |
label | any | Заголовок зоны вместо `placeholder`. |
tip | any | Подпись под заголовком: ограничения по типу и размеру. |
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
| Event | Type | Описание |
|---|---|---|
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 и понятное отображение последнего результата загрузки.
<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 можно собрать из других компонентов пакета.
<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.
- contract.pdf · 1 KB
<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.
<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.onProgress → state-change → дефолтный GrProgressBar. Без слотов.
Свой прогресс через слот
Кастомный круговой индикатор и кнопка отмены — через scoped-слот progress. Дефолтный бар выключен через :show-progress="false".
<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.
https://httpbin.org/post. Прогресс приходит из XMLHttpRequest.upload.onprogress, отмена — через внутренний AbortController. <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 получает толщину из того же размера.
<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() повторяет загрузку без повторного выбора.
<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 при удалении и размонтировании.
<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); зона сброса в таб-порядок не входит. Кнопки строки файла — отмена, повтор, удаление — идут дальше по обходу