GrFilePreview
Shows a stored file as a tile: the image itself, or an icon for its type.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a feed of attachments — receipts, contracts, exports: the set holds pictures and documents mixed together, and one rule will not show them both;
- the type of the file is not known in advance — the controller returns the entries
without a filter, and an
<img>on a PDF draws a broken icon; - the preview opens a viewer — the tile emits
click, and the consumer shows the window; - there are a dozen tiles on the page — lazy loading and an aspect ratio that holds the space are already inside.
When to take something else
| Need | Take |
|---|---|
| Pick a file and send it to a server | GrFileUpload |
| A file as the value of a form field | GrFormFile |
| Open a picture full screen | GrImageViewer |
| The avatar of a person or an entity | GrAvatar |
| Show the contents of a file as text | GrCodeBlock (the @feugene/granularity-code package) |
The type is decided by `mime`, not by the extension
The extension lies: a .dat on an export, a .pdf on a renamed archive. The backend
returns the real type — that is what is taken.
There are six kinds: a picture, a PDF, a document, a spreadsheet, an archive and the
unrecognised. A picture is the only one drawn with an <img>; the rest get an icon and a
caption. An empty mime, application/octet-stream and an unknown type give a
placeholder rather than emptiness: “there is no type” is an ordinary state of a row in a
database, not a reason to show a hole.
text/csv is classified as a spreadsheet rather than as text: it is opened as a
spreadsheet.
The classification is public: fileKindOf(mime) and isPreviewableKind(kind) are
given away by the package. They are needed outside for exactly the reason that the tile
does not open the viewer (see “Limits”): deciding which files to hand to
GrImageViewer falls to the consumer — and without those functions
they write a mime.startsWith('image/') of their own, which diverges from the tile at the
very first new type.
A placeholder instead of a broken picture
Three paths lead to one and the same placeholder, and that is deliberate:
- the type is not a picture;
srcis empty;- the loading failed (
onerror) — the preview may have vanished from the disk.
In the last case the sign is different — “the image did not open” rather than “this is a
file”: the difference between “a file of this kind” and “there was a picture but it did
not arrive” is visible at once. A new src does not inherit the error.
`alt` is not invented
If name is set, it becomes the alt. If it is not, the picture is decorative
(alt=""), because a description invented by the component will be read by a screen
reader as a fact, and that is worse than an empty one.
On a placeholder the name is printed as a caption under the icon, and the icon itself is
hidden from a screen reader: the text has already said everything.
An interactive tile takes its name from the content — the alt of the picture or the
caption. If the content is nameless, the name is set with ariaLabel: a button without a
name is empty for a screen reader.
A tile is clickable only when it has been asked to be
Without clickable, href and as it is a <div>: a picture, not a control. It takes
no Tab stop and gets no cursor — an empty stop is worse than none.
The order in which the tag is chosen is as → <a href> → <button clickable> →
<div>, as in GrCard and GrLink. A link component (Inertia’s Link, RouterLink)
receives href; a string tag other than a does not.
The size and the proportions
tileSize is a step of the canonical scale or a number: a 96px tile in a feed of
attachments does not fit into four steps. The numeric escape hatch is here for the same
reason as the diameter in GrAvatar.
ratio holds the space until the loading finishes. Without it a row of tiles jumps when
the pictures arrive out of order.
The space is held by a skeleton, not by emptiness
While the picture is on its way, the tile shows a skeleton. It has three states — “loading”, “ready”, “did not open” — and without the middle one a feed of attachments lies: an empty cell reads as “the file has no preview”, although the request is still in flight. Across two dozen tiles arriving out of order the difference is visible at once.
The picture stays in the tree in the process and simply waits invisibly: take it out of there and the browser will not start the loading, and the “loading” state will never end.
Limits
The component does not load files, does not open the viewer itself and does not
generate previews for PDFs: rendering the first page of a document in the browser is the
job of a separate library, and its weight is out of proportion to a tile. If a thumbnail
of a PDF is needed, prepare it on the server and pass it in src as a picture.
Playground 4
Loading…
<GrFilePreview />Install
npm i @feugene/granularityImport
import { GrFilePreview } from '@feugene/granularity/components/GrFilePreview'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
ariaLabel | string | undefined | undefined | The accessible name of an interactive tile. Unset — the name comes from the content: the `alt` of the picture or the caption of the placeholder. |
loading | "lazy" | "eager" | undefined | undefined | — |
name | string | null | undefined | undefined | The name of the file: the accessible name of the picture and the caption of the placeholder. |
src | string | null | undefined | undefined | The address of the preview. If it is empty, the placeholder by type appears at once. |
as | string | Component | undefined | undefined | A root tag of your own (`RouterLink`, Inertia’s `Link`). Stronger than `href`. |
href | string | undefined | undefined | A link to the original — for non-pictures and for going past the viewer. |
clickable | boolean | undefined | false | The tile is clickable and emits `click` — usually to open the viewer. |
mime | string | null | undefined | undefined | The MIME type. It decides whether this is a picture or a file. |
tileSize | GrSizeWithPx | undefined | undefined | A step of the canonical scale or an arbitrary width in pixels. A number is an escape hatch, like the diameter in GrAvatar: a 96px tile in a feed of attachments does not fit into four steps. |
ratio | GrFilePreviewRatio | undefined | undefined | — |
Events
| Event | Type | Description |
|---|---|---|
click | [event: MouseEvent] | — |
Examples 3
One row, six kinds of file
<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>Tile opens the viewer, and survives a dead link
<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>A dozen tiles, each holding its place while it loads
<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>