GrFormFile

Package: @feugene/granularitycoreGroup: forms

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 filesmultiple and limit together with a list of what has been chosen;
  • a preview is neededpreview shows a thumbnail of an image before the submission.

When to take something else

NeedTake
The file leaves for the server at once, with progressGrFileUpload
The image has to be examinedGrImageViewer
The value is not a fileGrInput

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: acceptlimit → the consumer’s validatorsvalidate. 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…

Code
<GrFormFile />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
multipleboolean | undefinedfalse
disabledboolean | undefinedfalse
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.
requiredboolean | undefinedfalseA required field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the buttons, the icons and the labels.
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
limitnumber | undefinedundefinedThe maximum number of files in the set. Extra ones are not truncated silently — the set is rejected with an error.
validatorsFileValidator[] | undefinedundefined
acceptstring | undefinedundefinedThe W3C `accept` for the `<input type="file">` plus sugar over `acceptValidator(...)`.
previewboolean | undefinedfalseThumbnails for the pictures in the set. Files of other types stay a row.
validate((files: File[]) => FileValidationIssue[] | Promise<FileValidationIssue[]>) | undefinedundefinedAdditional (custom) validation on the side of the consumer.
uploadTextstring | undefinedundefined
changeTextstring | undefinedundefined
removeTextstring | undefinedundefined
clearAllTextstring | undefinedundefined
errorsFileValidationIssue[] | undefinedundefinedThe 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.
modelValuerequiredFile | File[] | null

Slots

SlotTypeDescription
error{ errors: FileValidationIssue[]; }Your own presentation of the errors instead of the default list.

Events

EventTypeDescription
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 / ExposeTypeDescription
focus() => void
blur() => void

Examples 7

Single file selection with summary state

No contract attached yet
Select a PDF or spreadsheet to populate the contract field.

Basic Selection
<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

Only `.pdf`Up to 1 MB
Upload approval packet
Latest validation status: Ready for upload review

Validation
<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

0 files0.0 KB
Drop screenshots or PDF notes
This scenario mirrors incident-report attachments where reviewers build a small queue before submitting the form.

Multiple 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

Pick images to see thumbnails
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.

Preview
<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

size="xs"
No files selected
size="sm"
No files selected
size="md"
No files selected
size="lg"
No files selected

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

No files selected
Ошибки объявляются `role="alert"` и связаны с кнопкой выбора через `aria-describedby` — и те, что нашла валидация, и те, что вернул сервер.

Server Errors
<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

PDF up to 1 MB

This field is required
No files selected

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>

Component documentationAll components