GrFormFile

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда файл — значение поля.

Когда брать

  • файл — значение поля — резюме, скан, вложение: отправляет форма, а не компонент;
  • файл проверяется до отправки — размер, тип, количество через validators;
  • файлов несколькоmultiple и limit вместе со списком выбранного;
  • нужен предпросмотрpreview показывает миниатюру изображения до отправки.

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

НужноБерите
Файл уходит на сервер сразу, с прогрессомGrFileUpload
Изображение нужно рассмотретьGrImageViewer
Значение — не файлGrInput

Граница с GrFileUpload проходит по тому, кто отправляет. Здесь файл — обычное значение v-model, и оно уезжает вместе с остальной формой; там компонент грузит сам и показывает прогресс каждого файла.

Ошибки — контролируемое значение

v-model:errors — двусторонний канал: туда пишет внутренняя валидация, и туда же потребитель кладёт ошибки, пришедшие с сервера. Пока проп задан, он сильнее внутреннего списка (та же схема, что sortKey у GrDataTable).

<GrFormFile v-model="files" v-model:errors="errors" accept="application/pdf" multiple :limit="3" />

Канал один: эмит validation дублировал update:errors той же нагрузкой и снят.

Список ошибок объявляется role="alert" и связан с кнопкой выбора через aria-describedby — вместе с aria-describedby от GrFormField, если поле внутри него. Пока ошибки есть, кнопка несёт aria-invalid. До этого «уронил файл не того типа» выглядело для скринридера как «ничего не произошло».

Валидация одна на оба пути ввода

Набор валидаторов собирается в одном месте и уходит и в выбор через диалог, и в v-dropzone: две копии этой сборки разъезжаются при первой же правке, и перетаскивание начинает вести себя не так, как диалог.

Порядок: acceptlimitvalidators потребителя → validate. limit — сахар к maxCountValidator: лишние файлы не обрезаются молча, набор отбивается ошибкой, как любым другим правилом.

Список файлов

В multiple каждая строка показывает имя и размер, а кнопка удаления называет свой файл (aria-label) — три подряд кнопки «Удалить» для скринридера неразличимы.

Disabled гасится курсором и состоянием самих кнопок: opacity на контейнере разбавляла бы и подписи, и имена файлов.

Превью картинок

preview включает миниатюры: они появляются у файлов image/*, файл любого другого типа остаётся обычной строкой. Миниатюра квадратная и обрезается по object-cover — иначе строки списка скакали бы по высоте вслед за пропорциями снимков.

<GrFormFile v-model="gallery" multiple preview accept="image/*" />

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

`readonly`

Набор виден и уходит в форму, но не меняется ничем: ни диалогом выбора, ни перетаскиванием, ни кнопками — они в этом состоянии не рендерятся. Кнопка выбора остаётся в таб-порядке и объявляет aria-readonly: поле должно быть достижимо с клавиатуры и уметь объяснить, почему не поддаётся.

Отличие от disabled: тот выключает и саму кнопку, то есть поле выпадает из обхода целиком.

Внутри `GrForm`

Те же ограничения можно объявить правилом формы — рядом с остальными:

const rules: GrFormRules = {
  contract: [{ required: true, file: { accept: '.pdf', maxSizeMb: 1 } }],
}

Проверку ведут те же валидаторы, поэтому текст ошибки не меняется — меняется момент: правило поля не пускает плохой файл в модель сразу, правило формы отбивает submit и попадает в invalid и в скролл к первой ошибке. Подробности и полный список ключей — GrForm.md.

Типичное разделение: ограничения в rules, а на поле accept как фильтр диалога выбора.

Чего нет

Сортировки набора.

Playground 15

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

Код
<GrFormFile />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
multipleboolean | undefinedfalse
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер кнопок, иконок и подписей.
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя вне `GrFormField`.
limitnumber | undefinedundefinedМаксимум файлов в наборе. Лишние не обрезаются молча — набор отбивается ошибкой.
validatorsFileValidator[] | undefinedundefined
acceptstring | undefinedundefinedW3C `accept` для `<input type="file">` + sugar к `acceptValidator(...)`.
previewboolean | undefinedfalseМиниатюры для картинок в наборе. Файлы других типов остаются строкой.
validate((files: File[]) => FileValidationIssue[] | Promise<FileValidationIssue[]>) | undefinedundefinedДополнительная (кастомная) валидация на стороне потребителя.
uploadTextstring | undefinedundefined
changeTextstring | undefinedundefined
removeTextstring | undefinedundefined
clearAllTextstring | undefinedundefined
errorsFileValidationIssue[] | undefinedundefinedКонтролируемый список ошибок: `v-model:errors`. Задан — показывается он, и внутренняя валидация его не перетирает. Сюда же кладутся ошибки, пришедшие с сервера. Не задан — компонент держит свои ошибки сам.
modelValueобязательныйFile | File[] | null

Slots

SlotTypeОписание
error{ errors: FileValidationIssue[]; }Собственный вывод ошибок вместо списка по умолчанию.

Events

EventTypeОписание
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 / ExposeTypeОписание
focus() => void
blur() => void

Примеры 7

Выбор одного файла со сводкой

Базовый сценарий показывает single-file поток: поле управляет выбором/заменой файла, а экран отдельно отображает business-friendly summary.

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>

Своя проверка с показанными ошибками

Отдельно фиксируем validate/update:errors: showcase должен показать, что GrFormFile подходит и для domain-specific upload rules, а не только для accept.

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>

Очередь вложений

Многофайловый режим раскрывает список выбранных файлов и подходит для attachment-очередей в support/review-формах.

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>

Миниатюры изображений и набор только для чтения

Превью показываются только у картинок — файл другого типа остаётся строкой. Переключатель рядом делает поле read-only: набор виден и уходит в форму, но менять его нечем.

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>

Шкала размеров

Размер доезжает до вложенных кнопок и иконок, поэтому поле выбора файла встаёт в один ряд с остальными контролами формы.

size="xs"
Файлы не выбраны
size="sm"
Файлы не выбраны
size="md"
Файлы не выбраны
size="lg"
Файлы не выбраны

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>

Ошибки сервера и предел набора

v-model:errors — двусторонний канал: в него пишет и внутренняя валидация, и ответ сервера. limit отбивает лишние файлы тем же правилом, что и остальные.

До трёх файлов, только PDF

Файлы не выбраны
Ошибки объявляются `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

Обязательное поле
Файлы не выбраны

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>

Документация компонентаВсе компоненты