GrToaster

Пакет: @feugene/granularityядроГруппа: Обратная связь

Берут, когда действие завершилось.

Когда брать

  • действие завершилось — «сохранено», «скопировано», «письмо отправлено»: подтверждение не должно занимать место;
  • сообщение приходит откуда угодно — очередь живёт в useToast, и вызвать её можно из любого модуля;
  • у сообщения есть отмена — кнопка действия в тосте заменяет диалог подтверждения на обратимом действии;
  • сообщений может быть несколько — стек с очередью, паузой под курсором и прогрессом до закрытия.

Когда взять другое

НужноБерите
Сообщение относится к месту на страницеGrAlert
Ошибка ответа сервера с деталямиGrResponseErrorBanner
Нужен ответ пользователяGrConfirmDialog
Идёт длинный процессGrProgressBar

На приложение ставится один GrToaster: состояние живёт в useToast, и второй экземпляр показал бы ту же очередь дважды. Критичное сообщение тостом не передают — он уйдёт сам, и пользователь может его не увидеть.

Запрос одним тостом

toast.promise ведёт весь жизненный цикл запроса в одном тосте: «загружаем» переписывается в результат, а не закрывается ради нового — стек не дёргается.

const { promise } = useToast()

promise(api.sync(), {
  loading: { title: 'Синхронизируем', message: 'Отправляем изменения…' },
  success: result => ({ title: 'Готово', message: `${result.files} файлов` }),
  error: reason => ({ title: 'Не вышло', message: String(reason) }),
})

Строка вместо объекта — шорткат для { title }. Загрузочный тост показывается без автозакрытия, результат получает обычный таймаут.

Промис возвращается как есть и отказ не глотается — тост не заменяет обработку ошибки, catch по-прежнему на вызывающем. Если пользователь закрыл тост руками, пока запрос шёл, результат его не воскресит.

Точечно тост меняется через update(id, patch): он же перезапускает таймер, когда в патче приходит timeoutMs.

Сколько тостов помнит очередь

maxVisible у компонента ограничивает только видимые тосты. Сама очередь ограничена потолком состояния — по умолчанию 20; при переполнении вытесняются самые старые, вместе с их таймерами. Иначе поток событий (переподключение сокета, цикл ошибок) копил бы очередь и вываливал её на пользователя, когда стек освободится.

app.use(granularityToastPlugin, { maxToasts: 50 })

Повтор схлопывается ключом, а не запоминанием текста

dedupeKey у push() заменяет живой тост с тем же ключом и перезапускает его автозакрытие. Типовой источник дублей — навигация, переигрывающая одни и те же пропсы страницы: без ключа каждое повторение заводило бы новый тост.

push({ title: flash.success, tone: 'success', dedupeKey: `flash:${flash.success}` })

Ключ занят, только пока тост на экране. Это не то же самое, что помнить последний показанный текст: такая память не сбрасывается, и повтор действия с тем же текстом («Сохранено» второй раз) не показался бы вовсе. Закрылся тост — ключ свободен, и следующий push снова видимый.

Повтор заменяет тост целиком, как стадия promise(): message и кнопки от предыдущего вызова не донашиваются. Точечная правка показанного тоста — это update(id, patch), у неё патч-семантика.

Объявление тостов скринридеру

Тост объявляет себя сам: role="status", а warning/danger — ассертивный role="alert". Постоянной обёртки-live-region над списком нет и не должно быть: вложение регионов с разной ассертивностью спецификацией не определено — браузеры и скринридеры расходятся вплоть до полной потери объявления. Контейнер остаётся именованным role="region" (regionLabel), это навигация, а не объявление.

Смахивание

Тост смахивается к своему краю экрана: стек справа уходит вправо, слева — влево. Смахивание вглубь экрана читалось бы как попытка достать что-то из-под тоста, а не выбросить его, поэтому движение «наоборот» идёт с сопротивлением — тост едет за пальцем, но не закрывается.

Порог — четверть ширины тоста, но не меньше 56px. Отпустили раньше — тост вернулся; отпустили дальше — закрылся. Обрыв жеста возвращает тост: если указатель забрал браузер (системный жест, звонок, потеря окна), пользователь жест не довёл, и дожимать его за него нельзя.

Пока тост тянут, его таймер стоит — иначе уведомление сгорало бы прямо под пальцем, посреди жеста.

