GrToaster
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
| Need | Take |
|---|---|
| The message belongs to a place on the page | GrAlert |
| A server error with details | GrResponseErrorBanner |
| An answer from the user is needed | GrConfirmDialog |
| A long process is running | GrProgressBar |
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…
<GrToaster />Install
npm i @feugene/granularityImport
import { GrToaster } from '@feugene/granularity/components/GrToaster'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
placement | GrToasterPlacement | undefined | "top-right" | The corner of the screen for the stack of notifications. |
regionLabel | string | undefined | undefined | The a11y label of the region container (i18n). |
width | string | number | undefined | undefined | The 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. |
dismissLabel | string | undefined | undefined | The a11y label of the close button (i18n). |
maxVisible | number | undefined | 4 | The maximum of toasts visible at once; the rest wait in the queue. `4` by default. |
focusHotkey | string | 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. |
swipeDismiss | boolean | undefined | true | Swiping 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
| Slot | Type | Description |
|---|---|---|
actions | { toast: Toast; dismiss: () => void; } | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => boolean | — |
Examples 7
Interactive toaster constructor
<!-- 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('&', '&').replaceAll('"', '"')
}
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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
Сторона смахивания идёт за стеком: у правого края тост уходит вправо, у левого — влево. Отпустите раньше порога — вернётся на место; оборвите жест — тоже вернётся.
<!-- 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>