GrToaster

Package: @feugene/granularitycoreGroup: feedback

Shows brief pop-up notifications about the result of actions.

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

When to take it

  • an action has finished — “saved”, “copied”, “the letter has been sent”: a confirmation must not take up room;
  • the message comes from anywhere — the queue lives in useToast, and it can be called from any module;
  • the message has an undo — an action button in a toast replaces a confirmation dialog for a reversible action;
  • there may be several messages — a stack with a queue, a pause under the cursor and progress until closing.

When to take something else

NeedTake
The message belongs to a place on the pageGrAlert
A server error with detailsGrResponseErrorBanner
An answer from the user is neededGrConfirmDialog
A long process is runningGrProgressBar

One GrToaster is placed per application: the state lives in useToast, and a second instance would show the same queue twice. A critical message is not delivered with a toast — it will leave on its own, and the user may not see it.

A request in a single toast

toast.promise runs the whole lifecycle of a request in one toast: “loading” is rewritten into the result rather than being closed for the sake of a new one — the stack does not jerk.

const { promise } = useToast()

promise(api.sync(), {
  loading: { title: 'Syncing', message: 'Sending the changes…' },
  success: result => ({ title: 'Done', message: `${result.files} files` }),
  error: reason => ({ title: 'It did not work', message: String(reason) }),
})

A string instead of an object is a shortcut for { title }. The loading toast is shown without auto-closing, and the result gets the ordinary timeout.

The promise is returned as it is and a rejection is not swallowed — a toast does not replace the handling of an error, and the catch is still on the caller. If the user closed the toast by hand while the request was running, the result will not resurrect it.

A toast is changed pointwise through update(id, patch): it also restarts the timer when a timeoutMs arrives in the patch.

How many toasts the queue remembers

The maxVisible of the component limits only the visible toasts. The queue itself is bounded by the ceiling of the state — 20 by default; on overflow the oldest are evicted together with their timers. Otherwise a stream of events (a socket reconnecting, a loop of errors) would pile up the queue and dump it on the user when the stack freed up.

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

A repeat is folded by a key rather than by remembering the text

The dedupeKey of push() replaces a live toast with the same key and restarts its auto-closing. The typical source of duplicates is navigation replaying the same props of a page: without a key every repetition would start a new toast.

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

The key is taken only while the toast is on the screen. That is not the same as remembering the last text shown: such a memory is not reset, and a repeat of an action with the same text (“Saved” a second time) would not be shown at all. Once a toast has closed, the key is free, and the next push is visible again.

A repeat replaces the toast as a whole, like a stage of promise(): the message and the buttons from the previous call are not carried over. A pointed edit of a shown toast is update(id, patch), which has patch semantics.

Announcing toasts to a screen reader

A toast announces itself: role="status", and warning/danger an assertive role="alert". There is no permanent live-region wrapper over the list and there must not be: nesting regions with different assertiveness is not defined by the specification — browsers and screen readers diverge up to losing the announcement entirely. The container remains a named role="region" (regionLabel), which is navigation rather than an announcement.

Swiping

A toast is swiped towards its own edge of the screen: a stack on the right leaves to the right, one on the left to the left. Swiping into the depth of the screen would read as an attempt to get something out from under the toast rather than to throw it away, so the “backwards” movement goes with resistance — the toast follows the finger but does not close.

The threshold is a quarter of the width of the toast, but no less than 56px. If released earlier, the toast returns; if released further, it closes. An interrupted gesture returns the toast: if the pointer was taken by the browser (a system gesture, a call, the loss of the window), the user did not complete the gesture, and finishing it on their behalf is not allowed.

While a toast is being dragged, its timer stands still — otherwise the notification would burn out right under the finger, in the middle of the gesture.

A released toast flies off past its edge and only then closes. The flight is the same transition as in the other states of a toast, so there is nothing to fix for prefers-reduced-motion and no need: the global clamp compresses the transition to instantaneous, and the gesture itself is direct manipulation rather than movement of the interface, and there is nothing to hold back.

swipe-dismiss="false" switches the gesture off; the keyboard equivalent remains in the process.

The keyboard

The toasts are teleported to the end of body, so an “Undo” button lies beyond a reasonable number of Tab presses. F6 (the focusHotkey prop, false switches it off) moves the focus to the top toast, and from there the actions are walked with an ordinary Tab. The toast itself is not a Tab stop of its own — tabindex="-1": a pop-up notification must not intercept the walk over the page.

The same from the outside — through a ref:

<GrToaster ref="toaster" />

focus() returns false if the stack is empty.

Delete and Backspace on a focused toast close it — the same action as a swipe. The focus moves to the neighbouring toast if there is one: there is nowhere to fall onto body in the middle of a stack that is still being read. Escape is deliberately not taken — the toaster is not a modal layer, and intercepting it would close a notification instead of the dialog underneath.

The width

width is a number (pixels) or a CSS length; it travels into --gr-toaster-width, so the same thing is set by the theme as well. The default is 360px, and the ceiling is calc(100vw - 2rem).

The pause on auto-closing

The cursor, focus inside the stack or a swipe that has begun stops the timers of all of the visible toasts and freezes the progress bar (WCAG 2.2.1). Toasts from the queue (beyond maxVisible) are always paused — the countdown starts when a toast becomes visible.

Playground 4

Loading…

Code
<GrToaster />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
placementGrToasterPlacement | undefined"top-right"The corner of the screen for the stack of notifications.
regionLabelstring | undefinedundefinedThe a11y label of the region container (i18n).
widthstring | number | undefinedundefinedThe width of the stack. A number means pixels, a string any CSS length. It goes into `--gr-toaster-width`, so the same can be set by the theme as well.
dismissLabelstring | undefinedundefinedThe a11y label of the close button (i18n).
maxVisiblenumber | undefined4The maximum of toasts visible at once; the rest wait in the queue. `4` by default.
focusHotkeystring | false | undefined"F6"The key that moves the focus into the stack of notifications; `false` turns the hotkey off. `F6` is the APG recommendation for moving between the "regions" of a page.
swipeDismissboolean | undefinedtrueSwiping a toast towards its edge of the screen. The keyboard equivalent — `Delete` and `Backspace` on a focused toast — stays even with the gesture turned off.

Slots

SlotTypeDescription
actions{ toast: Toast; dismiss: () => void; }

Methods / Expose

Methods / ExposeTypeDescription
focus() => boolean

Examples 7

Interactive toaster constructor

Builderdepends on the showcase environment
<!-- 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>

Sticky toast and manual clear

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>

Queued workflow feedback

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>

Action buttons: size, variant, multiple

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>

Custom action buttons via slot

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>

Focus Hotkey

Тосты живут в конце 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>

Swipe

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

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>

Component documentationAll components