Отпущенный тост уезжает за свой край и только потом закрывается. Отлёт — это тот же переход, что и у остальных состояний тоста, поэтому prefers-reduced-motion чинить нечем и не нужно: глобальный кламп сжимает переход до мгновенного, а сам жест — прямое манипулирование, а не движение интерфейса, и гасить его нечего.

swipe-dismiss="false" выключает жест; клавиатурный эквивалент при этом остаётся.

Клавиатура

Тосты телепортированы в конец body, поэтому кнопка «Отменить» лежит за пределами разумного числа нажатий Tab. F6 (проп focusHotkey, false отключает) переводит фокус на верхний тост, дальше действия обходятся обычным Tab. Сам тост собственной остановкой Tab не является — tabindex="-1": всплывающее уведомление не должно перехватывать обход страницы.

То же снаружи — через ref:

<GrToaster ref="toaster" />

focus() возвращает false, если стек пуст.

Delete и Backspace на сфокусированном тосте закрывают его — то же действие, что смахивание. Фокус переходит на соседний тост, если он есть: падать на body посреди стека, который ещё читают, некуда. Escape намеренно не занят — тостер не модальный слой, и перехват закрывал бы уведомление вместо диалога под ним.

Ширина

width — число (пиксели) или CSS-длина; уезжает в --gr-toaster-width, поэтому то же самое задаётся и темой. Дефолт — 360px, потолок — calc(100vw - 2rem).

Пауза автозакрытия

Курсор, фокус внутри стека или начатое смахивание останавливает таймеры всех видимых тостов и замораживает полосу прогресса (WCAG 2.2.1). Тосты из очереди (сверх maxVisible) стоят на паузе всегда — отсчёт начинается, когда тост становится видимым.

Playground 4

Загружается…

Код
<GrToaster />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
placementGrToasterPlacement | undefined"top-right"Угол экрана для стека уведомлений.
regionLabelstring | undefinedundefinedA11y-лейбл контейнера-региона (i18n).
widthstring | number | undefinedundefinedШирина стека. Число — пиксели, строка — любая CSS-длина. Уезжает в `--gr-toaster-width`, поэтому то же самое можно задать и темой.
dismissLabelstring | undefinedundefinedA11y-лейбл кнопки закрытия (i18n).
maxVisiblenumber | undefined4Максимум одновременно видимых тостов; остальные ждут в очереди. По умолчанию `4`.
focusHotkeystring | false | undefined"F6"Клавиша перевода фокуса в стек уведомлений; `false` отключает хоткей. `F6` — рекомендация APG для перехода между «регионами» страницы.
swipeDismissboolean | undefinedtrueСмахивание тоста к своему краю экрана. Клавиатурный эквивалент — `Delete` и `Backspace` на сфокусированном тосте — остаётся и при выключенном жесте.

Slots

SlotTypeОписание
actions{ toast: Toast; dismiss: () => void; }

Methods / Expose

Methods / ExposeTypeОписание
focus() => boolean

Примеры 7

Интерактивный конструктор уведомлений

Живой playground для ключевых пропсов GrToaster и payload useToast.push: меняйте tone, placement, timeoutMs, тексты и проверяйте поведение без переключения между отдельными demo-карточками.

Builderзависит от окружения витрины
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterBuilderDemo.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'

import {
  GrButton,
  GrCard,
  GrFormField,
  GrInput,
  GrNumberInput,
  GrRadioGroup,
  GrSelect,
  GrToaster,
  type GrToastTone,
  type GrToasterPlacement,
  useToast,
} from '@feugene/granularity'

import CodeBlock from '../../../components/doc/CodeBlock.vue'
import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push, clear } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('builder')

const tone = ref<GrToastTone>('info')
const placement = ref<GrToasterPlacement>('top-right')
const title = ref('Workspace updated')
const message = ref('Your changes are visible to the entire team.')
const timeoutMs = ref(3500)
const dismissLabel = ref('Dismiss')
const regionLabel = ref('Notifications')

