GrImageViewer

Package: @feugene/granularitycoreGroup: overlays

Opens an image in a convenient viewing and zooming mode.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the picture has to be examined — a scan of a document, a drawing, a photograph of a product: zoom, rotation and panning are already there;
  • there are several imagesurlList is paged with the arrows and by swiping, and the index is controlled through v-model;
  • the original can be downloadedshowDownload gives a button next to the zoom;
  • the work is done from the keyboard — the whole set of actions has keys and labels.

When to take something else

NeedTake
Show an image in the flow of the pagean ordinary <img> or GrCard
An avatar or a thumbnailGrAvatar
A layout of your own on top of the pageGrModal
Choose and upload a fileGrFileUpload
A carousel gallery in the flow of the pagemarkup of your own: there is no carousel in the package

The frames: a string or `{ src, alt }`

urlList accepts both variants, mixed together included:

<GrImageViewer
  v-model="open"
  :url-list="[
    { src: '/photos/roof.jpg', alt: 'The roof from above' },
    '/photos/plan.png',
  ]"
/>

A string sets the address only — the alt of such a frame is empty. The component cannot invent the text on the consumer’s behalf, but neither must it substitute the name of the file silently: for a blind user “roof.jpg” is not a description. The object form is the way to give a description where there is one.

The alt also arrives in the toolbar slot (src, alt in the slot props) — the toolbar can show a caption next to the frame.

The accessible name and announcing a change of frame

The layer declares itself a dialog, so it always has a name: from the locale (gr.imageViewer.label) or from the ariaLabel prop. A dialog without a name is a violation of aria-dialog-name.

A change of frame goes into the shared live region of the package (announcer.md): “Image 2 of 5” (gr.imageViewer.position). On opening the position is not announced — the name of the dialog will say it.

The region lies outside the viewer, although the viewer covers the page with inert. That works thanks to the exemption by data-gr-live-region in the layer stack: inert throws a subtree out of the accessibility tree, and a region inside it would fall silent.

The showProgress counter stays purely visual: it duplicates the same information for the eye.

Zoom, panning and gestures

The wheel and the trackpad gesture zoom into the point under the cursor rather than from the centre: otherwise reaching the corner of an enlarged frame would require a separate drag.

The offset of the frame is bounded by its own overflow — as far as the picture has come out of the viewing area, that far it can be dragged. Dragging an image into the void and losing it is impossible, and on returning to a scale of 1 the offset resets itself. A rotation by 90° swaps the axes, and the bounds are computed from the rotated frame.

An enlarged frame can always be dragged — the draggable prop permits dragging a fitted one as well (which matters for rotated frames that come off the screen even without zoom).

The gestures have two owners, and that is visible in the behaviour. The mouse and the pen are run by the shared useDragGesture primitive: the listeners are on window, so the release is caught both outside the picture and when the frame changed in the middle of the gesture. Touch is run by a tracker of its own — it has a multi-finger model: a pinch with two fingers, a swipe for paging, and on an enlarged frame a single gesture drags.

An interrupted gesture (the browser took the pointer — a system gesture, a call, the loss of the window) ends the dragging leaving the frame where it was brought to, and does not count as a swipe: an interrupted gesture does not page.

The nominal scale against the real one

scale is nominal: one means “the frame is fitted into the window” (object-contain) rather than the natural size. For a photograph of 4752 px fitted into a window about 1000 px wide, a nominal 100% is a real 21%: at such a scale the pixels are not visible at all.

Hence two different buttons in the toolbar. “100%” resets the transformation and returns the fitted frame; “1:1” (actions.zoomToNatural) brings it to a real 100% — a pixel of the picture per pixel of the screen. The consumer does not have to compute that themselves, although the slot does give the data for the computation (naturalWidth, renderedWidth, realScale).

The maxScale ceiling applies to “1:1” as well: it limits the zoom deliberately, and the natural size is no reason to bypass it. The default maxScale: 5 does not reach a real 100% for a large frame (the nominal zoom needed is naturalWidth / renderedWidth at scale = 1, which for photographs is 5–12×), so for large images the ceiling is set with a prop. The reverse works too: if the frame is smaller than its place on the screen, “1:1” will shrink it — with an eye on minScale.

