ToastUpload

Тост для прогресса загрузки одного или нескольких файлов. Содержит общий прогресс-бар, список файлов с per-file статусами и actions (pause/resume/cancel/retry), кнопку collapse/expand. По умолчанию не auto-dismiss’ится — закрывается явно через crossклик или вызов API.

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

  • Загрузка одного или нескольких файлов с возможностью отмены / паузы.
  • Длительная операция, требующая видимости прогресса и контроля.

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

  • Короткий ack об успешной загрузке (без прогресса) — используй UserAction с success.
  • Системное уведомление об ошибке без прогресса — используй SystemEvent с error.

Анатомия

Status

Общий статус загрузки:

  • loading — идёт загрузка (анимированный progress).
  • pause — пауза.
  • error — ошибка для всей операции.
  • uploaded — все файлы загружены.
  • errorUploaded — частичный результат (часть с ошибкой).

Collapsed (default false)

Сворачивает список файлов, оставляя только заголовок и общий progress. Управляется через collapsed + onCollapsed.

Files

Массив UploadItem с per-file статусом и actions. Каждый item опционально поддерживает subscribeToState для частых апдейтов прогресса без ре-рендера всего списка.

General actions

generalActions — controls для всей операции (onPause, onContinue, onRetry). Кнопка cancel отдельным prop cancelButton.

Установка

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

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

Базовый Upload

Базовый UploadОдин тост, несколько файлов, общий прогресс
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import {
  POSITION_SYSTEM_EVENT,
  TOAST_UPLOAD_ITEM_STATUS,
  toaster,
  TOASTER_TYPE,
  ToasterContainer,
  UploadItem,
} from '@cloud-ru/ds-toaster';
import { useState } from 'react';

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

const CONTAINER_ID = 'demo-upload-basic';
const TOAST_ID = 'demo-upload-basic-toast';

const initialFiles: UploadItem[] = [
  {
    id: '1',
    title: 'document.pdf',
    status: TOAST_UPLOAD_ITEM_STATUS.Loading,
    statusLabel: 'Загрузка...',
    progress: 45,
    formattedSize: '12.4 МБ',
  },
  {
    id: '2',
    title: 'image.png',
    status: TOAST_UPLOAD_ITEM_STATUS.Uploaded,
    statusLabel: 'Загружено',
    progress: 100,
    formattedSize: '2.1 МБ',
    actions: {},
    link: { label: 'Открыть', href: '#' },
  },
];

const CLOSE_DELAY_MS = 1500;

export function UploadBasic() {
  const [files, setFiles] = useState<UploadItem[]>(initialFiles);

  const removeFile = (id: string) => setFiles(prev => prev.filter(f => f.id !== id));

  const cancelAll = () => {
    const stopped = files.map(f =>
      f.status === TOAST_UPLOAD_ITEM_STATUS.Loading ? { ...f, status: TOAST_UPLOAD_ITEM_STATUS.Pause, actions: {} } : f,
    );
    setFiles(stopped);
    toaster.upload.startOrUpdate({
      id: TOAST_ID,
      title: 'Загрузка остановлена',
      description: 'Закроется автоматически',
      status: 'pause',
      progress: { current: 0, total: stopped.length },
      files: stopped,
      closable: true,
      containerId: CONTAINER_ID,
    });
    setTimeout(() => {
      toaster.upload.dismiss({ containerId: CONTAINER_ID });
      setFiles(initialFiles);
    }, CLOSE_DELAY_MS);
  };

  const filesWithActions: UploadItem[] = files.map(f =>
    f.status === TOAST_UPLOAD_ITEM_STATUS.Loading ? { ...f, actions: { onCancel: () => removeFile(f.id ?? '') } } : f,
  );

  const open = () => {
    const completed = filesWithActions.filter(f => f.status === TOAST_UPLOAD_ITEM_STATUS.Uploaded).length;
    toaster.upload.startOrUpdate({
      id: TOAST_ID,
      title: 'Загрузка файлов',
      description: `${completed} из ${filesWithActions.length} файлов`,
      status: 'loading',
      progress: { current: completed, total: filesWithActions.length },
      files: filesWithActions,
      closable: true,
      onCancelAll: cancelAll,
      containerId: CONTAINER_ID,
    });
  };

  return (
    <PortalContextProvider>
      <div className={styles.uploadFrame}>
        <Button appearance={APPEARANCE.Primary} label='Открыть Upload' onClick={open} />
        <Button
          appearance={APPEARANCE.Neutral}
          label='Сбросить'
          onClick={() => {
            setFiles(initialFiles);
            toaster.upload.dismiss({ containerId: CONTAINER_ID });
          }}
        />
        <ToasterContainer
          type={TOASTER_TYPE.Upload}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={1}
          autoClose={false}
        />
      </div>
    </PortalContextProvider>
  );
}