const toneOptions = [
  { value: 'primary', label: 'Primary' },
  { value: 'neutral', label: 'Neutral' },
  { value: 'success', label: 'Success' },
  { value: 'warning', label: 'Warning' },
  { value: 'danger', label: 'Danger' },
  { value: 'info', label: 'Info' },
  { value: 'slate', label: 'Slate' },
  { value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrToastTone, label: string }>

const placementOptions = [
  { value: 'top-left', label: 'TL' },
  { value: 'top-right', label: 'TR' },
  { value: 'bottom-left', label: 'BL' },
  { value: 'bottom-right', label: 'BR' },
] satisfies Array<{ value: GrToasterPlacement, label: string }>

const effectiveTitle = computed(() => title.value.trim() || 'Workspace updated')
const effectiveMessage = computed(() => message.value.trim())

const previewSummary = computed(() => {
  if (timeoutMs.value <= 0)
    return 'Sticky toasts (timeoutMs ≤ 0) stay until the user dismisses them — handy for warnings that need acknowledgement'

  if (tone.value === 'warning' || tone.value === 'danger')
    return 'Warning and danger tones use role=alert and aria-live=assertive to interrupt the screen reader'

  return 'Tweak tone, placement and timeoutMs to validate the toast contract before wiring useToast into a real flow'
})

function escapeAttribute(value: string) {
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
}

function pushToast() {
  activateHost()
  push({
    tone: tone.value,
    title: effectiveTitle.value,
    message: effectiveMessage.value || undefined,
    timeoutMs: timeoutMs.value,
  })
}

function clearStore() {
  activateHost()
  clear()
}

const previewCode = computed(() => {
  const toasterAttributes = [
    `placement="${placement.value}"`,
    `dismiss-label="${escapeAttribute(dismissLabel.value || 'Dismiss')}"`,
    `region-label="${escapeAttribute(regionLabel.value || 'Notifications')}"`,
  ]

  const pushPayload: string[] = [
    `  tone: '${tone.value}',`,
    `  title: '${effectiveTitle.value.replaceAll('\'', '\\\'')}',`,
  ]

  if (effectiveMessage.value)
    pushPayload.push(`  message: '${effectiveMessage.value.replaceAll('\'', '\\\'')}',`)

  pushPayload.push(`  timeoutMs: ${timeoutMs.value},`)

  return [
    '<script setup lang="ts">',
    'import { GrButton, GrToaster, useToast } from \'@feugene/granularity\'',
    '',
    'const { push } = useToast()',
    '',
    'function notify() {',
    '  push({',
    ...pushPayload.map(line => `  ${line}`),
    '  })',
    '}',
    '<\/script>',
    '',
    '<template>',
    '  <GrButton size="sm" @click="notify">Push toast</GrButton>',
    '',
    '  <GrToaster',
    ...toasterAttributes.map(attribute => `    ${attribute}`),
    '  />',
    '</template>',
  ].join('\n')
})
</script>

<template>
  <div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
    <div class="grid gap-4">
      <div
        class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
      >
        <div class="flex h-full flex-col items-center justify-center gap-4 text-center">
          <div class="showcase-demo-caption text-xs">
            Preview
          </div>

          <div class="flex flex-wrap justify-center gap-2">
            <GrButton size="sm" @click="pushToast">
              Push toast
            </GrButton>
            <GrButton size="sm" variant="ghost" @click="clearStore">
              Clear store
            </GrButton>
          </div>

          <div class="text-xs text-[var(--gr-muted-fg)]">
            Active host: <span class="font-medium text-[var(--gr-fg)]">{{ isActiveHost ? 'this preview' : 'another preview' }}</span>
          </div>

          <div
            class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2"
          >
            <div class="showcase-demo-text max-w-[40ch] text-center text-sm">
              {{ previewSummary }}
            </div>
          </div>
        </div>
      </div>

      <CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
    </div>

    <div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
      <div class="showcase-demo-title text-sm font-semibold">
        Properties
      </div>

      <div class="grid gap-4">
        <GrFormField label="Tone">
          <GrSelect v-model="tone" :options="toneOptions" aria-label="Toast tone" />
        </GrFormField>

        <GrFormField label="Placement">
          <GrRadioGroup v-model="placement" :options="placementOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Title">
          <GrInput v-model="title" placeholder="Workspace updated" aria-label="Toast title" />
        </GrFormField>

        <GrFormField label="Message">
          <GrInput v-model="message" placeholder="Optional supporting text" aria-label="Toast message" />
        </GrFormField>

        <GrFormField label="Timeout (ms, 0 = sticky)">
          <GrNumberInput
            v-model="timeoutMs"
            :min="0"
            :step="500"
            placeholder="3500"
            aria-label="Toast timeout in milliseconds"
          />
        </GrFormField>

        <GrFormField label="Dismiss label">
          <GrInput v-model="dismissLabel" placeholder="Dismiss" aria-label="Dismiss button label" />
        </GrFormField>

        <GrFormField label="Region label">
          <GrInput v-model="regionLabel" placeholder="Notifications" aria-label="Toaster region label" />
        </GrFormField>
      </div>

      <GrCard class="grid gap-2 p-4 text-xs text-[var(--gr-muted-fg)]">
        <div>
          <span class="font-medium text-[var(--gr-fg)]">Tip:</span> set timeout to 0 for warnings that require acknowledgement.
        </div>
        <div>
          Active host pattern keeps a single `GrToaster` rendered for the shared `useToast` store.
        </div>
      </GrCard>
    </div>

    <GrToaster
      v-if="isActiveHost"
      :placement="placement"
      :dismiss-label="dismissLabel || 'Dismiss'"
      :region-label="regionLabel || 'Notifications'"
    />
  </div>
</template>

Удобный формат для дизайн-ревью и QA: один сценарий покрывает все пропсы и shared-store контракт useToast.

Уведомление без автозакрытия и ручная очистка

Демонстрация timeoutMs = 0 и ручного очищения shared store для уведомлений, которые не должны исчезать автоматически.

Last sticky id:

Sticky
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterStickyDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrToaster, useToast } from '@feugene/granularity'

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push, clear } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('sticky')
const lastId = ref('')