The touch gestures work on the same pointer events:

GestureWhat it does
Two fingersThe scale by the distance between them, the anchor is the midpoint
A finger on a fitted frame, horizontallyPaging through the frames
A finger on an enlarged frameDragging

The swipe threshold is 60px and a predominance of the horizontal: a vertical movement must not page, the user was aiming elsewhere. The picture is declared touch-action: none, otherwise the browser takes the gesture for itself and moves the page instead of zooming.

Downloading

showDownload adds a button to the toolbar: it downloads the current frame (<a download>) and emits download with { src, alt, index } — for analytics.

A cross-origin address will not always be downloaded by the browser: the download attribute is ignored for a foreign origin, and the file opens in a new tab. Where signed links or a request of your own are needed, the button is switched off (showDownload: false) and one of your own is put into the #toolbar-actions slot — it receives the same slot props, actions.download included.

Preloading the neighbouring frames

The neighbours of the current frame are warmed up only for an open viewer: a closed one on a page does not pull two full-size images merely because it is there. On a change of frame and on closing, unfinished loads are aborted — otherwise quick paging through a gallery piles up requests nobody needs any more.

Changing the list on the fly

The viewer holds on to the frame, not to the index. If the next page of the gallery has loaded, the open image stays on the screen together with its scale and rotation, even if it has shifted by position. If the current frame has disappeared from the list, the index is clamped to the bounds rather than reset to initialIndex: throwing the user back to the first image on every load is not allowed.

The layer

--gr-z-modal, as in the other modal overlays. zIndexVar replaces the layer variable with one of your own — the same escape hatch as in useFloating and GrLoading. The component does not accept a raw number: the layer is set by the scale, see ../z-index.md.

The exact value is calc(var(<token>) + depth): the viewer takes its own depth among the open modal layers from the stack, so it ends up above a window by rendering as well, not only by Esc. With zIndexVar that works the same way — the depth is added to the substituted variable.

Esc, inert for the lower layers and the return of focus go through the shared stack (useOverlayLayer), so a viewer on top of a modal closes itself rather than the modal.

The chrome tokens

The panel, the buttons and the scrim are coloured with per-component tokens:

TokenWhat it colours
--gr-image-viewer-scrimthe scrim under the image
--gr-image-viewer-chrome-bgthe background of the buttons and of the toolbar
--gr-image-viewer-chrome-bg-hoverthe background of a button under the cursor
--gr-image-viewer-chrome-bg-softthe soft highlight of the toolbar buttons
--gr-image-viewer-chrome-fgthe icons and the text of the chrome
--gr-image-viewer-chrome-fg-mutedthe text of the empty state
--gr-image-viewer-chrome-brdthe borders and the separators
--gr-image-viewer-ringthe focus ring

The values are the same in the light and the dark theme on purpose: next to a photograph a light panel argues with the content and highlights the edges of the frame. The tokens exist not for changing the theme but so that the consumer can recolour the chrome to fit their product.

The imperative API

<GrImageViewer ref="viewer" v-model="open" :url-list="images" />

viewer.value gives away close, prev, next, zoomIn, zoomOut, zoomToNatural, reset, rotateLeft, rotateRight — the same set the toolbar slot receives. Opening is not among them: it belongs to v-model, and a second way in would put the state out of sync with the model.

The events

EventWhen
update:modelValueopening/closing
closeclosing
changeanother frame is shown, the argument is the new index
rotatea rotation, the argument is the accumulated angle in degrees

Playground 25

Loading…

