GrFormFile
Берут, когда файл — значение поля.
Когда брать
- файл — значение поля — резюме, скан, вложение: отправляет форма, а не компонент;
- файл проверяется до отправки — размер, тип, количество через
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: две копии этой сборки разъезжаются при первой же правке, и
перетаскивание начинает вести себя не так, как диалог.
Порядок: accept → limit → validators потребителя → 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
multiple | boolean | undefined | false | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и уходит в форму, но не редактируется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер кнопок, иконок и подписей. |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Доступное имя вне `GrFormField`. |
limit | number | undefined | undefined | Максимум файлов в наборе. Лишние не обрезаются молча — набор отбивается ошибкой. |
validators | FileValidator[] | undefined | undefined | — |
accept | string | undefined | undefined | W3C `accept` для `<input type="file">` + sugar к `acceptValidator(...)`. |
preview | boolean | undefined | false | Миниатюры для картинок в наборе. Файлы других типов остаются строкой. |
validate | ((files: File[]) => FileValidationIssue[] | Promise<FileValidationIssue[]>) | undefined | undefined | Дополнительная (кастомная) валидация на стороне потребителя. |
uploadText | string | undefined | undefined | — |
changeText | string | undefined | undefined | — |
removeText | string | undefined | undefined | — |
clearAllText | string | undefined | undefined | — |
errors | FileValidationIssue[] | undefined | undefined | Контролируемый список ошибок: `v-model:errors`. Задан — показывается он, и внутренняя валидация его не перетирает. Сюда же кладутся ошибки, пришедшие с сервера. Не задан — компонент держит свои ошибки сам. |
modelValueобязательный | File | File[] | null | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
error | { errors: FileValidationIssue[]; } | Собственный вывод ошибок вместо списка по умолчанию. |
Events
| Event | Type | Описание |
|---|---|---|
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 | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Примеры 7
Выбор одного файла со сводкой
Базовый сценарий показывает single-file поток: поле управляет выбором/заменой файла, а экран отдельно отображает business-friendly summary.
<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.
<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-формах.
<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: набор виден и уходит в форму, но менять его нечем.
<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>Шкала размеров
Размер доезжает до вложенных кнопок и иконок, поэтому поле выбора файла встаёт в один ряд с остальными контролами формы.
<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
<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>