function openStickyToast() {
  activateHost()
  lastId.value = push({
    title: 'Manual follow-up required',
    message: 'Use timeoutMs = 0 when the toast must stay until a user action.',
    tone: 'warning',
    timeoutMs: 0,
  })
}

function clearStickyToast() {
  activateHost()
  clear()
  lastId.value = ''
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" variant="outline" @click="openStickyToast">
        Open sticky toast
      </GrButton>
      <GrButton size="sm" variant="ghost" @click="clearStickyToast">
        Clear store
      </GrButton>
    </div>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Last sticky id: <span class="font-medium text-[var(--gr-fg)]">{{ lastId || '—' }}</span>
    </div>

    <GrToaster v-if="isActiveHost" />
  </div>
</template>

Очередь уведомлений о ходе работы

Отдельный workflow-сценарий: пушим несколько toast подряд, чтобы проверить stacking и ручные sticky warnings.

One active `GrToaster` host is enough because `useToast` shares a global reactive store.

Queue
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterQueueDemo.vue -->
<script setup lang="ts">
import { GrButton, GrToaster, useToast } from '@feugene/granularity'

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('queue')

function queueWorkflowToasts() {
  activateHost()
  push({ title: 'Sync started', message: 'Preparing records for upload.', tone: 'info' })
  push({ title: '2 warnings', message: 'Some fields will be normalized before import.', tone: 'warning', timeoutMs: 0 })
  push({ title: 'Sync finished', message: 'Records were uploaded successfully.', tone: 'success' })
}
</script>

<template>
  <div class="grid gap-3">
    <GrButton size="sm" class="justify-self-start" @click="queueWorkflowToasts">
      Queue workflow toasts
    </GrButton>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      One active `GrToaster` host is enough because `useToast` shares a global reactive store.
    </div>

    <GrToaster v-if="isActiveHost" />
  </div>
</template>

Кнопки действия: размер, вариант, несколько

Payload useToast.push принимает action: { label, onClick } для одной кнопки или actions: [...] для нескольких. У каждой кнопки настраиваются size и variant; dismissOnClick: false оставляет тост открытым (например, «Retry» для sticky-ошибки).

Last action:

Action
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterActionDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

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

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push, promise, clear } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('action')

// Отслеживаем, что действие реально выполнилось (для наглядности демо).
const lastAction = ref('—')
const archived = ref(false)

function notifyWithUndo() {
  activateHost()
  archived.value = true
  lastAction.value = 'Message archived'

  push({
    title: 'Message archived',
    message: 'Moved to archive. You can still undo this.',
    tone: 'info',
    timeoutMs: 6000,
    // Массив кнопок с разными variant/size.
    actions: [
      {
        label: 'Undo',
        variant: 'primary',
        size: 'sm',
        onClick: () => {
          archived.value = false
          lastAction.value = 'Undo — message restored'
        },
      },
      {
        label: 'View archive',
        variant: 'ghost',
        size: 'sm',
        dismissOnClick: false,
        onClick: () => {
          lastAction.value = 'Opened archive'
        },
      },
    ],
  })
}

