GrFileUpload

  • ядро
  • Формы
  • API · 29 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

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

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

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

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • Импорт банковской выписки

    Выписка отправляется в момент выбора: request — собственный вызов приложения, он сообщает прогресс через ctx.onProgress, слушает ctx.signal, а его ответ типизирует событие success. accept фильтрует системный диалог и проверяет перетащенный файл; maxFileSize добавляет предел. Ошибки компонент отдаёт событием, а приложение формулирует их для поля по FileValidationError.code.

    Import transactionsBusiness account •••• 4821
    Drop a statement here or choose a file
    CSV or OFX export from your bank, up to 10 MB
  • Документы поставщика, по запросу на файл

    upload-mode="per-file" даёт каждой строке свой статус, процент, «Отмену» и «Повторить загрузку»: когда хранилище не отвечает, переотправляется один документ, а не весь набор. concurrency держит в полёте два запроса, limit останавливает седьмой файл до отправки, а preview рисует миниатюры сфотографированных документов.

    Supplier onboarding · Northwind TradersRegistration certificate, VAT certificate, bank letter, ID of the signatory
    Drop the documents here or choose them
    PDF or photos, up to 20 MB each, six at most
  • Строка логотипа вместо зоны перетаскивания

    Слот по умолчанию заменяет зону целиком и возвращает всё её состояние: выбранные files, state загрузки с фазой и процентом, openDialog и removeFile. Строка сразу рисует файл и отзывает его адрес при смене; перетащить файл на строку по-прежнему можно.

    Workspace logo
    NTInitials are shown until a logo is setPNG or SVG, square, up to 1 MB

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
modelValuev-modelFile[] | undefinedundefinedНабор файлов — тот, что показан в списке и уйдёт в `retry`. Проп **необязателен**: без него компонент держит набор сам. Передан — набор следует за ним, и потребитель может очистить или подменить список снаружи.
actionstring | undefinedundefinedАдрес для POST `multipart/form-data` встроенным загрузчиком. Нужен, когда нет `request`.
requestGrFileUploadRequest<TResponse> | undefinedundefinedСвой загрузчик (axios, fetch) вместо `action`: получает файлы и `signal`, `extraData`, `onProgress`. Сильнее `action`.
namestring | undefined"file"Имя поля файла в `FormData` встроенного загрузчика. По умолчанию `file`.
multipleboolean | undefinedfalseВыбор нескольких файлов за раз; без него `limit` не действует. По умолчанию `false`.
limitnumber | undefinedundefinedПредел файлов в одном выборе при `multiple`: сверх него — `exceed` и `error`, загрузки нет.
beforeUpload((file: File) => boolean | Promise<unknown>) | undefinedundefinedGuard перед отправкой. Остаётся пропом, а не эмитом, осознанно: эмит не возвращает значения, а этот колбэк обязан ответить «пускать или нет». Уведомления — `exceed`, `success`, `error`, `progress` — эмиты.
validatorsFileValidator[] | undefinedundefinedПроверки набора до отправки, после `accept`: размер, тип, число. Нарушения приходят в `error` одним `FileValidationError`, и загрузка не стартует.
acceptstring | undefinedundefinedW3C `accept` для `<input type="file">` — и sugar к `acceptValidator(...)`. Только атрибута мало: он фильтрует системный диалог, но не drag&drop — перетащить можно что угодно. Поэтому то же значение уходит и в валидаторы, как в `GrFormFile`.
capture"user" | "environment" | undefinedundefined`capture` для мобильной камеры/микрофона.
directoryboolean | undefinedfalseВыбор каталога целиком (`webkitdirectory`). Поддержка — Chromium и Safari.
disabledboolean | undefinedfalseКонтрол недоступен: диалог и перетаскивание не работают. Включается и от `GrFormField`.
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.

Слоты

