GrFilePreview
Берут, когда лента вложений.
Когда брать
- лента вложений — чеки, договоры, выгрузки: в наборе вперемешку картинки и документы, и по одному правилу их не покажешь;
- тип файла заранее неизвестен — контроллер отдаёт варианты без фильтра, и
<img>на PDF рисует битую иконку; - превью открывает просмотрщик — плитка эмитит
click, окно показывает потребитель; - плиток на странице десяток — ленивая загрузка и держащее место соотношение сторон уже внутри.
Когда взять другое
| Нужно | Берите |
|---|---|
| Выбрать файл и отправить его на сервер | GrFileUpload |
| Файл как значение поля формы | GrFormFile |
| Открыть картинку во весь экран | GrImageViewer |
| Аватар человека или сущности | GrAvatar |
| Показать содержимое файла текстом | GrCodeBlock (пакет @feugene/granularity-code) |
Тип решает `mime`, а не расширение
Расширение врёт: .dat у выгрузки, .pdf у переименованного архива. Бэкенд
отдаёт настоящий тип — берётся он.
Видов шесть: картинка, PDF, документ, таблица, архив и неопознанное. Картинка —
единственный, который рисуется <img>; остальные получают иконку и подпись.
Пустой mime, application/octet-stream и незнакомый тип дают заглушку, а не
пустоту: «типа нет» — обычное состояние строки в БД, а не повод показать дыру.
text/csv разбирается как таблица, а не как текст: открывается он таблицей.
Классификация публичная: fileKindOf(mime) и isPreviewableKind(kind)
отдаются из пакета. Она нужна снаружи ровно потому, что плитка просмотрщик не
открывает (см. «Границы»): решать, какие файлы отдать в
GrImageViewer, приходится потребителю — и без этих
функций он заводит свой mime.startsWith('image/'), который расходится с
плиткой на первом же новом типе.
Заглушка вместо сломанной картинки
Три пути ведут в одну и ту же заглушку, и это осознанно:
- тип не картинка;
srcпуст;- загрузка сорвалась (
onerror) — превью могло исчезнуть с диска.
В последнем случае значок другой — «изображение не открылось», а не «это файл»:
разница между «файл такого рода» и «картинка была, но не доехала» видна сразу.
Новый src ошибку не наследует.
`alt` не выдумывается
Задано name — оно и становится alt. Не задано — картинка декоративна
(alt=""), потому что придуманное компонентом описание диктор прочитает как
факт, и это хуже пустого.
У заглушки name печатается подписью под иконкой, а сама иконка скрыта от
диктора: текст уже сказал всё.
Интерактивная плитка берёт имя из содержимого — alt картинки или подписи.
Если содержимое безымянно, имя задаётся ariaLabel: кнопка без имени для
скринридера пуста.
Плитка кликабельна только когда её попросили
Без clickable, href и as это <div>: картинка, а не контрол. Она не
занимает остановку Tab и не получает курсор — пустая остановка хуже её
отсутствия.
Порядок выбора тега — as → <a href> → <button clickable> → <div>, как у
GrCard и GrLink. Компонент-ссылка (Link от Inertia, RouterLink) получает
href; строковый тег, кроме a, — нет.
Размер и пропорции
tileSize — ступень канонической шкалы либо число: плитка 96px в ленте
вложений в четыре ступени не укладывается. Числовой escape-hatch здесь по той же
причине, что диаметр у GrAvatar.
ratio держит место до загрузки. Без него ряд плиток прыгает, когда картинки
доезжают вразнобой.
Место держит скелет, а не пустота
Пока картинка едет, плитка показывает скелет. Состояний у неё три — «грузится», «готова», «не открылась», — и без среднего лента вложений врёт: пустая ячейка читается как «у файла нет превью», хотя запрос ещё в пути. На двух десятках плиток, приезжающих вразнобой, разница видна сразу.
Картинка при этом остаётся в дереве и просто ждёт невидимой: убери её оттуда — браузер не начнёт загрузку, и состояние «грузится» не кончится никогда.
Границы
Компонент не грузит файлы, не открывает просмотрщик сам и не генерирует
превью для PDF: отрисовать первую страницу документа в браузере — задача
отдельной библиотеки, и её вес несоразмерен плитке. Нужна миниатюра PDF —
готовьте её на сервере и передавайте в src как картинку.
Playground 4
Загружается…
<GrFilePreview />Установка
npm i @feugene/granularityИмпорт
import { GrFilePreview } from '@feugene/granularity/components/GrFilePreview'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
ariaLabel | string | undefined | undefined | Доступное имя интерактивной плитки. Не задано — имя приходит из содержимого: `alt` картинки или подписи заглушки. |
loading | "lazy" | "eager" | undefined | undefined | — |
name | string | null | undefined | undefined | Имя файла: доступное имя картинки и подпись заглушки. |
src | string | null | undefined | undefined | Адрес превью. Пусто — сразу заглушка по типу. |
as | string | Component | undefined | undefined | Свой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`. |
href | string | undefined | undefined | Ссылка на оригинал — для не-картинок и для перехода мимо просмотрщика. |
clickable | boolean | undefined | false | Плитка кликабельна и эмитит `click` — обычно чтобы открыть просмотрщик. |
mime | string | null | undefined | undefined | MIME-тип. Он решает, картинка это или файл. |
tileSize | GrSizeWithPx | undefined | undefined | Ступень канонической шкалы либо произвольная ширина в пикселях. Число — escape-hatch, как диаметр у GrAvatar: плитка 96px в ленте вложений в четыре ступени не укладывается. |
ratio | GrFilePreviewRatio | undefined | undefined | — |
Events
| Event | Type | Описание |
|---|---|---|
click | [event: MouseEvent] | — |
Примеры 3
Один ряд, шесть видов файлов
Тип решает mime, а не расширение. Картинка рисуется картинкой, остальное получает иконку вида: PDF, документ, таблица, архив. Пустой тип даёт заглушку, а не пустоту — «типа нет» это обычное состояние строки в БД.
<script setup lang="ts">
import { GrFilePreview } from '@feugene/granularity'
// Картинка нарисована на месте, а не взята с внешнего хоста: демо снимается в
// визуальный эталон, и чужой сервер сделал бы снимок невоспроизводимым.
const thumbnail = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="#dbeafe" />
<path d="M0 150l60-50 45 38 40-30 55 42v50H0z" fill="#2563eb" opacity="0.3" />
<circle cx="152" cy="52" r="22" fill="#2563eb" opacity="0.45" />
</svg>
`)}`
// Ровно то, что отдаёт контроллер: варианты файла без фильтра по типу.
const files = [
{ name: 'receipt.png', mime: 'image/png', src: thumbnail },
{ name: 'contract.pdf', mime: 'application/pdf', src: null },
{ name: 'report.xlsx', mime: 'application/vnd.ms-excel', src: null },
{ name: 'sources.zip', mime: 'application/zip', src: null },
{ name: 'notes.txt', mime: 'text/plain', src: null },
// Тип бэкенд не проставил — обычное состояние строки в БД.
{ name: 'export.dat', mime: null, src: null },
]
</script>
<template>
<div class="flex flex-wrap gap-3">
<GrFilePreview
v-for="file in files"
:key="file.name"
:src="file.src"
:mime="file.mime"
:name="file.name"
tile-size="lg"
/>
</div>
</template><img> на не-картинку рисует битую иконку. Ровно этот дефект и был у потребителя: контроллер отдавал варианты файла без фильтра по типу.
Плитка открывает просмотрщик и переживает битую ссылку
Плитка эмитит click — просмотрщик открывает потребитель: набор плиток и просмотр набора это разные состояния страницы. Третья ссылка битая: превью деградирует в заглушку с другим значком — «изображение не открылось», а не «это файл».
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrFilePreview, GrImageViewer } from '@feugene/granularity'
// Картинки нарисованы на месте: демо попадает в визуальный эталон, а внешний
// хост сделал бы снимок зависящим от сети.
function receipt(hue: number): string {
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<rect width="400" height="400" fill="hsl(${hue} 90% 92%)" />
<rect x="120" y="70" width="160" height="260" rx="8" fill="hsl(${hue} 70% 55%)" opacity="0.25" />
<rect x="150" y="110" width="100" height="10" rx="5" fill="hsl(${hue} 70% 40%)" />
<rect x="150" y="140" width="70" height="10" rx="5" fill="hsl(${hue} 70% 40%)" opacity="0.6" />
<rect x="150" y="170" width="90" height="10" rx="5" fill="hsl(${hue} 70% 40%)" opacity="0.6" />
</svg>
`)}`
}
const files = [
{ name: 'receipt-01.jpg', mime: 'image/jpeg', src: receipt(210) },
{ name: 'receipt-02.jpg', mime: 'image/jpeg', src: receipt(150) },
// Битая ссылка: превью исчезло с диска. Плитка деградирует в заглушку, а не
// в сломанную картинку.
{ name: 'receipt-03.jpg', mime: 'image/jpeg', src: 'https://cdn.invalid/missing.jpg' },
{ name: 'act.pdf', mime: 'application/pdf', src: null },
]
// В просмотрщик уходят только картинки: у PDF смотреть нечего.
const images = computed(() => files.filter(file => file.mime?.startsWith('image/')))
const viewerOpen = ref(false)
const viewerIndex = ref(0)
function open(name: string): void {
viewerIndex.value = Math.max(0, images.value.findIndex(file => file.name === name))
viewerOpen.value = true
}
</script>
<template>
<div class="flex flex-wrap gap-3">
<template v-for="file in files" :key="file.name">
<!--
Картинка открывает просмотрщик, остальное — ссылка на оригинал.
Решение принимает потребитель: плитка только сообщает о клике.
-->
<GrFilePreview
v-if="file.mime?.startsWith('image/')"
:src="file.src"
:mime="file.mime"
:name="file.name"
clickable
:aria-label="`Открыть ${file.name}`"
@click="open(file.name)"
/>
<GrFilePreview
v-else
:mime="file.mime"
:name="file.name"
href="#"
/>
</template>
<GrImageViewer
v-model="viewerOpen"
:url-list="images.map(file => file.src).filter((src): src is string => src !== null)"
:initial-index="viewerIndex"
/>
</div>
</template>Десяток плиток, каждая держит своё место при загрузке
Лента вложений к заявке: двенадцать плиток мелкой ступени. Пока картинка не доехала, место держит скелет — «ещё грузится» и «у файла нет превью» это разные сообщения, и пустой ячейкой их не различить.
<script setup lang="ts">
import { GrFilePreview } from '@feugene/granularity'
// Лента вложений к заявке: одна ссылка на файл, ничего больше. Картинки
// нарисованы на месте, а не взяты с внешнего хоста: демо снимается в визуальный
// эталон, и чужой сервер сделал бы снимок зависящим от сети.
function scan(index: number): string {
const hue = (index * 29) % 360
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160">
<rect width="160" height="160" fill="hsl(${hue} 85% 90%)" />
<path d="M0 120l45-38 34 29 30-23 51 32v40H0z" fill="hsl(${hue} 70% 45%)" opacity="0.35" />
<circle cx="120" cy="42" r="16" fill="hsl(${hue} 70% 45%)" opacity="0.5" />
</svg>
`)}`
}
const attachments = Array.from({ length: 12 }, (_, index) => ({
name: `scan-${String(index + 1).padStart(2, '0')}.jpg`,
mime: 'image/jpeg',
src: scan(index),
}))
</script>
<template>
<!--
Пока картинка не доехала, плитка показывает скелет, а не пустой фон:
«ещё грузится» и «у файла нет превью» — разные сообщения, и на дюжине
плиток сразу видно, какое из них правда.
-->
<div class="flex flex-wrap gap-2">
<GrFilePreview
v-for="file in attachments"
:key="file.name"
:src="file.src"
:mime="file.mime"
:name="file.name"
tile-size="xs"
ratio="1:1"
/>
</div>
</template>Картинка при этом остаётся в разметке и просто ждёт невидимой: убери её на время загрузки — браузер не начнёт качать, и состояние «грузится» не кончится никогда.