Widget
Widget — контейнер продуктовой карточки: TitleClickable в шапке, опциональный SegmentControl, слот управления, массив действий (Button, kebab/droplist) и body с состояниями default / loading / error.
Когда использовать
- Компактные блоки на dashboard и overview-страницах: заголовок-ссылка, переключатель вкладок, действия и контент в одной карточке.
- Нужны единые состояния загрузки и ошибки с
InfoBlockи кнопкой повтора без ручной вёрстки. - Действия должны адаптироваться по ширине: primary в шапке (wide desktop), overflow в kebab, на узком layout — кнопки в footer.
Когда не нужен Widget:
-
Простая карточка без шапки и действий:
- используйте
@cloud-ru/ds-blockили@cloud-ru/ds-card.
- используйте
-
Только кликабельный заголовок без оболочки:
- используйте
TitleClickable.
- используйте
-
Сложная таблица или список с сортировкой и пагинацией:
- используйте отдельный data-компонент, не оборачивайте в виджет.
-
✅ Передавайте
errorState.onClickUpdateи переключайтеstateобратно вdefaultпосле успешного retry. -
❌ Оставлять
state='error'без обработчика повтора — кнопка вInfoBlockне сможет восстановить контент. -
✅ На desktop с несколькими действиями включайте
wide, чтобы primary и kebab жили в шапке. -
❌ Ожидать wide-раскладку на mobile — флаг
wideпринудительно отключается. -
✅ Оборачивайте демо и страницу с kebab/droplist в
PortalContextProvider, если порталы рендерятся вне корня приложения. -
❌ Полагаться на глобальный portal-context из layout docs-сайта — каждый
client:visible-островок изолирован. -
✅ Скрывайте лишние действия через
hidden: true, не удаляя элемент из массива. -
❌ Дублировать один и тот же primary CTA в
actionsи вchildren— достаточно одного места.
Анатомия
State (default default)
Состояние из WIDGET_STATE:
default— рендеритchildrenв body.loading— skeleton в шапке; body —loadingState.loadingContentили skeleton приloadingState.showSkeleton.error—InfoBlockсerrorStateи кнопкойonClickUpdate; видимыеactionsостаются в шапке для retry/навигации.
Wide (default false)
false— legacy layout: overflow-действия в kebab шапки, primary-кнопки на всю ширину под контентом (кромеerror).true— primary и kebab в одной строке шапки рядом сSegmentControl/actionsChildren. На mobile-раскладке игнорируется.
Action variant
Элемент actions[i] — discriminated union по variant (default — filled Button):
filled/outline/tonal/function/simple— пропсы@cloud-ru/ds-button+ опциональныйtooltip.kebab—ButtonKebab+list.items(группы и пункты меню).droplist— кнопка-триггер + выпадающий список.
Меню обоих вариантов рендерит Droplist из @cloud-ru/ds-list: на mobile список открывается в BottomSheet, на desktop — анкорным popover’ом. Раскладка берётся из AdaptiveProvider.
Общие поля: hidden, tooltip. Для списков: closeDroplistOnItemClick, controlled open / onOpenChange.
Slots
header— пропсыTitleClickable:title,href,icon,avatar,onClick, …children— основной контент body.segmentControl— пропсыSegmentControlв шапке (частоwidth: fullна desktop).actionsChildren— произвольный узел слева от кнопок (фильтр, badge, …).loadingState/errorState— настройки соответствующих состояний.
Установка
pnpm add @cloud-ru/ds-uikit-product-widget
import { Widget, BUTTON_TYPE, WIDGET_STATE } from '@cloud-ru/ds-uikit-product-widget'
Примеры использования
Контент и SegmentControl
import { WIDTH } from '@cloud-ru/ds-segment-control';
import { Widget } from '@cloud-ru/ds-uikit-product-widget';
export function DefaultContent() {
return (
<Widget
header={{ title: 'Cloud servers', href: '#' }}
segmentControl={{
width: WIDTH.Full,
defaultValue: 'overview',
items: [
{ value: 'overview', label: 'Overview' },
{ value: 'events', label: 'Events' },
],
}}
>
Keep product metrics, shortcuts, and status details in one compact card.
</Widget>
);
}Wide desktop и действия
import { WIDTH } from '@cloud-ru/ds-segment-control';
import { BUTTON_TYPE, Widget } from '@cloud-ru/ds-uikit-product-widget';
import { useState } from 'react';
export function WithActions() {
const [lastAction, setLastAction] = useState<string | null>(null);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Widget
wide
header={{ title: 'Managed databases', href: '#' }}
segmentControl={{
width: WIDTH.Auto,
defaultValue: 'overview',
items: [
{ value: 'overview', label: 'Overview' },
{ value: 'events', label: 'Events' },
],
}}
actions={[
{ label: 'Create', onClick: () => setLastAction('Create') },
{
variant: BUTTON_TYPE.Outline,
label: 'Settings',
onClick: () => setLastAction('Settings'),
},
{
variant: BUTTON_TYPE.Kebab,
list: {
items: [
{ content: { label: 'Export' }, onClick: () => setLastAction('Export') },
{ content: { label: 'Archive' }, onClick: () => setLastAction('Archive') },
],
},
},
]}
>
Actions are shown in the header for wide desktop widgets.
</Widget>
{lastAction ? <span>Last action: {lastAction}</span> : null}
</div>
);
}Loading
import { Widget } from '@cloud-ru/ds-uikit-product-widget';
export function LoadingState() {
return (
<Widget
header={{ title: 'Billing', href: '#' }}
state='loading'
loadingState={{ showSkeleton: true }}
actions={[{ label: 'Refresh' }]}
>
Billing summary
</Widget>
);
}Error и повтор
import { Widget } from '@cloud-ru/ds-uikit-product-widget';
import { useState } from 'react';
export function ErrorState() {
const [state, setState] = useState<'default' | 'error'>('error');
return (
<Widget
header={{ title: 'Monitoring', href: '#' }}
state={state}
errorState={{
errorTitle: 'Metrics are unavailable',
errorDescription: 'Try reloading the widget.',
updateButtonLabel: 'Reload',
onClickUpdate: () => setState('default'),
}}
>
{state === 'error' ? 'Metrics' : 'Metrics loaded successfully.'}
</Widget>
);
}Props
Types
WidgetProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
actions | Action[] | — | no | Действия в шапке/footer. |
actionsChildren | ReactNode | — | no | Дополнительный слот рядом с действиями. |
children | ReactNode | — | yes | Контент виджета. |
className | string | — | no | Дополнительный CSS-класс. |
data-test-id | string | — | no | |
errorState | WidgetErrorStateProps | — | no | Настройки error-состояния. |
header | WidgetHeaderProps | — | yes | Пропсы кликабельного заголовка. |
loadingState | WidgetLoadingStateProps | — | no | Настройки loading-состояния. |
segmentControl | SegmentControlProps | — | no | Пропсы SegmentControl в шапке. |
state | "default" | "error" | "loading" | — | no | Состояние виджета. |
wide | boolean | — | no | Только desktop: wide-раскладка виджета. На mobile принудительно выключается (`wide && !isMobile`). |
Unions
Types
WidgetProps
Action
BaseAction
ButtonDroplistProps
ButtonKebabProps
WidgetErrorStateProps
WidgetHeaderProps
WidgetLoadingStateProps
Unions
WidgetState
Related props
SegmentControlProps
Адаптивность
Widget — адаптивный компонент: DOM остаётся единым, но при mobile-раскладке карточка перестраивается. Раскладку он берёт из AdaptiveProvider (контекст @cloud-ru/ds-adaptive); публичный API единый для обеих платформ:
- desktop (по умолчанию) — учитывает
wideи динамическое схлопывание действий в kebab по ширине контейнера. - mobile — узкий режим:
wideпринудительно выключен, primary-кнопки уезжают в footer.
Верстайте под desktop и поставьте один <AdaptiveProvider> в корне приложения — mobile-перестроение включается автоматически (desktop-first). Пропа layoutType у компонента нет: источник раскладки — только контекст.
Mobile layout
import { AdaptiveProvider, LAYOUT_TYPE } from '@cloud-ru/ds-adaptive';
import { BUTTON_TYPE, Widget } from '@cloud-ru/ds-uikit-product-widget';
import { useState } from 'react';
export function MobileLayout() {
const [lastAction, setLastAction] = useState<string | null>(null);
return (
<AdaptiveProvider layoutType={LAYOUT_TYPE.Mobile}>
<div style={{ maxWidth: 360, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Widget
wide
header={{ title: 'Object storage', href: '#' }}
actions={[
{ label: 'Upload', onClick: () => setLastAction('Upload') },
{
variant: BUTTON_TYPE.Kebab,
list: {
items: [
{
content: { label: 'Delete bucket' },
onClick: () => setLastAction('Delete bucket'),
},
],
},
},
]}
>
On mobile, wide is ignored: primary actions move to the footer, overflow goes to kebab.
</Widget>
{lastAction ? <span>Last action: {lastAction}</span> : null}
</div>
</AdaptiveProvider>
);
}Как форсировать платформу
Форс — только контекстом, не пропом:
- Поддерево — вложенный провайдер:
import { AdaptiveProvider } from '@cloud-ru/ds-adaptive' <AdaptiveProvider layoutType='mobile'> <Widget header={header} actions={actions}>{content}</Widget> </AdaptiveProvider> - Отдельный компонент —
withLayoutType(module-scope, сахар над провайдером):import { withLayoutType } from '@cloud-ru/ds-adaptive' import { Widget } from '@cloud-ru/ds-uikit-product-widget' const MobileWidget = withLayoutType(Widget, 'mobile')
Платформенные пропы
Таблица синхронизирована с JSDoc-пометками у WidgetProps.
| Проп | desktop | mobile |
|---|---|---|
wide | используется | игнорируется (принудительно выключен) |
Подробнее о модели адаптивности — Адаптивность — паттерн.
Storybook
Figma
Смотри также
TitleClickable— заголовок-ссылка в шапке виджета.SegmentControl— переключатель вкладок в шапке.InfoBlock— блок ошибки внутриstate='error'.