function notifyWithRetry() {
  activateHost()
  lastAction.value = 'Upload failed'

  push({
    title: 'Upload failed',
    message: 'Network error while uploading report.pdf.',
    tone: 'danger',
    // Sticky: держим тост, пока пользователь не отреагирует на action.
    timeoutMs: 0,
    action: {
      label: 'Retry',
      // Более крупная кнопка для основного sticky-действия.
      size: 'md',
      variant: 'outline',
      // dismissOnClick: false — оставляем тост открытым, чтобы показать «повтор».
      dismissOnClick: false,
      onClick: () => {
        lastAction.value = 'Retrying upload…'
      },
    },
  })
}

// Один тост на весь жизненный цикл запроса: «загружаем» переписывается в
// результат, а не закрывается ради нового.
function notifyWithPromise(shouldFail: boolean) {
  activateHost()
  lastAction.value = 'Syncing…'

  const request = new Promise<{ files: number }>((resolve, reject) => {
    setTimeout(() => (shouldFail ? reject(new Error('Gateway timeout')) : resolve({ files: 12 })), 1500)
  })

  promise(request, {
    loading: { title: 'Syncing workspace', message: 'Uploading local changes…' },
    success: result => ({ title: 'Workspace synced', message: `${result.files} files uploaded` }),
    error: reason => ({ title: 'Sync failed', message: (reason as Error).message }),
  })
    .then(() => { lastAction.value = 'Sync finished' })
    .catch(() => { lastAction.value = 'Sync failed' })
}

function clearStore() {
  activateHost()
  clear()
  lastAction.value = '—'
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-2">
      <GrButton size="sm" @click="notifyWithUndo">
        Archive with Undo
      </GrButton>
      <GrButton size="sm" variant="outline" @click="notifyWithRetry">
        Failed upload (Retry)
      </GrButton>
      <GrButton size="sm" variant="outline" @click="notifyWithPromise(false)">
        Sync (promise)
      </GrButton>
      <GrButton size="sm" variant="outline" @click="notifyWithPromise(true)">
        Sync that fails
      </GrButton>
      <GrButton size="sm" variant="ghost" @click="clearStore">
        Clear store
      </GrButton>
    </div>

    <div class="flex flex-wrap items-center gap-2 text-xs">
      <GrBadge :tone="archived ? 'warning' : 'success'">
        {{ archived ? 'Archived' : 'In inbox' }}
      </GrBadge>
      <span class="text-[var(--gr-muted-fg)]">
        Last action: <span class="font-medium text-[var(--gr-fg)]">{{ lastAction }}</span>
      </span>
    </div>

    <GrToaster v-if="isActiveHost" />
  </div>
</template>

action.onClick вызывается синхронно перед закрытием — удобно для undo/retry-паттернов, где важно успеть отменить операцию.

Свои кнопки действия через слот

Слот #actions полностью заменяет дефолтные кнопки: он получает сам toast и функцию dismiss, которая закрывает именно этот тост. Так можно рендерить любые контролы и самому решать, когда закрывать уведомление.

Status:

Action Slot
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterActionSlotDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrToaster, useToast } from '@feugene/granularity'

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('action-slot')

const status = ref('—')

function notify() {
  activateHost()
  status.value = 'Awaiting review'

  push({
    title: 'Deploy ready',
    message: 'Review the build and promote it to production.',
    tone: 'success',
    timeoutMs: 0,
  })
}
</script>

<template>
  <div class="grid gap-3">
    <GrButton size="sm" @click="notify">
      Notify with custom actions
    </GrButton>
    <span class="text-xs text-[var(--gr-muted-fg)]">
      Status: <span class="font-medium text-[var(--gr-fg)]">{{ status }}</span>
    </span>

    <GrToaster v-if="isActiveHost">
      <!-- Кнопки действий передаём через слот. `dismiss` закрывает этот тост. -->
      <template #actions="{ toast, dismiss }">
        <GrButton
          size="sm"
          variant="primary"
          @click="() => { status = `Promoted: ${toast.title}`; dismiss() }"
        >
          Promote
        </GrButton>
        <GrButton size="sm" variant="ghost" @click="dismiss">
          Later
        </GrButton>
      </template>
    </GrToaster>
  </div>
</template>

Слот задаётся один раз на GrToaster и применяется ко всем тостам; внутри доступен toast (title/message/tone/…) и dismiss().

F6 — фокус в стек уведомлений

Тосты телепортированы в конец body: без хоткея кнопка действия недостижима с клавиатуры. Заодно — ширина стека пропом.