Прогрессивная загрузка → uploaded

Прогрессивная загрузка → uploadedstartOrUpdate с фиксированным id, прогресс 0 → 100 каждые 400ms, статус меняется на uploaded
tsx
import { APPEARANCE, Button } from '@cloud-ru/ds-button';
import { PortalContextProvider } from '@cloud-ru/ds-portal-context';
import {
  POSITION_SYSTEM_EVENT,
  TOAST_UPLOAD_ITEM_STATUS,
  TOAST_UPLOAD_STATUS,
  toaster,
  TOASTER_TYPE,
  ToasterContainer,
  UploadItem,
} from '@cloud-ru/ds-toaster';
import { useEffect, useRef } from 'react';

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

const CONTAINER_ID = 'demo-upload-progress';
const TOAST_ID = 'demo-upload-progress-toast';
const TICK_MS = 400;
const CLOSE_DELAY_MS = 1500;

function getStatusLabel(progress: number, status: UploadItem['status']): string {
  if (status === TOAST_UPLOAD_ITEM_STATUS.Uploaded) return 'Загружено';
  if (status === TOAST_UPLOAD_ITEM_STATUS.Error) return 'Ошибка';
  return `${progress}%`;
}

function makeFile(progress: number, status: UploadItem['status'], onCancel: () => void): UploadItem {
  return {
    id: '1',
    title: 'video.mp4',
    status,
    statusLabel: getStatusLabel(progress, status),
    progress,
    formattedSize: '48.6 МБ',
    actions: status === TOAST_UPLOAD_ITEM_STATUS.Loading ? { onCancel } : {},
    ...(status === TOAST_UPLOAD_ITEM_STATUS.Uploaded ? { link: { label: 'Открыть', href: '#' } } : {}),
  };
}