Code
<GrImageViewer />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
ariaLabelstring | undefinedundefinedThe accessible name of the layer. A modal dialog without a name is a violation of `aria-dialog-name`: a screen reader will announce "dialog" and fall silent.
emptyTextstring | undefinedundefinedi18n: the text of the empty state (there are no images).
closeLabelstring | undefinedundefinedi18n: the aria-label of the close button.
showProgressboolean | undefinedfalse
initialIndexnumber | undefined0
zoomRatenumber | undefined1.2
minScalenumber | undefined0.5
maxScalenumber | undefined5
hideOnClickModalboolean | undefinedfalse
closeOnPressEscapeboolean | undefinedtrue
showZoomValueboolean | undefinedtrue
wheelZoomboolean | undefinedtrueSwitches on zooming with the mouse wheel or a trackpad gesture. On by default.
draggableboolean | undefinedfalseSwitches on dragging (panning) of the picture with the mouse. On hover the cursor becomes a hand. Off by default.
zIndexVarstring | undefinedundefinedThe name of the CSS variable of the layer — an escape hatch past `--gr-z-modal`. The component does not accept a raw number: the layer is set by the scale, see `docs/z-index.md`.
prevLabelstring | undefinedundefinedi18n: the aria-label of the "previous image" button.
nextLabelstring | undefinedundefinedi18n: the aria-label of the "next image" button.
zoomInLabelstring | undefinedundefinedi18n: the aria-label of the "zoom in" button.
zoomOutLabelstring | undefinedundefinedi18n: the aria-label of the "zoom out" button.
resetZoomLabelstring | undefinedundefinedi18n: the aria-label of the "reset the zoom" button.
zoomToNaturalLabelstring | undefinedundefinedi18n: the aria-label of the "to the natural size" button.
rotateLeftLabelstring | undefinedundefinedi18n: the aria-label of the "rotate left" button.
rotateRightLabelstring | undefinedundefinedi18n: the aria-label of the "rotate right" button.
showDownloadboolean | undefinedfalseShow the button that downloads the current frame.
downloadLabelstring | undefinedundefinedi18n: the aria-label of the "download" button.
modelValuerequiredboolean
urlListrequiredGrImageViewerSource[]The frames. A string is an address only; an object `{ src, alt }` gives the image an alternative text: without it the viewer is empty for a blind user, and the component cannot invent the text on the consumer’s behalf.

Slots

SlotTypeDescription
toolbarGrImageViewerSlotPropsThe toolbar as a whole instead of the built-in one.
toolbar-actionsGrImageViewerSlotPropsButtons of your own beside the built-in ones — rotation, downloading, printing.

Events

EventTypeDescription
close[]
update:modelValue[value: boolean]
change[newIndex: number]
rotate[deg: number]
download[payload: { src: string; alt: string; index: number; }]

Methods / Expose

Methods / ExposeTypeDescription
close() => void
prev() => void
next() => void
zoomIn() => void
zoomOut() => void
zoomToNatural() => voidThe "one to one" scale: a real 100% rather than a nominal one.
reset() => void
rotateLeft() => void
rotateRight() => void
download() => voidDownload the current frame — the same as the toolbar button does.

Examples 6

Alt And Append

2 кадров · показан 1
Список можно менять на лету: просмотрщик держится за кадр, а не за индекс — открытое изображение остаётся на экране вместе с масштабом, даже если сдвинулось по позиции.

