GrToaster

  • ядро
  • Обратная связь
  • API · 8 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

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

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

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

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

Playground контролы выводятся из API, а не пишутся руками

Сцена

Этот компонент живёт поверх экрана, а не внутри холста: его открывает экран, а конструктору открывать его неоткуда. Примеры ниже показывают его в работе.

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • Что остаётся после действия со счётом

    Напоминание подтверждается и уходит само. Аннулирование обратимо, поэтому диалога подтверждения нет: тост несёт «Undo» и держится восемь секунд, чтобы им успели воспользоваться. PDF — один тост на весь запрос: promise переписывает «Preparing» в результат на месте, а сам промис возвращается как есть — отказ по-прежнему ловим мы.

    INV-2026-0418 · Northwind Traders€1,284.50 · due 1 NovemberOpen
  • Сканер, который теряет сеть

    Сокет повторяет попытку каждую секунду и сообщает о каждой неудаче; dedupeKey сворачивает повторы в один живой тост и перезапускает его таймер, а тост «снова в сети» встаёт на его место под тем же ключом. Пять сканов — пять подтверждений: с третьего стек складывается в стопку через collapse, max-visible ограничивает экран, а слот actions даёт предупреждению «Retry now», а принятой коробке — «Undo scan».

    Receiving · Dock 3Online — scans are sent at once
  • Дотянуться до тоста с клавиатуры

    Выгрузка заканчивается, пока пользователь глубоко в длинной странице. focus-hotkey — по умолчанию F6, клавиша APG для перехода между регионами, — переводит в стек откуда угодно, а focus() делает то же с кнопки и сообщает, было ли что фокусировать. Delete закрывает тост под фокусом — клавиатурный двойник смахивания, которое здесь выключено через swipe-dismiss.

    Customers · export

API порождается из пакета · разойтись не может

Props

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

Слоты

SlotTypeОписание
actions{ toast: Toast; dismiss: () => void; }Кнопки тоста вместо `action`/`actions` из его описания. Получает сам `toast` и `dismiss` — функцию, закрывающую именно этот тост.

Методы

MethodsСигнатураОписание
focus() => booleanФокус на верхний тост, как по `focusHotkey`; `false` — тостов нет.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrToasterPlacement, GrToasterProps, GrToastTone } from '@feugene/granularity/components/GrToaster'

Файлdist/types/components/GrToaster/grToasterStyles.d.ts

GrToasterPlacementtype
type GrToasterPlacement = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';

Файлdist/types/components/GrToaster/GrToaster.vue.d.ts

GrToasterPropsinterface
interface GrToasterProps {
    /** Угол экрана для стека уведомлений. */
    placement?: GrToasterPlacement;
    /** Максимум одновременно видимых тостов; остальные ждут в очереди. По умолчанию `4`. */
    maxVisible?: number;
    /** A11y-лейбл кнопки закрытия (i18n). */
    dismissLabel?: string;
    /** A11y-лейбл контейнера-региона (i18n). */
    regionLabel?: string;
    /**
     * Ширина стека. Число — пиксели, строка — любая CSS-длина. Уезжает в
     * `--gr-toaster-width`, поэтому то же самое можно задать и темой.
     */
    width?: string | number;
    /**
     * Клавиша перевода фокуса в стек уведомлений; `false` отключает хоткей.
     * `F6` — рекомендация APG для перехода между «регионами» страницы.
     */
    focusHotkey?: string | false;
    /**
     * Смахивание тоста к своему краю экрана. Клавиатурный эквивалент — `Delete`
     * и `Backspace` на сфокусированном тосте — остаётся и при выключенном жесте.
     */
    swipeDismiss?: boolean;
    /**
     * Со скольких видимых тостов стек схлопывается в стопку: колонка занимает
     * место одного тоста, а остальные выглядывают из-под передней. Разворачивается
     * по наведению и по фокусу. `false` — не схлопывать никогда.
     *
     * Порог, а не переключатель: пока тостов мало, колонка читается лучше стопки,
     * и менять её незачем.
     */
    collapse?: number | false;
}

