ToastSystemEvent

Системные уведомления о состоянии системы — успех/ошибка/предупреждение, опционально с link или action-кнопками. По умолчанию auto-dismiss 5s, в stacked-контейнере накапливается в deck-стопку.

Когда использовать

  • Сообщить о результате асинхронной операции (сохранение, отправка, синхронизация).
  • Информировать о системных событиях, которые не блокируют UI (фоновые задачи, networking).
  • Показать критическую ошибку с возможностью повторить (appearance='errorCritical' + action).

Когда не нужен SystemEvent:

  • Короткий ack на пользовательское действие («скопировано», «отменено») — используй UserAction.
  • Прогресс загрузки файлов — используй Upload.

Анатомия

Appearance (default neutral)

  • neutral — информационное (дефолт).
  • success — успешное завершение действия.
  • warning — предупреждение, требующее внимания.
  • error — обычная ошибка.
  • errorCritical — критическая ошибка с акцентом (красный фон, изменённый стиль link).

Progress bar (default true)

Полоса auto-dismiss таймера внизу карточки. Анимация совпадает с autoClose мс. Pause при hover контейнера и в свёрнутом стеке (ghost-карточки).

Closable (default true)

Крестик закрытия в правом верхнем углу. Клик вызывает onCloseClick(e, closeToast); если хендлер не задан — вызывается closeToast.

Action (default undefined)

Массив 1–2 кнопок в footer’е. По соглашению Figma primary-кнопка находится справа, поэтому первый элемент массива (action[0]) рендерится справа как primary action, второй (action[1]) — слева как secondary. Используется для критических подтверждений (autoClose: false рекомендуется).

Установка

import { toaster, ToasterContainer, TOASTER_TYPE } from '@cloud-ru/ds-toaster'

Примеры использования

Базовые appearance

Базовые appearanceSuccess и error с link
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';

const CONTAINER_ID = 'demo-basic-system-event';

export function BasicSystemEvent() {
  return (
    <PortalContextProvider>
      <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
        <Button
          appearance={APPEARANCE.Primary}
          label='Success'
          onClick={() =>
            toaster.systemEvent.success({
              title: 'Изменения сохранены',
              description: 'Все данные синхронизированы',
              containerId: CONTAINER_ID,
            })
          }
        />
        <Button
          appearance={APPEARANCE.Critical}
          label='Error'
          onClick={() =>
            toaster.systemEvent.error({
              title: 'Не удалось сохранить',
              description: 'Попробуйте ещё раз',
              link: { label: 'Подробнее', href: '#' },
              containerId: CONTAINER_ID,
            })
          }
        />
        <Button
          appearance={APPEARANCE.Neutral}
          label='Закрыть все'
          onClick={() => toaster.systemEvent.dismiss({ containerId: CONTAINER_ID })}
        />
        <ToasterContainer
          type={TOASTER_TYPE.SystemEvent}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={5}
          autoClose={5000}
          stacked={false}
          displayCloseAllButton={false}
        />
      </div>
    </PortalContextProvider>
  );
}

Stack-режим

Stack-режимHover разворачивает стопку, mouse-leave сворачивает
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';
import { useRef } from 'react';

const CONTAINER_ID = 'demo-stacked-system-event';

export function StackedSystemEvent() {
  const counter = useRef(0);

  const fire = () => {
    counter.current += 1;
    toaster.systemEvent.neutral({
      title: `Уведомление #${counter.current}`,
      description: 'Hover по стопке разворачивает её',
      containerId: CONTAINER_ID,
    });
  };

  return (
    <PortalContextProvider>
      <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
        <Button appearance={APPEARANCE.Primary} label='Добавить тост' onClick={fire} />
        <Button
          appearance={APPEARANCE.Neutral}
          label='Закрыть все'
          onClick={() => toaster.systemEvent.dismiss({ containerId: CONTAINER_ID })}
        />
        <ToasterContainer
          type={TOASTER_TYPE.SystemEvent}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={5}
          autoClose={5000}
          stacked
          displayCloseAllButton
        />
      </div>
    </PortalContextProvider>
  );
}

С action-кнопками

С action-кнопкамиwarning + 2 кнопки, autoClose отключён
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';

const CONTAINER_ID = 'demo-system-event-with-action';

