NotificationPanel

Панель ленты уведомлений: заголовок, опциональные фильтры (segmented control, chip toggle), кнопка «прочитать все», слот-список карточек и состояния loading / blank. Собрана на Drawer, поэтому поверхность адаптивная: на desktop — боковая панель, на mobile — bottom-sheet.

Namespace NotificationPanel.{Blank, Group, Stack} — субкомпоненты-композиции для слота content.

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

  • Лента уведомлений, открываемая из иконки в шапке или тулбаре.
  • Один и тот же контент панели на desktop и mobile без дублирования разметки.

Анатомия

Layout type (default desktop)

Раскладка приходит из AdaptiveProvider и определяет поверхность:

  • desktop — drawer (Figma notificationDrawer).
  • mobile — bottom-sheet (Figma notificationBottomSheet), chipToggle в этой раскладке не показывается.

Position (default right)

Сторона, с которой выезжает панель. Только desktop.

Width (default s)

Ширина панели. Только desktop.

Panel state

Состояния тела панели (соответствуют Figma variant content):

  • content — стандартный список карточек через слот content.
  • loading — loading={true} показывает скелетоны (skeletonsAmount, default 2).
  • blank — content={<NotificationPanel.Blank />} для пустого состояния и состояния ошибки.

Субкомпоненты слота content

  • NotificationPanel.Group — группа карточек с заголовком.
  • NotificationPanel.Stack — стек схлопывающихся карточек. Стек из одной карточки рендерится без обёртки.
  • NotificationPanel.Blank — пустое состояние (используется и для ошибки — с иконкой и текстом).

Установка