Alt And Append
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrImageViewerSource } from '@feugene/granularity'
import { GrBadge, GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <text x="140" y="520" fill="white" font-size="114" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const open = ref(false)
const page = ref(1)

// Кадр объектом — единственный способ дать изображению описание: имя файла
// незрячему пользователю ничего не говорит.
const slides = ref<GrImageViewerSource[]>([
  { src: createSlide('Roof', '#1d4ed8'), alt: 'Кровля здания с высоты птичьего полёта' },
  { src: createSlide('Plan', '#9333ea'), alt: 'Поэтажный план второго этажа' },
])

const currentIndex = ref(0)
const total = computed(() => slides.value.length)

function loadMore() {
  page.value += 1
  slides.value = [
    ...slides.value,
    { src: createSlide(`Page ${page.value}`, '#047857'), alt: `Скан страницы ${page.value}` },
  ]
}

function prependEarlier() {
  slides.value = [
    { src: createSlide('Earlier', '#b45309'), alt: 'Более ранний снимок объекта' },
    ...slides.value,
  ]
}
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" @click="open = true">
        Открыть просмотрщик
      </GrButton>
      <GrButton size="sm" variant="outline" @click="loadMore">
        Догрузить следующую страницу
      </GrButton>
      <GrButton size="sm" variant="outline" @click="prependEarlier">
        Добавить кадр в начало
      </GrButton>

      <GrBadge size="sm" tone="neutral">
        {{ total }} кадров · показан {{ currentIndex + 1 }}
      </GrBadge>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Список можно менять на лету: просмотрщик держится за кадр, а не за индекс — открытое
      изображение остаётся на экране вместе с масштабом, даже если сдвинулось по позиции.
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      show-progress
      hide-on-click-modal
      @change="currentIndex = $event"
    />
  </div>
</template>

Fullscreen gallery from thumbnails

Gallery
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <circle cx="930" cy="180" r="140" fill="${accent}" fill-opacity="0.35" />
      <rect x="130" y="160" width="320" height="22" rx="11" fill="white" fill-opacity="0.75" />
      <rect x="130" y="210" width="520" height="18" rx="9" fill="white" fill-opacity="0.45" />
      <rect x="130" y="660" width="420" height="26" rx="13" fill="white" fill-opacity="0.7" />
      <text x="130" y="540" fill="white" font-size="108" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const slides = [
  { title: 'Workspace overview', url: createSlide('Overview', '#2563eb', '#a5f3fc') },
  { title: 'Risk dashboard', url: createSlide('Risk', '#7c3aed', '#f5d0fe') },
  { title: 'Approval queue', url: createSlide('Queue', '#059669', '#fde68a') },
]

const open = ref(false)
const initialIndex = ref(0)

function openAt(index: number) {
  initialIndex.value = index
  open.value = true
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-3 sm:grid-cols-3">
      <button
        v-for="(slide, index) in slides"
        :key="slide.title"
        type="button"
        class="group overflow-hidden rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] text-left transition-transform hover:-translate-y-0.5"
        @click="openAt(index)"
      >
        <img :src="slide.url" :alt="slide.title" class="h-36 w-full object-cover">
        <div class="flex items-center justify-between gap-3 p-3">
          <span class="text-sm font-600 text-[var(--gr-fg)]">{{ slide.title }}</span>
          <GrBadge size="sm" tone="neutral">
            Preview
          </GrBadge>
        </div>
      </button>
    </div>

    <div>
      <GrButton size="sm" variant="outline" @click="openAt(0)">
        Open fullscreen gallery
      </GrButton>
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides.map(slide => slide.url)"
      :initial-index="initialIndex"
      show-progress
    />
  </div>
</template>

Custom toolbar slot

`toolbar` slot подходит для брендинга, кастомных shortcuts и встроенных action-clusters поверх fullscreen overlay.

Toolbar Slot
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <rect x="120" y="120" width="960" height="660" rx="36" fill="${accent}" fill-opacity="0.28" />
      <text x="120" y="520" fill="white" font-size="120" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const slides = [
  createSlide('Marketing hero', '#0f172a', '#38bdf8'),
  createSlide('Support knowledge base', '#111827', '#c084fc'),
  createSlide('Compliance evidence', '#1f2937', '#34d399'),
]

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      `toolbar` slot подходит для брендинга, кастомных shortcuts и встроенных action-clusters поверх fullscreen overlay.
    </div>

    <div>
      <GrButton size="sm" @click="open = true">
        Open viewer with custom toolbar
      </GrButton>
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      :initial-index="1"
      show-progress
      :show-zoom-value="false"
    >
      <template #toolbar="{ displayIndex, total, scale, rotation, actions }">
        <div class="flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--gr-bg)_20%,transparent)] bg-[color-mix(in_srgb,var(--gr-fg)_45%,transparent)] px-2 py-1 text-[var(--gr-bg)] backdrop-blur-sm">
          <span class="px-2 text-xs font-600">{{ displayIndex }} / {{ total }}</span>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.prev">Prev</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.next">Next</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomIn">+</button>
          <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.reset">Reset</button>
          <span class="px-2 text-xs text-[color-mix(in_srgb,var(--gr-bg)_75%,transparent)]">{{ Math.round(scale * 100) }}% / {{ rotation }}°</span>
        </div>
      </template>
    </GrImageViewer>
  </div>
</template>

Real image size in toolbar

Real Sizedepends on the showcase environment
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

// Локальный кадр 4752×3168: настоящая фотография, а не SVG-заглушка — зерно на
// реальных 100% видно только у растра. Vite отдаёт ей хешированный URL, поэтому
// путь остаётся импортом, а не строкой в `public/`.
import photo from '../../../../media/svanhove-lorem-4873426.jpg'

const IMAGE_WIDTH = 4752
const IMAGE_HEIGHT = 3168

const slides = [photo]

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      Фотография {{ IMAGE_WIDTH }}×{{ IMAGE_HEIGHT }}. Номинальный `scale` считается относительно вписанного в окно
      изображения (`object-contain`), поэтому «100%» — это не натуральный размер: зерна на нём не видно вовсе. Кнопка
      «1:1» доводит кадр до реальных 100% — пиксель в пиксель, и разница сразу видна. Компонент сам отдаёт в slot
      natural-размер, фактический rendered-размер и реальный масштаб — без ручного чтения DOM.
    </div>

    <div>
      <GrButton size="sm" @click="open = true">
        Open real-size experiment
      </GrButton>
    </div>

    <!--
      `maxScale` поднят с дефолтной пятёрки: нужное номинальное приближение — это
      `naturalWidth / renderedWidth`, у кадра 4752 px оно выходит от пяти до
      двенадцати крат в зависимости от ширины окна. С дефолтным потолком кнопка
      «1:1» упиралась бы в него, не дойдя до реальных 100%.
    -->
    <GrImageViewer
      v-model="open"
      :url-list="slides"
      :max-scale="14"
      show-progress
      :draggable="true"
      :show-zoom-value="false"
    >
      <template #toolbar="{ scale, rotation, naturalWidth, naturalHeight, renderedWidth, renderedHeight, realScalePercent, actions }">
        <div class="flex flex-col gap-2 rounded-2xl border border-[color-mix(in_srgb,var(--gr-bg)_20%,transparent)] bg-[color-mix(in_srgb,var(--gr-fg)_55%,transparent)] px-3 py-2 text-[var(--gr-bg)] backdrop-blur-sm">
          <div class="flex items-center justify-center gap-2">
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomOut"></button>
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.reset">Fit</button>
            <button type="button" class="rounded-full px-3 py-1 text-xs font-600 transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomToNatural">1:1</button>
            <button type="button" class="rounded-full px-3 py-1 text-xs transition-colors hover:bg-[color-mix(in_srgb,var(--gr-bg)_10%,transparent)]" @click="actions.zoomIn">+</button>
          </div>

          <div class="grid gap-0.5 text-[11px] leading-tight font-500">
            <span>Natural: {{ naturalWidth }} × {{ naturalHeight }} px</span>
            <span>Rendered: {{ renderedWidth }} × {{ renderedHeight }} px</span>
            <span>Nominal scale: {{ Math.round(scale * 100) }}% · rotation {{ rotation }}°</span>
            <span>Real scale: {{ realScalePercent }}%</span>
          </div>
        </div>
      </template>
    </GrImageViewer>
  </div>
</template>

Async gallery loading

Используйте viewer после загрузки gallery payload — так проще показать loading/progress state до fullscreen modal.

Async Media
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'

import { GrBadge, GrButton, GrImageViewer, GrProgressBar } from '@feugene/granularity'

function createSlide(label: string, background: string, accent: string) {
  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900">
      <rect width="1200" height="900" fill="${background}" />
      <circle cx="940" cy="220" r="120" fill="${accent}" fill-opacity="0.35" />
      <rect x="140" y="170" width="280" height="20" rx="10" fill="white" fill-opacity="0.7" />
      <text x="140" y="520" fill="white" font-size="114" font-family="Arial, sans-serif" font-weight="700">${label}</text>
    </svg>
  `)}`
}

const loading = ref(false)
const open = ref(false)
const progress = ref(0)
const slides = ref<string[]>([])

const hasSlides = computed(() => slides.value.length > 0)

let progressTimer: number | undefined
let resolveTimer: number | undefined

function clearTimers() {
  if (progressTimer !== undefined) {
    window.clearInterval(progressTimer)
    progressTimer = undefined
  }

  if (resolveTimer !== undefined) {
    window.clearTimeout(resolveTimer)
    resolveTimer = undefined
  }
}

function loadGallery() {
  clearTimers()
  loading.value = true
  progress.value = 12
  slides.value = []

  progressTimer = window.setInterval(() => {
    progress.value = Math.min(progress.value + 18, 88)
  }, 180)

  resolveTimer = window.setTimeout(() => {
    clearTimers()
    slides.value = [
      createSlide('Invoices', '#1d4ed8', '#93c5fd'),
      createSlide('Disputes', '#9333ea', '#e9d5ff'),
      createSlide('Fraud', '#047857', '#bbf7d0'),
    ]
    progress.value = 100
    loading.value = false
    open.value = true
  }, 1200)
}

onBeforeUnmount(() => {
  clearTimers()
})
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" @click="loadGallery">
        Simulate async media fetch
      </GrButton>

      <GrBadge v-if="hasSlides" size="sm" tone="neutral">
        {{ slides.length }} slides ready
      </GrBadge>
    </div>

    <div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="mb-3 text-sm text-[var(--gr-muted-fg)]">
        Используйте viewer после загрузки gallery payload — так проще показать loading/progress state до fullscreen modal.
      </div>

      <GrProgressBar :value="progress" aria-label="Gallery loading progress" />
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="slides"
      show-progress
      show-zoom-value
      hide-on-click-modal
    />
  </div>
</template>

Zoom to cursor and download

Скачано: —
Колесо увеличивает в точку под курсором, увеличенный кадр тянется мышью и не уезжает за край. На тач-устройстве — щипок и свайп между кадрами.

Zoom Download
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrImageViewer } from '@feugene/granularity'

const blueprint = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000">
    <rect width="1600" height="1000" fill="#0f172a" />
    <g stroke="#38bdf8" stroke-opacity="0.35" stroke-width="2">
      ${Array.from({ length: 16 }, (_, i) => `<line x1="${i * 100}" y1="0" x2="${i * 100}" y2="1000" />`).join('')}
      ${Array.from({ length: 10 }, (_, i) => `<line x1="0" y1="${i * 100}" x2="1600" y2="${i * 100}" />`).join('')}
    </g>
    <rect x="120" y="120" width="520" height="360" fill="none" stroke="#f8fafc" stroke-width="6" />
    <rect x="760" y="420" width="700" height="460" fill="none" stroke="#f8fafc" stroke-width="6" />
    <text x="140" y="100" fill="#f8fafc" font-size="42" font-family="monospace">SECTOR A · scale 1:200</text>
    <text x="780" y="400" fill="#f8fafc" font-size="42" font-family="monospace">SECTOR B</text>
  </svg>
`)}`

const open = ref(false)
const lastDownload = ref('')

function onDownload(payload: { src: string, alt: string, index: number }) {
  // Событие приходит вдобавок к самому скачиванию — под аналитику и логи.
  lastDownload.value = `кадр ${payload.index + 1}: ${payload.alt || 'без описания'}`
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton @click="open = true">
        Open blueprint
      </GrButton>
      <span class="text-xs text-[var(--gr-muted-fg)]">
        Скачано: {{ lastDownload }}
      </span>
    </div>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Колесо увеличивает в точку под курсором, увеличенный кадр тянется мышью и не
      уезжает за край. На тач-устройстве — щипок и свайп между кадрами.
    </div>

    <GrImageViewer
      v-model="open"
      :url-list="[
        { src: blueprint, alt: 'План этажа, сектор A и B' },
      ]"
      show-download
      show-zoom-value
      @download="onDownload"
    />
  </div>
</template>

Accessibility

APG pattern
| GrToaster | F6 (проп focusHotkey) — фокус на верхний тост, дальше действия обходятся Tab; сам тост остановкой Tab не является; Delete/Backspace на сфокусированном тосте закрывают его — клавиатурный эквивалент смахивания

Full keyboard contract of the package

Component documentationAll components