export function SystemEventWithAction() {
  const dismiss = () => toaster.systemEvent.dismiss({ containerId: CONTAINER_ID });

  // `autoClose: false` — тост висит, пока пользователь не нажмёт action или
  // close. Используется, когда от пользователя ждут явного решения.
  const openSticky = () =>
    toaster.systemEvent.warning({
      title: 'Подтвердите действие',
      description: 'Удалить этот файл без возможности восстановления?',
      action: [
        { label: 'Удалить', onClick: dismiss },
        { label: 'Отмена', onClick: dismiss },
      ],
      autoClose: false,
      containerId: CONTAINER_ID,
    });

  // Без `autoClose: false` — тост закроется по таймеру контейнера (autoClose
  // = 5000ms). Action остаётся как «удобный сокращённый путь», но решение
  // не блокирующее.
  const openAutoClosing = () =>
    toaster.systemEvent.warning({
      title: 'Файл будет удалён через 5 секунд',
      description: 'Нажмите «Отменить», чтобы прервать.',
      action: [{ label: 'Отменить', onClick: dismiss }],
      containerId: CONTAINER_ID,
    });

  return (
    <PortalContextProvider>
      <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
        <Button appearance={APPEARANCE.Critical} label='Sticky (autoClose: false)' onClick={openSticky} />
        <Button
          appearance={APPEARANCE.Primary}
          label='С таймером (autoClose из контейнера)'
          onClick={openAutoClosing}
        />
        <Button appearance={APPEARANCE.Neutral} label='Закрыть все' onClick={dismiss} />
        <ToasterContainer
          type={TOASTER_TYPE.SystemEvent}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={5}
          autoClose={5000}
          stacked={false}
          displayCloseAllButton={false}
        />
      </div>
    </PortalContextProvider>
  );
}

Обновление: loading → success / error

Обновление: loading → success / errorОткрываем neutral-тост без autoClose, через 1.5s update.success / update.error
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';

const CONTAINER_ID = 'demo-system-event-saving-flow';
const PENDING_DELAY_MS = 1500;

export function SystemEventSavingFlow() {
  const run = async () => {
    const id = await toaster.systemEvent.neutral({
      title: 'Сохранение…',
      description: 'Не закрывайте окно',
      autoClose: false,
      progressBar: false,
      closable: false,
      containerId: CONTAINER_ID,
    });

    setTimeout(() => {
      toaster.systemEvent.update.success(id, {
        title: 'Сохранено',
        description: 'Все изменения зафиксированы',
        autoClose: 3000,
        containerId: CONTAINER_ID,
      });
    }, PENDING_DELAY_MS);
  };

  const fail = async () => {
    const id = await toaster.systemEvent.neutral({
      title: 'Отправка…',
      description: 'Ждём ответа сервера',
      autoClose: false,
      progressBar: false,
      closable: false,
      containerId: CONTAINER_ID,
    });

    setTimeout(() => {
      toaster.systemEvent.update.error(id, {
        title: 'Ошибка соединения',
        description: 'Попробуйте ещё раз',
        autoClose: 5000,
        closable: true,
        containerId: CONTAINER_ID,
      });
    }, PENDING_DELAY_MS);
  };

  return (
    <PortalContextProvider>
      <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
        <Button appearance={APPEARANCE.Primary} label='Сохранить → success' onClick={run} />
        <Button appearance={APPEARANCE.Critical} label='Сохранить → error' onClick={fail} />
        <Button
          appearance={APPEARANCE.Neutral}
          label='Закрыть все'
          onClick={() => toaster.systemEvent.dismiss({ containerId: CONTAINER_ID })}
        />
        <ToasterContainer
          type={TOASTER_TYPE.SystemEvent}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={3}
          autoClose={5000}
          stacked={false}
          displayCloseAllButton={false}
        />
      </div>
    </PortalContextProvider>
  );
}

Положения контейнера

Положения контейнера6 позиций: top/bottom × left/center/right. Каждое — свой ToasterContainer с уникальным containerId.
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, SystemEventPosition, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';

const POSITIONS: SystemEventPosition[] = Object.values(POSITION_SYSTEM_EVENT);

const containerIdFor = (position: SystemEventPosition) => `demo-system-event-position-${position}`;

export function SystemEventPositions() {
  const fire = (position: SystemEventPosition) =>
    toaster.systemEvent.neutral({
      title: `Тост в ${position}`,
      description: 'Каждое положение — свой контейнер',
      containerId: containerIdFor(position),
    });

  const dismissAll = () =>
    POSITIONS.forEach(position => toaster.systemEvent.dismiss({ containerId: containerIdFor(position) }));

  return (
    <PortalContextProvider>
      <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
        {POSITIONS.map(position => (
          <Button key={position} appearance={APPEARANCE.Primary} label={position} onClick={() => fire(position)} />
        ))}
        <Button appearance={APPEARANCE.Neutral} label='Закрыть все' onClick={dismissAll} />
        {POSITIONS.map(position => (
          <ToasterContainer
            key={position}
            type={TOASTER_TYPE.SystemEvent}
            containerId={containerIdFor(position)}
            position={position}
            limit={3}
            autoClose={4000}
            stacked={false}
            displayCloseAllButton={false}
          />
        ))}
      </div>
    </PortalContextProvider>
  );
}