Файлdist/types/components/GrToaster/config.d.ts

grToasterConfigconst
const grToasterConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrToaster">;

Файлdist/types/components/GrToaster/safelist.d.ts

grToasterSafelistconst
const grToasterSafelist: string[];

Файлdist/types/composables/useToast.d.ts

GrToastTonetype
type GrToastTone = GrTone;
ToastActiontype

Опциональная action-кнопка тоста: label — текст, onClick — обработчик. По клику вызывается onClick, затем тост закрывается (если dismissOnClick не установлен в false). size/variant настраивают вид кнопки.

type ToastAction = {
    label: string;
    onClick: () => void;
ToastInputtype
type ToastInput = {
    title: string;
    message?: string;
    tone?: GrToastTone;
    /**
     * Автозакрытие через N мс. `0` или отрицательное значение — «навсегда»
     * (до явного вызова `dismiss(id)` / `clear()`). По умолчанию: `3500`.
     */
    timeoutMs?: number;
    /** Одна action-кнопка в теле тоста (шорткат для `actions: [action]`). */
    action?: ToastAction;
    /** Несколько action-кнопок в теле тоста. Рендерятся после `action`. */
    actions?: ToastAction[];
    /**
     * Схлопывать повтор: `push` с уже занятым ключом обновляет **живой** тост
     * и перезапускает его автозакрытие вместо того, чтобы завести второй.
     *
     * Ключ действует только пока тост на экране. Это не то же самое, что помнить
     * последнее показанное: повтор действия с тем же текстом обязан показаться
     * снова, иначе второе «Сохранено» пропадёт молча.
     */
    dedupeKey?: string;
};
Toasttype
type Toast = {
    id: string;
    title: string;
    message?: string;
    tone: GrToastTone;
    /** Исходный таймаут автозакрытия, мс (`0` — без автозакрытия). Нужен для progress-бара. */
    timeoutMs: number;
    /** Одна action-кнопка в теле тоста. */
    action?: ToastAction;
    /** Несколько action-кнопок в теле тоста. */
    actions?: ToastAction[];
    /** Ключ схлопывания повторов, если тост создан с ним. */
    dedupeKey?: string;
};

Файлdist/types/components/GrToaster/index.d.ts

GrToasterInstancetype
type GrToasterInstance = ComponentExposed<typeof GrToasterComponent>;

Доступность

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

Использование из прозы библиотеки

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

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

ts
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; при переполнении вытесняются самые старые, вместе с их таймерами. Иначе поток событий (переподключение сокета, цикл ошибок) копил бы очередь и вываливал её на пользователя, когда стек освободится.

ts
app.use(granularityToastPlugin, { maxToasts: 50 })
Стопка вместо колонки

maxVisible держит число видимых тостов, но ничего не говорит про занятое ими место: подняв его до восьми, приложение получало колонку в пол-экрана поверх того интерфейса, ради которого уведомления и показывают.

collapse (по умолчанию 3) задаёт, со скольких видимых тостов стек схлопывается в стопку: передняя карточка видна целиком, из-под неё выглядывают края следующих, и весь стек занимает место одного тоста. collapse: false выключает схлопывание совсем.

Порог, а не переключатель: пока тостов два-три, колонка читается лучше стопки — все сообщения видны сразу, и прятать их незачем.

Разворачивают стопку наведение и фокус — те же два сигнала, что ставят таймеры на паузу (см. «Пауза автозакрытия»). Раз уж читать стек начали, отсчёт всё равно обязан остановиться, и заводить для разворота третий сигнал значило бы развести эти два события во времени.

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

ТокенДефолтЧто меняет
--gr-toaster-stack-peek12pxнасколько выглядывает каждая следующая карточка
--gr-toaster-stack-scale0.05на сколько она уменьшается

Очередь этим не заменяется: стопка — про раскладку видимых тостов, очередь — про то, скольким вообще дают показаться.

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

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

ts
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:

vue
<GrToaster ref="toaster" />

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

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

Ширина

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

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

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