pnpm add @cloud-ru/ds-uikit-product-notification
import { NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';

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

Базовая лента

Базовая лентаСписок карточек через слот content, readAllButton помечает все прочитанными, onVisible — каждую при прокрутке.
tsx
import { Button } from '@cloud-ru/ds-button';
import { APPEARANCE, NotificationCard, NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';
import { useState } from 'react';

export function PanelBasic() {
  const [open, setOpen] = useState(false);
  const [readIds, setReadIds] = useState<Set<string>>(new Set());
  const markRead = (id: string) => setReadIds(prev => (prev.has(id) ? prev : new Set(prev).add(id)));

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
      <Button label='Уведомления' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
      <NotificationPanel
        open={open}
        onClose={() => setOpen(false)}
        title='Уведомления'
        readAllButton={{
          label: 'Прочитать всё',
          onClick: () => setReadIds(new Set(['inc-4821', 'quota-var-data', 'deploy-9217', 'backup-1729'])),
        }}
        content={
          <>
            <NotificationCard
              id='inc-4821'
              label='INC-4821'
              appearance={APPEARANCE.Error}
              title='Кластер k8s-prod-1 деградирован'
              description='3 из 5 нод недоступны последние 6 минут.'
              date='03:14'
              link={{ label: 'Открыть инцидент', href: '/incidents/INC-4821' }}
              unread={!readIds.has('inc-4821')}
              onVisible={markRead}
            />
            <NotificationCard
              id='quota-var-data'
              label='Storage'
              appearance={APPEARANCE.Warning}
              title='Лимит дисковой квоты'
              description='Использовано 92% /var/data — осталось 38 ГБ из 480 ГБ.'
              date='02:50'
              unread={!readIds.has('quota-var-data')}
              onVisible={markRead}
            />
            <NotificationCard
              id='deploy-9217'
              label='api-gateway · v2.18.3'
              appearance={APPEARANCE.Error}
              title='Деплой не прошёл health-check'
              description='Readiness probe вернул 503 на 4 из 6 подов.'
              date='вчера · 23:11'
              unread={!readIds.has('deploy-9217')}
              onVisible={markRead}
            />
            <NotificationCard
              id='backup-1729'
              label='Backup'
              appearance={APPEARANCE.Success}
              title='Резервная копия завершена'
              description='Бэкап БД prod-1 (412 ГБ) загружен в s3://backups-prod/.'
              date='вчера · 14:32'
            />
          </>
        }
      />
    </div>
  );
}

Загрузка

Загрузкаloading + skeletonsAmount={4} вместо слота content; фильтры и readAllButton остаются на месте.
tsx
import { Button } from '@cloud-ru/ds-button';
import { NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';
import { useState } from 'react';

export function PanelLoading() {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
      <Button label='Уведомления' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
      <NotificationPanel
        open={open}
        onClose={() => setOpen(false)}
        title='Уведомления'
        loading
        skeletonsAmount={4}
        segments={{
          items: [
            { value: 'all', label: 'Все' },
            { value: 'unread', label: 'Непрочитанные' },
            { value: 'mentions', label: 'Упоминания' },
          ],
          value: 'all',
          onChange: () => {},
        }}
        readAllButton={{ label: 'Прочитать всё', onClick: () => {} }}
      />
    </div>
  );
}

Ошибка загрузки

Ошибка загрузкиСостояние ошибки через content={<NotificationPanel.Blank />} с иконкой и текстом — сервер недоступен.
tsx
import { Button } from '@cloud-ru/ds-button';
import { CrossSVG } from '@cloud-ru/ds-icons/interface/system';
import { NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';
import { useState } from 'react';

export function PanelError() {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
      <Button label='Уведомления' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
      <NotificationPanel
        open={open}
        onClose={() => setOpen(false)}
        title='Уведомления'
        content={
          <NotificationPanel.Blank
            icon={{ icon: CrossSVG, appearance: 'neutral' }}
            title='Сервис временно недоступен'
            content='Восстановление уже идёт. Обновите страницу через минуту.'
          />
        }
      />
    </div>
  );
}

Полный сценарий продовой ленты

Полный сценарий продовой лентыSegments + chipToggle + settings + readAll, Group для критичных, Stack для повторяющихся алертов квоты по нескольким хостам.
tsx
import { Button } from '@cloud-ru/ds-button';
import { APPEARANCE, NotificationCard, NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';
import { useMemo, useState } from 'react';

type Filter = 'all' | 'unread' | 'mentions';

type CardId = 'inc-4821' | 'deploy-9217' | 'stack-quota' | 'mention-1' | 'backup-1729';

export function PanelFull() {
  const [open, setOpen] = useState(false);
  const [filter, setFilter] = useState<Filter>('all');
  const [importantOnly, setImportantOnly] = useState(false);
  const [muted, setMuted] = useState(false);
  const [readIds, setReadIds] = useState<Set<CardId>>(new Set());

  const markRead = (id: string) =>
    setReadIds(prev => (prev.has(id as CardId) ? prev : new Set(prev).add(id as CardId)));

  const isUnread = (id: CardId) => !readIds.has(id);

  // Какие карточки попадают под текущий фильтр. `important` — критичные алерты (error/warning).
  const visible = useMemo(() => {
    const matches = (id: CardId, important: boolean, mention: boolean) => {
      if (filter === 'unread' && !isUnread(id)) return false;
      if (filter === 'mentions' && !mention) return false;
      if (importantOnly && !important) return false;
      return true;
    };

    return {
      inc: matches('inc-4821', true, false),
      deploy: matches('deploy-9217', true, false),
      quota: matches('stack-quota', true, false),
      mention: matches('mention-1', false, true),
      backup: matches('backup-1729', false, false),
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [filter, importantOnly, readIds]);

  const attentionVisible = visible.inc || visible.deploy;

  const panel = muted ? (
    <NotificationPanel
      open={open}
      onClose={() => setOpen(false)}
      title='Уведомления'
      settings={{
        button: { onClick: () => setMuted(false) },
        actions: [{ content: { label: 'Снять заглушение' }, onClick: () => setMuted(false) }],
      }}
      content={<NotificationPanel.Blank />}
    />
  ) : (
    <NotificationPanel
      open={open}
      onClose={() => setOpen(false)}
      title='Уведомления'
      segments={{
        items: [
          { value: 'all', label: 'Все' },
          { value: 'unread', label: 'Непрочитанные' },
          { value: 'mentions', label: 'Упоминания' },
        ],
        value: filter,
        onChange: value => setFilter(value as Filter),
      }}
      chipToggle={{
        label: 'Только важные',
        checked: importantOnly,
        onChange: setImportantOnly,
      }}
      settings={{
        button: {},
        actions: [
          { content: { label: 'Только непрочитанные' }, onClick: () => setFilter('unread') },
          { content: { label: 'Показать все' }, onClick: () => setFilter('all') },
          { content: { label: 'Заглушить на 1 час' }, onClick: () => setMuted(true) },
        ],
      }}
      readAllButton={{
        label: 'Прочитать всё',
        onClick: () => setReadIds(new Set(['inc-4821', 'deploy-9217', 'stack-quota', 'mention-1'])),
      }}
      content={
        <>
          {attentionVisible && (
            <NotificationPanel.Group title='Требуют внимания'>
              {visible.inc && (
                <NotificationCard
                  id='inc-4821'
                  label='INC-4821'
                  appearance={APPEARANCE.Error}
                  title='Кластер k8s-prod-1 деградирован'
                  description='3 из 5 нод недоступны последние 6 минут.'
                  date='03:14'
                  link={{ label: 'Открыть инцидент', href: '/incidents/INC-4821' }}
                  unread={isUnread('inc-4821')}
                  onVisible={markRead}
                />
              )}
              {visible.deploy && (
                <NotificationCard
                  id='deploy-9217'
                  label='api-gateway · v2.18.3'
                  appearance={APPEARANCE.Error}
                  title='Деплой не прошёл health-check'
                  description='Readiness probe вернул 503 на 4 из 6 подов.'
                  date='02:50'
                  primaryButton={{ label: 'Повторить', onClick: () => markRead('deploy-9217') }}
                  secondaryButton={{
                    label: 'Логи',
                    onClick: () => window.open('/logs/api-gateway/2.18.3', '_blank'),
                  }}
                  unread={isUnread('deploy-9217')}
                  onVisible={markRead}
                />
              )}
            </NotificationPanel.Group>
          )}

          {visible.quota && (
            <>
              <NotificationPanel.Stack
                title='Лимит дисковой квоты · 3 хоста'
                unread={isUnread('stack-quota')}
                actions={[
                  { content: { label: 'Прочитать все' }, onClick: () => markRead('stack-quota') },
                  { content: { label: 'Заглушить группу' }, onClick: () => setMuted(true) },
                ]}
                onOpenChanged={open => open && markRead('stack-quota')}
              >
                <NotificationCard
                  id='quota-prod-1'
                  label='Storage'
                  appearance={APPEARANCE.Warning}
                  title='prod-1 · /var/data'
                  description='Использовано 92% — осталось 38 ГБ из 480 ГБ.'
                  date='01:12'
                />
                <NotificationCard
                  id='quota-prod-2'
                  label='Storage'
                  appearance={APPEARANCE.Warning}
                  title='prod-2 · /var/log'
                  description='Использовано 87% — осталось 62 ГБ из 480 ГБ.'
                  date='00:48'
                />
                <NotificationCard
                  id='quota-stage-1'
                  label='Storage'
                  appearance={APPEARANCE.Warning}
                  title='stage-1 · /var/data'
                  description='Использовано 84% — осталось 76 ГБ из 480 ГБ.'
                  date='вчера · 23:50'
                />
              </NotificationPanel.Stack>
            </>
          )}

          {visible.mention && (
            <NotificationCard
              id='mention-1'
              label='@you · billing/PR-1402'
              appearance={APPEARANCE.Default}
              title='А. Иванов упомянул вас в PR'
              description='Пересмотрите расчёт grace-period в BillingScheduler.tsx:142'
              date='вчера · 18:20'
              link={{ label: 'Открыть PR', href: '/billing/pulls/1402' }}
              unread={isUnread('mention-1')}
              onVisible={markRead}
            />
          )}

          {visible.backup && (
            <>
              <NotificationCard
                id='backup-1729'
                label='Backup'
                appearance={APPEARANCE.Success}
                title='Резервная копия завершена'
                description='prod-1 (412 ГБ) → s3://backups-prod/2026-05-26/'
                date='вчера · 14:32'
              />
            </>
          )}
        </>
      }
    />
  );

  return (
    <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
      <Button label='Уведомления' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
      {panel}
    </div>
  );
}

Открытие из кнопки в шапке

Открытие из кнопки в шапкеТриггер со счётчиком непрочитанных и useState для open/close.
tsx
import { Button } from '@cloud-ru/ds-button';
import { APPEARANCE, NotificationCard, NotificationPanel } from '@cloud-ru/ds-uikit-product-notification';
import { useMemo, useState } from 'react';

export function PanelScenario() {
  const [open, setOpen] = useState(false);
  const [readIds, setReadIds] = useState<Set<string>>(new Set());

  const unreadCount = useMemo(() => ['inc-4821', 'deploy-9217'].filter(id => !readIds.has(id)).length, [readIds]);

  const markRead = (id: string) => setReadIds(prev => (prev.has(id) ? prev : new Set(prev).add(id)));

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: 'flex-end',
        alignItems: 'center',
        padding: '12px 16px',
        minHeight: 56,
      }}
    >
      <Button
        label={unreadCount > 0 ? `Уведомления · ${unreadCount}` : 'Уведомления'}
        view='outline'
        appearance='neutral'
        onClick={() => setOpen(true)}
      />
      <NotificationPanel
        open={open}
        onClose={() => setOpen(false)}
        title='Уведомления'
        readAllButton={{
          label: 'Прочитать всё',
          onClick: () => setReadIds(new Set(['inc-4821', 'deploy-9217'])),
        }}
        content={
          <>
            <NotificationCard
              id='inc-4821'
              label='INC-4821'
              appearance={APPEARANCE.Error}
              title='Кластер k8s-prod-1 деградирован'
              description='3 из 5 нод недоступны последние 6 минут.'
              date='03:14'
              link={{ label: 'Открыть инцидент', href: '/incidents/INC-4821' }}
              unread={!readIds.has('inc-4821')}
              onVisible={markRead}
            />
            <NotificationCard
              id='deploy-9217'
              label='api-gateway · v2.18.3'
              appearance={APPEARANCE.Error}
              title='Деплой не прошёл health-check'
              description='Readiness probe вернул 503 на 4 из 6 подов.'
              date='02:50'
              primaryButton={{ label: 'Повторить', onClick: () => markRead('deploy-9217') }}
              secondaryButton={{
                label: 'Логи',
                onClick: () => window.open('/logs/api-gateway/2.18.3', '_blank'),
              }}
              unread={!readIds.has('deploy-9217')}
              onVisible={markRead}
            />
            <NotificationCard
              id='backup-1729'
              label='Backup'
              appearance={APPEARANCE.Success}
              title='Резервная копия завершена'
              description='prod-1 (412 ГБ) → s3://backups-prod/2026-05-26/'
              date='вчера · 14:32'
            />
          </>
        }
      />
    </div>
  );
}

Props

NotificationPanel

Types

PropsNotificationPanelProps
PropTypeDefaultRequiredDescription
chipToggle{ label: string; checked: boolean; onChange(checked: boolean): void; }—noПереключатель для фильтрации. Только desktop: в мастере bottomSheet его нет.
classNamestring—noCSS-класс для элемента с контентом CSS-класс
closeOnPopstateboolean—noЗакрывать дровер при перемещении по истории браузера
containerstring | HTMLElement—noКонтейнер в котором будет рендерится Drawer. По-умолчанию - body
contentReactNode—noКонтент для отрисовки (e.g NotificationCard | NotificationPanel.Blank)
data-test-idstring—no
loadingboolean—noСостояние загрузки
onClose() => void—yesКолбэк закрытия
openboolean—yesУправление состоянием показан/не показан.
position"bottom" | "left" | "right" | "top"rightnoРасположение
readAllButton(Omit<ButtonProps, "data-test-id" | "size"> & { tooltip?: TooltipProps; })—noКнопка в "шапке" панели
rootClassNamestring—noCSS-класс для корневого элемента
scrollContainerRefRefObject<HTMLElement>—noСсылка на контейнер, который скроллится
scrollEndRefRefObject<HTMLDivElement>—noСсылка на элемент, обозначающий самый конец прокручиваемого списка
segmentsOmit<SegmentControlProps, "data-test-id" | "size">—noСегменты для фильтрации
settingsNotificationPanelSettingsProps—noКнопка настроек и выпадающий список
showBlackoutbooleantruenoОтображение темной подложки
skeletonsAmountnumber2noКоличество скелетонов карточек для отображения при загрузке
titlestring—yesЗаголовок панели
widthstring | numbersnoШирина (только при position: "left" | "right")

Types

NotificationPanelProps

NotificationPanel.Stack

Types

PropsNotificationCardStackProps
PropTypeDefaultRequiredDescription
actionsAction[]—noСписок действий в выпадающем меню
childrenIterable<ReactNode>—yesКарточки в стопке, видна первая карточка, остальные схлопываются под нее.
data-test-idstring—no
defaultOpenboolean—noСостояние открыт/закрыт по умолчанию
onOpenChanged((open: boolean) => void)—noКолбек смены состояния открыт/закрыт
titlestring—yesЗаголовок стопки карточек
unreadboolean—noСостояние непрочитанных карточек

Types

NotificationCardStackProps

NotificationPanel.Group

Types

PropsNotificationPanelGroupProps
PropTypeDefaultRequiredDescription
childrenReactNode—yesСодержимое группы
classNamestring—noCSS-класс
data-test-idstring—no
titlestring—yesЗаголовок группы

Types

NotificationPanelGroupProps

NotificationPanel.Blank

Types

PropsNotificationPanelBlankProps
PropTypeDefaultRequiredDescription
classNamestring—noДополнительный класс
contentReactNode—noПодзаголовок
data-test-idstring—no
iconIconPredefinedProps—noИконка
titlestring—noЗаголовок

Types

NotificationPanelBlankProps

Storybook

Figma