Хуки закрытия: onCloseClick + onClose

Хуки закрытия: onCloseClick + onCloseПодтверждение через onCloseClick(e, close) и аналитика через onClose(id)
  • Лог пуст
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import { POSITION_SYSTEM_EVENT, toaster, TOASTER_TYPE, ToasterContainer } from '@cloud-ru/ds-toaster';
import { useState } from 'react';

import styles from './styles.module.scss';

const CONTAINER_ID = 'demo-system-event-close-hooks';

export function SystemEventCloseHooks() {
  const [log, setLog] = useState<string[]>([]);
  const push = (line: string) => setLog(prev => [line, ...prev].slice(0, 4));

  const open = () => {
    toaster.systemEvent.warning({
      title: 'Несохранённые изменения',
      description: 'Закрыть тост?',
      containerId: CONTAINER_ID,
      onCloseClick: (_e, close) => {
        if (window.confirm('Закрыть уведомление?')) {
          push('onCloseClick → close()');
          close?.();
        } else {
          push('onCloseClick → отменено');
        }
      },
      onClose: id => push(`onClose(${String(id)})`),
    });
  };

  return (
    <PortalContextProvider>
      <div className={styles.uploadFrame}>
        <Button appearance={APPEARANCE.Primary} label='Показать тост' onClick={open} />
        <Button appearance={APPEARANCE.Neutral} label='Очистить лог' onClick={() => setLog([])} />
        <ul className={styles.eventLog} aria-label='Лог событий'>
          {log.length === 0 ? <li>Лог пуст</li> : log.map((line, i) => <li key={i}>{line}</li>)}
        </ul>
        <ToasterContainer
          type={TOASTER_TYPE.SystemEvent}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={3}
          autoClose={false}
        />
      </div>
    </PortalContextProvider>
  );
}

Императивный API

Тосты создаются через toaster.systemEvent.<appearance>({...}) — один метод на каждый visual вариант. Обновление существующего тоста — toaster.systemEvent.update.<appearance>(id, {...}). Закрытие — toaster.systemEvent.dismiss(...).

import { toaster, type SystemEventOptions } from '@cloud-ru/ds-toaster'

const id = await toaster.systemEvent.error({
  id: 'save-error',
  containerId: 'main',
  title: 'Не удалось сохранить',
  description: 'Сервер вернул 500',
  link: { label: 'Подробнее', href: '/logs/123' },
  autoClose: false,
  onCloseClick: (_e, close) => confirm('Закрыть?') && close?.(),
  onClose: id => log('closed', id),
})

toaster.systemEvent.update.warning(id, {
  title: 'Сохранение временно недоступно',
  description: 'Повтор через 30s',
})

toaster.systemEvent.dismiss(id)

Props

Types

PropsToastSystemEventProps
PropTypeDefaultRequiredDescription
actionToastButtonActionProps[]noКнопки в footer'е
appearance"error" | "errorCritical" | "neutral" | "success" | "warning"neutralnoЦветовая схема
autoClosenumber | falseundefined (резолвится в `AUTO_CLOSE_TIME[TOASTER_TYPE.SystemEvent]` = 5000)noВремя автозакрытия в мс. Три состояния различимы намеренно: - `undefined` — потребитель не указал значение, toaster-система подставит дефолт `AUTO_CLOSE_TIME[TOASTER_TYPE.SystemEvent]` (5000 мс) при рендере; именно этот случай позволяет ProgressBar анимироваться синхронно с auto-dismiss таймером менеджера. - `number` — явное значение от потребителя, используется как есть. - `false` — автозакрытие выключено, ProgressBar в этом режиме не рендерится.
classNamestringnoДоп. класс корневого элемента
closablebooleantruenoВозможность закрыть тост
closeToast(() => void)no
data-test-idstringno
descriptionstringnoОписание под заголовком
linkToastSystemEventLinknoSlot для ссылки
onCloseClick((e: MouseEvent<HTMLButtonElement, MouseEvent>, close?: (() => void)) => void)noКастомный обработчик клика по кнопке закрытия
progressBarbooleantruenoПоказывать ли ProgressBar внизу
titlestringyesЗаголовок тоста
toastProps{ autoClose?: number | false; }no

Unions

Types

ToastSystemEventProps

Unions

Storybook

Playground — все props через controls (appearance, link, action, closable, autoClose):

VisualMatrix · все варианты в одной сетке

Все 5 appearance × состояния (с/без link, с/без action, closable) — удобно для визуальной сверки и регрессии.

Связанные стори контейнера:

Figma

Смотри также