SlotTypeОписание
default
{ openDialog: () => void; abort: () => void; disabled: boole{ 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: bool{ 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; }
Свой индикатор прогресса вместо встроенного.

События

EventСигнатураОписание
update:modelValue(e: "update:modelValue", files: File[]): voidНабор файлов сменился: новый выбор или удаление. Не путать с `change`.
exceed(e: "exceed", files: File[], limit: number): voidВыбрано больше файлов, чем разрешает `limit`. Загрузка не стартует.
success(e: "success", payload: TResponse, file?: File | undefined): void`file` приходит только в режиме `per-file`: в батче отчитываться нечем.
error(e: "error", error: unknown, file?: File | undefined): voidНабор не ушёл или отправка упала: `limit`, валидаторы, `beforeUpload`, сеть, ответ сервера, отмена. `file` — только в `per-file`.
progress
(e: "progress", percent: number, info?: GrUploadProgressInfo(e: "progress", percent: number, info?: GrUploadProgressInfo | undefined, file?: File | undefined): void
Прогресс отправки в процентах, от `0` до `100`. `file` — только в `per-file`.
change(e: "change", files: File[]): voidНабор отправлен: в `batch` — после успеха, в `per-file` — когда отработала вся очередь. Не путать с `update:modelValue`.
stateChange(e: "stateChange", state: GrUploadState): voidСменилась сводная фаза загрузки — `idle`, `uploading`, `success`, `error` — с процентом.
focus(e: "focus", event: FocusEvent): voidФокус вошёл в зону. Переходы между полем и кнопками списка событием не считаются.
blur(e: "blur", event: FocusEvent): voidФокус ушёл из зоны целиком.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrFileUploadMode, GrFileUploadExtraDataValue, GrFileUploadExtraData } from '@feugene/granularity/components/GrFileUpload'

Файлdist/types/components/GrFileUpload/GrFileUpload.vue.d.ts

GrFileUploadModetype
type GrFileUploadMode = 'batch' | 'per-file';
GrFileUploadExtraDataValuetype
type GrFileUploadExtraDataValue = string | Blob;
GrFileUploadExtraDatatype
type GrFileUploadExtraData = Record<string, GrFileUploadExtraDataValue | GrFileUploadExtraDataValue[]>;
GrFileUploadRequestCtxtype
type GrFileUploadRequestCtx = {
    signal: AbortSignal;
    extraData?: GrFileUploadExtraData;
    /**
     * Колбэк прогресса аплоада. Пользовательский `request` (например, axios)
     * должен вызывать его из `onUploadProgress`, чтобы `GrFileUpload` отображал
     * прогресс через слот `progress` / дефолтный `GrProgressBar`.
     */
    onProgress?: (info: GrUploadProgressInfo) => void;
GrFileUploadRequesttype
type GrFileUploadRequest<TResponse = unknown> = (files: File[], ctx: GrFileUploadRequestCtx) => Promise<TResponse>;
/**
 * Пропы `GrFileUpload`.
 *
 * Либо `action` (URL для POST multipart/form-data), либо `request` — кастомный
 * загрузчик (например, через axios). Если переданы оба — приоритет у `request`,
 * а `action` не используется; в dev-сборке об этом предупреждает консоль.
 *
 * `placeholder` — надпись в дефолтном UI-варианте (без слота default).
 */
export interface GrFileUploadProps<TResponse = unknown> {
GrFileUploadPropsinterface

Пропы GrFileUpload. Либо action (URL для POST multipart/form-data), либо request — кастомный загрузчик (например, через axios). Если переданы оба — приоритет у request, а action не используется; в dev-сборке об этом предупреждает консоль. placeholder — надпись в дефолтном UI-варианте (без слота default).

interface GrFileUploadProps<TResponse = unknown> {
    /**
     * Набор файлов — тот, что показан в списке и уйдёт в `retry`.
     *
     * Проп **необязателен**: без него компонент держит набор сам. Передан —
     * набор следует за ним, и потребитель может очистить или подменить список
     * снаружи.
     */
    modelValue?: File[];
    /** Адрес для POST `multipart/form-data` встроенным загрузчиком. Нужен, когда нет `request`. */
    action?: string;
    /**
     * Свой загрузчик (axios, fetch) вместо `action`: получает файлы и `signal`, `extraData`,
     * `onProgress`. Сильнее `action`.
     */
    request?: GrFileUploadRequest<TResponse>;
    /** Имя поля файла в `FormData` встроенного загрузчика. По умолчанию `file`. */
    name?: string;
    /** Выбор нескольких файлов за раз; без него `limit` не действует. По умолчанию `false`. */
    multiple?: boolean;
    /** Предел файлов в одном выборе при `multiple`: сверх него — `exceed` и `error`, загрузки нет. */
    limit?: number;
    /**
     * Guard перед отправкой. Остаётся пропом, а не эмитом, осознанно: эмит не
     * возвращает значения, а этот колбэк обязан ответить «пускать или нет».
     * Уведомления — `exceed`, `success`, `error`, `progress` — эмиты.
     */
    beforeUpload?: (file: File) => boolean | Promise<unknown>;
GrFileUploadEmitsinterface
interface GrFileUploadEmits<TResponse = unknown> {
    /** Набор файлов сменился: новый выбор или удаление. Не путать с `change`. */
    (e: 'update:modelValue', files: File[]): void;
    /** Выбрано больше файлов, чем разрешает `limit`. Загрузка не стартует. */
    (e: 'exceed', files: File[], limit: number): void;
    /** `file` приходит только в режиме `per-file`: в батче отчитываться нечем. */
    (e: 'success', payload: TResponse, file?: File): void;
    /**
     * Набор не ушёл или отправка упала: `limit`, валидаторы, `beforeUpload`, сеть, ответ сервера,
     * отмена. `file` — только в `per-file`.
     */
    (e: 'error', error: unknown, file?: File): void;
    /** Прогресс отправки в процентах, от `0` до `100`. `file` — только в `per-file`. */
    (e: 'progress', percent: number, info?: GrUploadProgressInfo, file?: File): void;
    /**
     * Набор отправлен: в `batch` — после успеха, в `per-file` — когда отработала вся очередь.
     * Не путать с `update:modelValue`.
     */
    (e: 'change', files: File[]): void;
    /** Сменилась сводная фаза загрузки — `idle`, `uploading`, `success`, `error` — с процентом. */
    (e: 'stateChange', state: GrUploadState): void;
    /** Фокус вошёл в зону. Переходы между полем и кнопками списка событием не считаются. */
    (e: 'focus', event: FocusEvent): void;
    /** Фокус ушёл из зоны целиком. */
    (e: 'blur', event: FocusEvent): void;
}

Файлdist/types/components/GrFileUpload/fileEntry.d.ts

GrFileUploadStatustype

Состояние одного файла в пофайловом режиме (uploadMode="per-file"). Батчевый режим по-прежнему знает только сводное GrUploadState: весь набор уходит одним запросом, и говорить про отдельный файл там нечем.

type GrFileUploadStatus = 'pending' | 'uploading' | 'success' | 'error';
GrFileUploadEntryinterface
interface GrFileUploadEntry {
    file: File;
    status: GrFileUploadStatus;
    /** Процент `0..100`. У `pending` — 0, у `success` — 100. */
    percent: number;
    /** Причина ошибки, если `status === 'error'`. */
    error?: unknown;
}

Файлdist/types/components/GrFileUpload/config.d.ts

grFileUploadConfigconst
const grFileUploadConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrFileUpload">;

Файлdist/types/components/GrFileUpload/defaults.d.ts

GrFileUploadConfigurablePropsinterface

Пропы GrFileUpload, настраиваемые глобально через componentDefaults.

interface GrFileUploadConfigurableProps {
    size: GrComponentSize;
}

Файлdist/types/components/GrFileUpload/grFileUploadStyles.d.ts

GrFileUploadSizetype
type GrFileUploadSize = GrComponentSize;

Файлdist/types/components/GrFileUpload/safelist.d.ts

grFileUploadSafelistconst
const grFileUploadSafelist: string[];

Файлdist/types/components/GrFileUpload/uploadViaXhr.d.ts

GrUploadProgressInfointerface
interface GrUploadProgressInfo {
    /** Процент `0..100`. Для запросов без `lengthComputable` значение `0` и `indeterminate = true`. */
    percent: number;
    loaded: number;
    total: number;
    indeterminate: boolean;
}
GrUploadAbortErrorclass
class GrUploadAbortError extends Error {
    constructor(message?: string);
}
GrUploadHttpErrorclass
class GrUploadHttpError extends Error {
    status: number;
    payload: unknown;
    constructor(status: number, payload: unknown);
}

Файлdist/types/components/GrFileUpload/uploadState.d.ts

GrUploadPhasetype

Состояние процесса загрузки в GrFileUpload. Используется как для внутреннего управления UI, так и пробрасывается наружу через scoped-слоты (progress, default) и defineExpose.

type GrUploadPhase = 'idle' | 'uploading' | 'success' | 'error';
GrUploadStateIdleinterface
interface GrUploadStateIdle {
    phase: 'idle';
    percent: 0;
    indeterminate: false;
    loaded: 0;
    total: 0;
}
GrUploadStateUploadinginterface
interface GrUploadStateUploading {
    phase: 'uploading';
    percent: number;
    indeterminate: boolean;
    loaded: number;
    total: number;
}
GrUploadStateSuccessinterface
interface GrUploadStateSuccess {
    phase: 'success';
    percent: 100;
    indeterminate: false;
    loaded: number;
    total: number;
}
GrUploadStateErrorinterface
interface GrUploadStateError {
    phase: 'error';
    percent: number;
    indeterminate: false;
    loaded: number;
    total: number;
    error: unknown;
}
GrUploadStatetype
type GrUploadState = GrUploadStateIdle | GrUploadStateUploading | GrUploadStateSuccess | GrUploadStateError;

Файлdist/types/fileValidation/types.d.ts

FileValidatorSourcetype

Откуда пришёл набор: выбор в диалоге, перетаскивание или проверка формы перед отправкой (правило file у GrForm). Последний случай отличают не ради симметрии: дорогой валидатор вправе работать на submit и молчать на каждом выборе файла.

type FileValidatorSource = 'input' | 'drop' | 'form';
FileValidatortype
type FileValidator = (input: FileValidatorInput) => FileValidationIssue[] | Promise<FileValidationIssue[]>;

Файлdist/types/fileValidation/FileValidationError.d.ts

FileValidationErrorclass
class FileValidationError extends Error {
    code: FileValidationIssueCode;
    file?: File;
    issues: FileValidationIssue[];
    constructor(issues: FileValidationIssue[], files?: File[], message?: string);
}

Файлdist/types/components/GrFileUpload/index.d.ts

GrFileUploadInstancetype
type GrFileUploadInstance = ComponentExposed<typeof GrFileUploadComponent>;

Доступность

Паттерн APG

—

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

Использование из прозы библиотеки

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

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

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

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

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

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

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

ts
const uploader = ref<GrFileUploadInstance>()

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

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

action или request, но не оба

Куда уходят файлы, задаёт ровно один из двух пропов: action — URL для POST multipart, request — свой загрузчик. Передашь оба — отправит request, а action не участвует ни в чём.

Требование «ровно один» типом не выражается, и это ограничение Vue, а не недосмотр: defineProps принимает объектный тип или интерфейс, но не discriminated union — ключи схлопываются в плоский объект, и vue-tsc пропускает и «оба сразу», и «ни одного». Поэтому обе ошибки ловятся в dev-сборке предупреждением в консоли: без него первая всплывала бы на первом выборе файла, а вторая не всплывала бы вовсе — молча проигнорированный action по поведению неотличим от учтённого.

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

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

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

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

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

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

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

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

Пока файл едет, у его строки одна кнопка — «остановить» (значок circle-stop, имя «Cancel upload of …»), а «убрать» появляется, когда загрузка остановлена или закончилась: два одинаковых крестика рядом не различить. Строка списка занимает всю ширину колонки: имя слева и усекается многоточием (полное — в title), статус и кнопки стоят у общего правого края — и в узком контейнере длинное имя их не выталкивает.

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

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

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

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

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

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

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

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

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

Превью

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

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

Контролируемый набор
vue
<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 не объявляет вовсе. Прогресс в процентах туда не идёт — диктор захлебнётся.