GrFileUpload

  • core
  • forms
  • API · 29 props
  • ⌨ keys

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

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

Overview

When to take it

  • the file leaves for the server at once — action sends the multipart itself, request connects an uploader of your own;
  • there are many files — concurrency 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

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • Importing a bank statement

    The statement is sent the moment it is chosen: request is the app’s own call, it reports progress through ctx.onProgress, honours ctx.signal, and its answer types the success event. accept filters the system dialog and checks a dropped file too; maxFileSize adds a limit. The component emits its errors, and the app words them for the field by 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
  • Supplier documents, one request each

    upload-mode="per-file" gives every row its status, percent, «Cancel» and «Retry upload»: when the storage times out, one document is resent, not the whole set. concurrency keeps two requests in flight, limit stops the seventh file before anything is sent, and preview draws thumbnails for photographed documents.

    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
  • A logo row instead of a drop zone

    The default slot replaces the zone whole and hands its whole state back: the chosen files, the upload state with its phase and percent, openDialog and removeFile. The row draws the file at once and revokes its address when it changes; dropping a file on the row still works.

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

API generated from the package · cannot drift

Props

PropTypedefaultDescription
modelValuev-modelFile[] | 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 | undefinedundefinedThe address for a `multipart/form-data` POST by the built-in uploader. Needed when there is no `request`.
requestGrFileUploadRequest<TResponse> | undefinedundefinedA custom uploader (axios, fetch) instead of `action`: it receives the files and `signal`, `extraData`, `onProgress`. Stronger than `action`.
namestring | undefined"file"The name of the file field in the `FormData` of the built-in uploader. `file` by default.
multipleboolean | undefinedfalseChoosing several files at once; without it `limit` has no effect. `false` by default.
limitnumber | undefinedundefinedThe limit of files in one choice with `multiple`: beyond it come `exceed` and `error`, and there is no upload.
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[] | undefinedundefinedChecks of the set before sending, after `accept`: size, type, count. Violations arrive in `error` as one `FileValidationError`, and the upload does not start.
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 | undefinedfalseThe control is unavailable: the dialog and drag and drop do not work. Also switched on by `GrFormField`.
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.

Slots

SlotTypeDescription
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; }
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: 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; }
A progress indicator of your own instead of the built-in one.

Events

EventSignatureDescription
update:modelValue(e: "update:modelValue", files: File[]): voidThe set of files has changed: a new choice or a removal. Not to be confused with `change`.
exceed(e: "exceed", files: File[], limit: number): voidMore files were chosen than `limit` allows. The upload does not start.
success(e: "success", payload: TResponse, file?: File | undefined): void`file` arrives only in the `per-file` mode: in a batch there is nothing to report about.
error(e: "error", error: unknown, file?: File | undefined): voidThe set did not leave or the sending failed: `limit`, the validators, `beforeUpload`, the network, the server response, cancellation. `file` is present only in `per-file`.
progress
(e: "progress", percent: number, info?: GrUploadProgressInfo(e: "progress", percent: number, info?: GrUploadProgressInfo | undefined, file?: File | undefined): void
The sending progress in percent, from `0` to `100`. `file` is present only in `per-file`.
change(e: "change", files: File[]): voidThe set has been sent: in `batch` after success, in `per-file` when the whole queue has finished. Not to be confused with `update:modelValue`.
stateChange(e: "stateChange", state: GrUploadState): voidThe overall upload phase changed — `idle`, `uploading`, `success`, `error` — with the percentage.
focus(e: "focus", event: FocusEvent): voidFocus entered the zone. Moving between the field and the list buttons does not count as an event.
blur(e: "blur", event: FocusEvent): voidFocus left the zone entirely.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrFileUploadMode, GrFileUploadExtraDataValue, GrFileUploadExtraData } from '@feugene/granularity/components/GrFileUpload'

Filedist/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;
    onProgress?: (info: GrUploadProgressInfo) => void;