Тосты живут в конце body — без хоткея кнопка «Вернуть» была бы за десятками нажатий Tab.

Focus Hotkey
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterFocusHotkeyDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

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

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('focus-hotkey')

const lastAction = ref('—')

function notify() {
  activateHost()
  lastAction.value = '—'

  push({
    title: 'Отчёт удалён',
    message: 'Нажмите F6 — фокус уедет на уведомление, дальше Tab до кнопки.',
    tone: 'warning',
    timeoutMs: 0,
    action: {
      label: 'Вернуть',
      size: 'sm',
      onClick: () => {
        lastAction.value = 'Отчёт восстановлен с клавиатуры'
      },
    },
  })
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-2">
      <GrButton size="sm" @click="notify">
        Показать уведомление
      </GrButton>
      <span class="text-xs text-[var(--gr-muted-fg)]">
        Тосты живут в конце body — без хоткея кнопка «Вернуть» была бы за
        десятками нажатий Tab.
      </span>
    </div>

    <GrBadge :tone="lastAction === '—' ? 'neutral' : 'success'">
      {{ lastAction }}
    </GrBadge>

    <GrToaster v-if="isActiveHost" placement="bottom-right" :width="420" />
  </div>
</template>

Смахивание и клавиатурный эквивалент

Тост смахивается к своему краю экрана — сторона идёт за placement. Порог — четверть ширины, короткое движение возвращает тост, обрыв жеста тоже. swipe-dismiss="false" выключает жест, не трогая клавиатуру.

Сторона смахивания идёт за стеком: у правого края тост уходит вправо, у левого — влево. Отпустите раньше порога — вернётся на место; оборвите жест — тоже вернётся.

Swipe
<!-- showcaseToasterHost.ts -->
import { computed, ref } from 'vue'

import { useToast } from '@feugene/granularity'

const activeHostId = ref<string | null>(null)

export function useShowcaseToasterHost(hostId: string) {
  const isActiveHost = computed(() => activeHostId.value === hostId)

  /**
   * Стек `useToast` один на страницу, а тостер смонтирован ровно один — тот, чьё
   * демо нажали последним. Поэтому чистим стек при **смене** хоста: иначе тосты
   * соседнего демо всплыли бы в этом. Повторные нажатия внутри одного демо стек
   * не трогают — несколько уведомлений обязаны жить одновременно, каждое со своим
   * таймером.
   */
  function activateHost() {
    if (activeHostId.value === hostId)
      return

    useToast().clear()
    activeHostId.value = hostId
  }

  return {
    isActiveHost,
    activateHost,
  }
}

<!-- GrToasterSwipeDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrSegmented, GrToaster, useToast, type GrToasterPlacement } from '@feugene/granularity'

import { useShowcaseToasterHost } from './showcaseToasterHost'

const { push } = useToast()
const { isActiveHost, activateHost } = useShowcaseToasterHost('swipe')

const placement = ref<GrToasterPlacement>('bottom-right')
const swipeDismiss = ref(true)

const placements = [
  { value: 'bottom-right', label: 'Справа' },
  { value: 'bottom-left', label: 'Слева' },
]

function notify() {
  activateHost()

  push({
    title: 'Черновик сохранён',
    message: swipeDismiss.value
      ? 'Смахните уведомление к своему краю экрана — или нажмите Delete, доведя до него фокус клавишей F6.'
      : 'Жест выключен: закрыть можно кнопкой или клавишей Delete.',
    tone: 'success',
    timeoutMs: 0,
  })
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" @click="notify">
        Показать уведомление
      </GrButton>

      <GrSegmented v-model="placement" size="sm" :options="placements" aria-label="Край экрана" />

      <label class="flex items-center gap-2 text-xs">
        <input v-model="swipeDismiss" type="checkbox">
        Смахивание включено
      </label>
    </div>

    <p class="text-xs text-[var(--gr-muted-fg)]">
      Сторона смахивания идёт за стеком: у правого края тост уходит вправо, у левого — влево.
      Отпустите раньше порога — вернётся на место; оборвите жест — тоже вернётся.
    </p>

    <GrToaster
      v-if="isActiveHost"
      :placement="placement"
      :swipe-dismiss="swipeDismiss"
      :width="420"
    />
  </div>
</template>

Клавиатурный эквивалент — Delete и Backspace на сфокусированном тосте; пока тост тянут, его таймер стоит.

Документация компонентаВсе компоненты