GrFormFile
Binds file uploads to a form field and validation.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the file is the value of a field — a CV, a scan, an attachment: it is sent by the form, not by the component;
- the file is checked before submission — the size, the type, the number through
validators; - there are several files —
multipleandlimittogether with a list of what has been chosen; - a preview is needed —
previewshows a thumbnail of an image before the submission.
When to take something else
| Need | Take |
|---|---|
| The file leaves for the server at once, with progress | GrFileUpload |
| The image has to be examined | GrImageViewer |
| The value is not a file | GrInput |
The border with GrFileUpload runs along who does the sending. Here a file is an
ordinary v-model value, and it leaves together with the rest of the form; there the
component uploads by itself and shows the progress of every file.
The errors are a controlled value
v-model:errors is a two-way channel: the internal validation writes into it, and the
consumer puts the errors that came from the server into the same place. While the prop is
set, it is stronger than the internal list (the same scheme as sortKey in GrDataTable).
<GrFormFile v-model="files" v-model:errors="errors" accept="application/pdf" multiple :limit="3" />
There is one channel: the validation emit duplicated update:errors with the same payload
and has been removed.
The list of errors is declared role="alert" and is linked to the choose button through
aria-describedby — together with the aria-describedby from GrFormField, if the field
is inside one. While there are errors, the button carries aria-invalid. Before that,
“dropped a file of the wrong type” looked to a screen reader like “nothing happened”.
The validation is one for both ways of input
The set of validators is assembled in one place and goes both into the choice through the
dialog and into v-dropzone: two copies of that assembly drift apart at the very first
edit, and dragging starts behaving differently from the dialog.
The order: accept → limit → the consumer’s validators → validate. limit is sugar
over maxCountValidator: extra files are not truncated silently, the set is rejected with
an error, as by any other rule.
The list of files
In multiple every row shows the name and the size, and the remove button names its file
(aria-label) — three “Remove” buttons in a row are indistinguishable to a screen reader.
Disabled is dimmed with the cursor and with the state of the buttons themselves: an
opacity on the container would dilute both the labels and the names of the files.
Previews of pictures
preview switches thumbnails on: they appear for image/* files, and a file of any other
type stays an ordinary row. A thumbnail is square and is cropped with object-cover —
otherwise the rows of the list would jump in height following the proportions of the shots.
<GrFormFile v-model="gallery" multiple preview accept="image/*" />
The alt of a thumbnail is empty: the name of the file stands right beside it, and there is
no point announcing it twice. The object URL lives exactly as long as the file is in the
set — it is revoked as soon as the file has left the set, whatever removed it.
`readonly`
The set is visible and leaves with the form, but is changed by nothing: not by the
choice dialog, not by dragging, not by the buttons — they are not rendered in this state.
The choose button stays in the tab order and announces aria-readonly: a field has to be
reachable from the keyboard and able to explain why it does not yield.
The difference from disabled: that one switches the button off as well, that is, the field
drops out of the walk entirely.
Inside `GrForm`
The same constraints can be declared as a rule of the form — beside the rest:
const rules: GrFormRules = {
contract: [{ required: true, file: { accept: '.pdf', maxSizeMb: 1 } }],
}
The checking is done by the same validators, so the text of the error does not change — what
changes is the moment: the rule of the field keeps a bad file out of the model at once, the
rule of the form rejects the submit and enters invalid and the scroll to the first error.
The details and the full list of keys — GrForm.md.
The typical division: the constraints in rules and accept on the field as a filter for
the choice dialog.
What is missing
Sorting of the set.
Playground 15
Loading…
<GrFormFile />Install
npm i @feugene/granularityImport
import { GrFormFile } from '@feugene/granularity/components/GrFormFile'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
multiple | boolean | undefined | false | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read-only: the value is visible and leaves with the form, but is not edited. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A required field (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the buttons, the icons and the labels. |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The accessible name outside `GrFormField`. |
limit | number | undefined | undefined | The maximum number of files in the set. Extra ones are not truncated silently — the set is rejected with an error. |
validators | FileValidator[] | undefined | undefined | — |
accept | string | undefined | undefined | The W3C `accept` for the `<input type="file">` plus sugar over `acceptValidator(...)`. |
preview | boolean | undefined | false | Thumbnails for the pictures in the set. Files of other types stay a row. |
validate | ((files: File[]) => FileValidationIssue[] | Promise<FileValidationIssue[]>) | undefined | undefined | Additional (custom) validation on the side of the consumer. |
uploadText | string | undefined | undefined | — |
changeText | string | undefined | undefined | — |
removeText | string | undefined | undefined | — |
clearAllText | string | undefined | undefined | — |
errors | FileValidationIssue[] | undefined | undefined | The controlled list of errors: `v-model:errors`. If it is set, it is what is shown, and the internal validation does not overwrite it. The errors that came from the server are put here as well. Unset — the component keeps its errors itself. |
modelValuerequired | File | File[] | null | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
error | { errors: FileValidationIssue[]; } | Your own presentation of the errors instead of the default list. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: File | File[] | null] | — |
change | [value: File | File[] | null] | — |
clear | [] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
update:errors | [errors: FileValidationIssue[]] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 7
Single file selection with summary state
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrFormField, GrFormFile } from '@feugene/granularity'
const selectedFile = ref<File | null>(null)
const summary = computed(() => {
if (!(selectedFile.value instanceof File))
return 'Select a PDF or spreadsheet to populate the contract field.'
return `${selectedFile.value.name} • ${(selectedFile.value.size / 1024).toFixed(1)} KB`
})
</script>
<template>
<div class="grid gap-4">
<GrFormField label="Signed contract" for-id="showcase-form-file-basic">
<GrFormFile
v-model="selectedFile"
accept=".pdf,.xlsx,.csv"
placeholder="No contract attached yet"
upload-text="Attach file"
change-text="Replace file"
remove-text="Remove attachment"
/>
</GrFormField>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
{{ summary }}
</div>
</div>
</template>Custom validation with surfaced errors
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrFormField, GrFormFile } from '@feugene/granularity'
import type { FileValidationIssue } from '@feugene/granularity'
const selectedFile = ref<File | null>(null)
const validationMessages = ref<string[]>([])
function validateFiles(files: File[]): FileValidationIssue[] {
return files.flatMap((file) => {
const issues: FileValidationIssue[] = []
if (file.size > 1024 * 1024)
issues.push({ code: 'custom:max-size', message: 'Keep review attachments under 1 MB for faster handoff.' })
if (!file.name.endsWith('.pdf'))
issues.push({ code: 'custom:pdf-only', message: 'QA requests PDF exports for approval packets.' })
return issues
})
}
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap gap-2">
<GrBadge tone="info" radius="round">Only `.pdf`</GrBadge>
<GrBadge tone="warning" radius="round">Up to 1 MB</GrBadge>
</div>
<GrFormField
label="Approval packet"
for-id="showcase-form-file-validation"
:error="validationMessages[0]"
>
<GrFormFile
v-model="selectedFile"
accept=".pdf"
:validate="validateFiles"
placeholder="Upload approval packet"
upload-text="Upload packet"
change-text="Replace packet"
@update:errors="validationMessages = $event.map(issue => issue.message ?? issue.code)"
/>
</GrFormField>
<div class="text-sm text-[var(--gr-muted-fg)]">
Latest validation status:
<span class="font-semibold text-[var(--gr-fg)]">
{{ validationMessages[0] ?? 'Ready for upload review' }}
</span>
</div>
</div>
</template>Multiple attachment queue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrFormFile } from '@feugene/granularity'
const attachments = ref<File[]>([])
const totalSizeLabel = computed(() => {
const totalBytes = attachments.value.reduce((sum, file) => sum + file.size, 0)
return `${(totalBytes / 1024).toFixed(1)} KB`
})
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-2">
<GrBadge tone="info" radius="semi">{{ attachments.length }} files</GrBadge>
<GrBadge tone="info" radius="semi">{{ totalSizeLabel }}</GrBadge>
</div>
<GrFormFile
v-model="attachments"
multiple
accept=".png,.jpg,.pdf"
placeholder="Drop screenshots or PDF notes"
upload-text="Add assets"
change-text="Add more"
clear-all-text="Clear queue"
/>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
This scenario mirrors incident-report attachments where reviewers build a small queue before submitting the form.
</div>
</div>
</template>Image thumbnails and a read-only set
<script setup lang="ts">
import { ref } from 'vue'
import { GrFormFile, GrSwitch } from '@feugene/granularity'
const gallery = ref<File[]>([])
const locked = ref(false)
</script>
<template>
<div class="grid gap-4">
<GrSwitch v-model="locked">
Read-only
</GrSwitch>
<GrFormFile
v-model="gallery"
multiple
preview
:readonly="locked"
accept="image/*,application/pdf"
placeholder="Pick images to see thumbnails"
upload-text="Add files"
change-text="Add more"
clear-all-text="Clear all"
/>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Thumbnails appear for images only — a PDF stays a plain row. Switch the field to read-only and the set stays
visible while every way to change it goes away: no remove buttons, and dropping a file does nothing.
</div>
</div>
</template>Sizes
<script setup lang="ts">
import { ref } from 'vue'
import { GrFormField, GrFormFile } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const file = ref<File | File[] | null>(null)
</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>
<GrFormField label="Attachment">
<GrFormFile v-model="file" :size="size" accept=".pdf,.png" />
</GrFormField>
</div>
</div>
</template>Server errors and limit
До трёх файлов, только PDF
<script setup lang="ts">
import { ref } from 'vue'
import type { GrFormFileError } from '@feugene/granularity'
import { GrButton, GrFormFile, GrFormField } from '@feugene/granularity'
const files = ref<File[]>([])
// `v-model:errors` — двусторонний канал: сюда пишет и внутренняя валидация,
// и ответ сервера.
const errors = ref<GrFormFileError[]>([])
const sending = ref(false)
async function submit(): Promise<void> {
if (!files.value.length)
return
sending.value = true
await new Promise(resolve => setTimeout(resolve, 700))
sending.value = false
errors.value = [{
code: 'accept',
fileName: files.value[0]?.name,
message: 'Сервис принимает только подписанные PDF',
}]
}
</script>
<template>
<div class="grid gap-3">
<GrFormField label="Документы" hint="До трёх файлов, только PDF">
<GrFormFile
v-model="files"
v-model:errors="errors"
accept="application/pdf,.pdf"
multiple
:limit="3"
/>
</GrFormField>
<div class="flex flex-wrap items-center gap-3">
<GrButton size="sm" :loading="sending" :disabled="!files.length" @click="submit">
Отправить
</GrButton>
<GrButton size="sm" variant="ghost" :disabled="!errors.length" @click="errors = []">
Сбросить ошибки
</GrButton>
</div>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Ошибки объявляются `role="alert"` и связаны с кнопкой выбора через `aria-describedby` —
и те, что нашла валидация, и те, что вернул сервер.
</div>
</div>
</template>Rules
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { GrButton, GrForm, GrFormField, GrFormFile, type GrFormInstance, type GrFormRules } from '@feugene/granularity'
const model = reactive<{ contract: File | null }>({ contract: null })
/**
* Ограничения объявлены один раз — здесь. У поля остаётся `accept` как фильтр
* диалога: это подсказка ОС, а не проверка.
*/
const rules: GrFormRules = {
contract: [{
required: true,
file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
}],
}
const formRef = ref<GrFormInstance>()
const submitted = ref(false)
function onSubmit() {
submitted.value = true
}
function reset() {
formRef.value?.resetFields()
submitted.value = false
}
</script>
<template>
<GrForm
ref="formRef"
:model="model"
:rules="rules"
class="grid max-w-md gap-4"
@submit="onSubmit"
>
<GrFormField name="contract" label="Contract" hint="PDF up to 1 MB">
<GrFormFile v-model="model.contract" accept=".pdf,application/pdf" />
</GrFormField>
<div class="flex gap-2">
<GrButton type="submit">
Send
</GrButton>
<GrButton variant="secondary" type="button" @click="reset">
Reset
</GrButton>
</div>
<p v-if="submitted" class="text-sm text-[var(--gr-success)]">
Submitted — the file passed the form rule.
</p>
</GrForm>
</template>