GrFileUploadRequesttype
type GrFileUploadRequest<TResponse = unknown> = (files: File[], ctx: GrFileUploadRequestCtx) => Promise<TResponse>;
export interface GrFileUploadProps<TResponse = unknown> {
GrFileUploadPropsinterface
interface GrFileUploadProps<TResponse = unknown> {
    modelValue?: File[];
    action?: string;
    request?: GrFileUploadRequest<TResponse>;
    name?: string;
    multiple?: boolean;
    limit?: number;
    beforeUpload?: (file: File) => boolean | Promise<unknown>;
GrFileUploadEmitsinterface
interface GrFileUploadEmits<TResponse = unknown> {
    (e: 'update:modelValue', files: File[]): void;
    (e: 'exceed', files: File[], limit: number): void;
    (e: 'success', payload: TResponse, file?: File): void;
    (e: 'error', error: unknown, file?: File): void;
    (e: 'progress', percent: number, info?: GrUploadProgressInfo, file?: File): void;
    (e: 'change', files: File[]): void;
    (e: 'stateChange', state: GrUploadState): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
}

Filedist/types/components/GrFileUpload/fileEntry.d.ts

GrFileUploadStatustype
type GrFileUploadStatus = 'pending' | 'uploading' | 'success' | 'error';
GrFileUploadEntryinterface
interface GrFileUploadEntry {
    file: File;
    status: GrFileUploadStatus;
    percent: number;
    error?: unknown;
}

Filedist/types/components/GrFileUpload/config.d.ts

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

Filedist/types/components/GrFileUpload/defaults.d.ts

GrFileUploadConfigurablePropsinterface
interface GrFileUploadConfigurableProps {
    size: GrComponentSize;
}

Filedist/types/components/GrFileUpload/grFileUploadStyles.d.ts

GrFileUploadSizetype
type GrFileUploadSize = GrComponentSize;

Filedist/types/components/GrFileUpload/safelist.d.ts

grFileUploadSafelistconst
const grFileUploadSafelist: string[];

Filedist/types/components/GrFileUpload/uploadViaXhr.d.ts

GrUploadProgressInfointerface
interface GrUploadProgressInfo {
    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);
}

Filedist/types/components/GrFileUpload/uploadState.d.ts

GrUploadPhasetype
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;

Filedist/types/fileValidation/types.d.ts

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

Filedist/types/fileValidation/FileValidationError.d.ts

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

Filedist/types/components/GrFileUpload/index.d.ts

GrFileUploadInstancetype
type GrFileUploadInstance = ComponentExposed<typeof GrFileUploadComponent>;

Accessibility

APG pattern

—

Usage from the library prose

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.

vue
<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.

ts
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.

action or request, but not both

Where the files go is set by exactly one of two props: action — a URL for a multipart POST, request — an uploader of your own. Pass both, and request does the sending while action takes part in nothing.

The “exactly one” requirement cannot be expressed by a type, and that is a limitation of Vue rather than an oversight: defineProps accepts an object type or an interface but not a discriminated union — the keys are flattened into one flat object, and vue-tsc lets through both “both at once” and “neither”. Both mistakes are therefore caught in a dev build with a warning in the console: without it the first would surface at the first choice of a file, and the second would not surface at all — a silently ignored action is indistinguishable in behaviour from one that was honoured.

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):

vue
<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.

While a file is on its way, its row has one button — “stop” (the circle-stop icon, the name “Cancel upload of …”), and “remove” appears once the upload has been stopped or has finished: two identical crosses side by side cannot be told apart. A row of the list takes the full width of the column: the name is on the left and is truncated with an ellipsis (the full one is in title), the status and the buttons stand at a shared right edge — and in a narrow container a long name does not push them out.

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.

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

function onUploaded(file: UploadedFile) { /* the payload is typed */ }
vue
<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
vue
<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.