GrFilePreview

Package: @feugene/granularitycoreGroup: data

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

NeedTake
Pick a file and send it to a serverGrFileUpload
A file as the value of a form fieldGrFormFile
Open a picture full screenGrImageViewer
The avatar of a person or an entityGrAvatar
Show the contents of a file as textGrCodeBlock (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;
  • src is 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…

Code
<GrFilePreview />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
ariaLabelstring | undefinedundefinedThe 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" | undefinedundefined
namestring | null | undefinedundefinedThe name of the file: the accessible name of the picture and the caption of the placeholder.
srcstring | null | undefinedundefinedThe address of the preview. If it is empty, the placeholder by type appears at once.
asstring | Component | undefinedundefinedA root tag of your own (`RouterLink`, Inertia’s `Link`). Stronger than `href`.
hrefstring | undefinedundefinedA link to the original — for non-pictures and for going past the viewer.
clickableboolean | undefinedfalseThe tile is clickable and emits `click` — usually to open the viewer.
mimestring | null | undefinedundefinedThe MIME type. It decides whether this is a picture or a file.
tileSizeGrSizeWithPx | undefinedundefinedA 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.
ratioGrFilePreviewRatio | undefinedundefined

Events

EventTypeDescription
click[event: MouseEvent]

Examples 3

One row, six kinds of file

contract.pdf
report.xlsx
sources.zip
notes.txt
export.dat

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

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

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

Component documentationAll components