export function UploadProgress() {
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const progressRef = useRef(0);

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
  };

  const cancel = () => {
    stop();
    if (closeTimerRef.current) return;
    toaster.upload.startOrUpdate({
      id: TOAST_ID,
      title: 'Загрузка остановлена',
      description: 'Закроется автоматически',
      status: TOAST_UPLOAD_STATUS.Pause,
      progress: { current: progressRef.current, total: 100 },
      files: [makeFile(progressRef.current, TOAST_UPLOAD_ITEM_STATUS.Pause, cancel)],
      generalActions: {},
      closable: true,
      containerId: CONTAINER_ID,
    });
    closeTimerRef.current = setTimeout(() => {
      closeTimerRef.current = null;
      toaster.upload.dismiss({ containerId: CONTAINER_ID });
    }, CLOSE_DELAY_MS);
  };

  useEffect(
    () => () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
      if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
    },
    [],
  );

  const start = () => {
    stop();
    if (closeTimerRef.current) {
      clearTimeout(closeTimerRef.current);
      closeTimerRef.current = null;
    }
    progressRef.current = 0;
    let progress = 0;

    toaster.upload.startOrUpdate({
      id: TOAST_ID,
      title: 'Загрузка файла',
      description: `${progress}% из 100%`,
      status: TOAST_UPLOAD_STATUS.Loading,
      progress: { current: progress, total: 100 },
      files: [makeFile(progress, TOAST_UPLOAD_ITEM_STATUS.Loading, cancel)],
      generalActions: { onPause: stop },
      closable: true,
      onCancelAll: cancel,
      containerId: CONTAINER_ID,
    });

    intervalRef.current = setInterval(() => {
      progress += 10;
      progressRef.current = progress;
      const done = progress >= 100;

      toaster.upload.startOrUpdate({
        id: TOAST_ID,
        title: done ? 'Файл загружен' : 'Загрузка файла',
        description: done ? '1 из 1 файлов' : `${progress}% из 100%`,
        status: done ? TOAST_UPLOAD_STATUS.Uploaded : TOAST_UPLOAD_STATUS.Loading,
        progress: { current: progress, total: 100 },
        files: [
          makeFile(progress, done ? TOAST_UPLOAD_ITEM_STATUS.Uploaded : TOAST_UPLOAD_ITEM_STATUS.Loading, cancel),
        ],
        generalActions: {},
        closable: true,
        onCancelAll: done ? undefined : cancel,
        containerId: CONTAINER_ID,
      });

      if (done) stop();
    }, TICK_MS);
  };

  return (
    <PortalContextProvider>
      <div className={styles.uploadFrame}>
        <Button appearance={APPEARANCE.Primary} label='Запустить загрузку' onClick={start} />
        <Button appearance={APPEARANCE.Neutral} label='Закрыть все' onClick={cancel} />
        <ToasterContainer
          type={TOASTER_TYPE.Upload}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={1}
          autoClose={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,
  TOAST_UPLOAD_ITEM_STATUS,
  TOAST_UPLOAD_STATUS,
  toaster,
  TOASTER_TYPE,
  ToasterContainer,
  UploadItem,
} from '@cloud-ru/ds-toaster';
import { useState } from 'react';

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

const CONTAINER_ID = 'demo-upload-close-hooks';
const TOAST_ID = 'demo-upload-close-hooks-toast';

const file: UploadItem = {
  id: '1',
  title: 'archive.zip',
  status: TOAST_UPLOAD_ITEM_STATUS.Loading,
  statusLabel: '40%',
  progress: 40,
  formattedSize: '120 МБ',
};

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

  const open = () => {
    toaster.upload.startOrUpdate({
      id: TOAST_ID,
      title: 'Загрузка файла',
      description: '40% из 100%',
      status: TOAST_UPLOAD_STATUS.Loading,
      progress: { current: 40, total: 100 },
      files: [file],
      closable: true,
      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='Открыть Upload' 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.Upload}
          containerId={CONTAINER_ID}
          position={POSITION_SYSTEM_EVENT.BottomRight}
          limit={1}
          autoClose={false}
        />
      </div>
    </PortalContextProvider>
  );
}

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

Тосты создаются через toaster.upload.startOrUpdate({...}). Один и тот же id обновляет существующий тост — отдельный update-метод не нужен. Закрытие — toaster.upload.dismiss(...).

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

const id = await toaster.upload.startOrUpdate({
  id: 'main-upload',
  containerId: 'main',
  title: 'Загрузка файлов',
  description: '40% из 100%',
  status: 'loading',
  progress: { current: 40, total: 100 },
  files: [
    { id: '1', title: 'doc.pdf', status: 'loading', statusLabel: '40%', progress: 40, formattedSize: '12 МБ' },
  ],
  onCancelAll: () => abortAllUploads(),
  onCloseClick: (_e, close) => confirm('Закрыть?') && close?.(),
  onClose: id => log('closed', id),
})

toaster.upload.dismiss({ containerId: 'main' })

Props

Types

PropsToastUploadProps
PropTypeDefaultRequiredDescription
classNamestringnoCSS-класс контейнера
closablebooleantruenoПоказывать кнопку закрытия тостера. По умолчанию `true`.
closeToast(() => void)no
collapsedbooleannoТостер свернут/развернут
data-test-idstringno
descriptionstringyesОписание статуса загрузки
filesUploadItem[]yesЗагружаемые элементы
generalActionsOmit<UploadActions, "onCancel">noЭкшены для управления общей загрузкой
onCancelAll((e: MouseEvent<HTMLButtonElement, MouseEvent>) => void)noКолбэк кнопки отмены всей загрузки. Кнопка рендерится только при наличии колбэка; подпись — из `@cloud-ru/ds-locale` (`ToastUpload.cancelAll`).
onCloseClick((e: MouseEvent<HTMLButtonElement, MouseEvent>, close?: (() => void)) => void)noЗакрыть тостер
onCollapsed((collapsed: boolean) => void)noРазвернуть/свернуть тостер
progress{ current: number; total: number; }yesОбщий прогресс загрузки
status"error" | "errorUploaded" | "loading" | "pause" | "uploaded"yesОбщий статус загрузки
titlestringnoЗаголовок тостера
toastProps{ autoClose?: number | false; }no

Unions

Types

ToastUploadProps

Unions

Storybook

Playground — все props через controls (status, progress, generalActions, collapsed, файлы):

VisualMatrix · статусы × состояния

Status (loading / paused / success / errorUploaded), collapsed/expanded, разные item-state’ы в списке файлов:

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

Figma

Смотри также