Dropdown
@cloud-ru/ds-dropdown — Выпадающий блок с произвольным контентом и встроенными состояниями loading / not-found / no-data / data-error.
Выпадающий блок над триггером: произвольный контент (меню, фильтры, справочная информация) + встроенные состояния loading / not-found / no-data / data-error через state. Поверх PopoverPrivate — те же пропсы позиционирования, плюс готовая визуальная обработка асинхронных случаев.
Когда использовать
- Меню действий над кнопкой (экспорт, фильтры, настройки).
- Асинхронные подсказки и suggestions — встроенный
stateскрывает ручное ветвление UI. - Композитные виджеты: селекторы, комбобоксы, авторасшифровки.
Когда не нужен Dropdown: для одиночного подсказывающего текста используйте Tooltip, для модального выбора — Modal или Popover.
Анатомия
State
Встроенные состояния контента, позволяющие не ветвить UI вручную: loading — идёт запрос (скелетон/спиннер), no-data — у источника пусто (первая загрузка), not-found — пользователь ввёл фильтр и ничего не нашлось, data-error — запрос упал (с опцией повтора).
Высота и прокрутка
Компонент высоту не ограничивает и своей прокрутки не даёт: содержимое dropdown произвольное, поэтому предел высоты проектируется вместе с ним. Для типового случая — списка опций — есть готовый Droplist из @cloud-ru/ds-list: он сам ограничивает высоту (256 / 320 / 384px по размеру) и включает прокрутку через limitedScrollHeight.
Width strategy (default gte)
Ширина dropdown относительно триггера:
auto— по содержимому, триггер не учитывается.eq— ровно по триггеру:widthфиксируется его шириной, содержимое сжимается или обрезается.gte— не уже триггера: ширина по содержимому, ноmin-widthравна ширине триггера.
Разница между gte и eq видна, когда содержимое шире триггера: eq сожмёт список до ширины триггера, gte даст ему растянуться. Когда содержимое уже триггера, оба дают одинаковый результат — при триггере 96 и содержимом 40 auto даёт 40, eq и gte — по 96.
Значения gte в макетах нет: Figma рисует только «по содержимому» и «по триггеру». Это осознанное расширение API — промежуточный случай встречается в продукте чаще остальных, поэтому он и стоит дефолтом.
Установка
pnpm add @cloud-ru/ds-dropdown
import { Dropdown, STATE } from '@cloud-ru/ds-dropdown'
Примеры использования
Базовый Dropdown
import { Button } from '@cloud-ru/ds-button';
import { Dropdown } from '@cloud-ru/ds-dropdown';
export function Basic() {
return (
<Dropdown content={<div style={{ padding: 12 }}>Контент меню</div>}>
<Button label='Открыть' />
</Dropdown>
);
}
Открытое меню для визуальной сверки
Управляемый режим — open
import { Button } from '@cloud-ru/ds-button';
import { Dropdown } from '@cloud-ru/ds-dropdown';
export function OpenForReview() {
return (
<Dropdown content={<div style={{ padding: 12 }}>Видимое содержимое</div>}>
<Button label='Триггер' />
</Dropdown>
);
}
Состояние loading
import { Button } from '@cloud-ru/ds-button';
import { Dropdown, STATE } from '@cloud-ru/ds-dropdown';
export function Loading() {
return (
<Dropdown state={{ type: STATE.Loading }} content={null}>
<Button label='Загрузка' />
</Dropdown>
);
}
Состояние not-found с действием
import { Button } from '@cloud-ru/ds-button';
import { Dropdown, STATE } from '@cloud-ru/ds-dropdown';
export function NotFound() {
return (
<Dropdown
state={{
type: STATE.NotFound,
content: 'Ничего не нашли',
actionLabel: 'Сбросить фильтры',
onActionClick: () => {},
}}
content={null}
>
<Button label='Поиск' />
</Dropdown>
);
}
Props
DropdownProps
| Prop | Type | Default | Description |
|---|---|---|---|
bodyPadding | boolean | true | Паддинги body. false — убрать (контент во всю ширину; на mobile прокидывается в BottomSheet). |
children | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | |
className | string | — | CSS-класс |
closeOnEscapeKey | boolean | true | Закрывать ли по нажатию на кнопку Esc |
closeOnPopstate | boolean | — | Закрывать ли поповер при переходе по истории браузера |
container | RefObject<HTMLElement | null> | — | Контейнер портала (ref). Переопределяет PortalContext для этого инстанса — по аналогии с container у Modal/Drawer. По умолчанию берётся из PortalContextProvider. |
content | ReactNode | — | Содержимое внутри поповера (body) |
data-test-id | string | — | |
defaultSnapIndex | number | — | Только mobile: индекс snap'а по умолчанию (см. BottomSheet). |
disableSpanWrapper | boolean | — | Отключает для isValidElement внешнюю обертку триггера Пригодится для элементов с position: absolute Работает для триггеров, которые умеют отдать свою DOM-ноду: нативные элементы, forwardRef-компоненты и компоненты, помеченные withInnerRefSupport из @cloud-ru/ds-utils. Остальные всё равно получают <span> — без ноды поповеру не от чего считать позицию; в dev-режиме об этом печатается предупреждение. |
fallbackPlacements | Placement | — | Цепочка расположений которая будет применяться к поповеру от первого к последнему если при текущем он не влезает. |
footer | ReactNode | — | Слот футера (bottomBar) |
footerDivider | boolean | — | Divider между body и футером |
headerDivider | boolean | — | Divider между шапкой и body |
hoverDelayClose | number | — | Задержка закрытия по ховеру |
hoverDelayOpen | number | — | Задержка открытия по ховеру |
offset | number | 0 | Отступ поповера от его триггер-элемента (в пикселях). |
onOpenChange | ((isOpen: boolean) => void) | — | Колбек отображения компонента. Срабатывает при изменении состояния open. |
open | boolean | — | Управляет состоянием показан/не показан. |
outsideClick | OutsideClickHandler | — | Закрывать ли при клике вне поповера |
placement | "bottom" | "bottom-end" | "bottom-start" | "left" | "left-end" | "left-start" | "right" | "right-end" | "right-start" | "top" | "top-end" | "top-start" | top | Положение поповера относительно своего триггера (children). |
search | ReactNode | — | Слот поиска в шапке (topBar) |
slotAfterTitle | ReactNode | — | Подсказка-иконка рядом с заголовком (потребитель собирает, напр. <QuestionTooltip />) |
snapPoints | SnapPoint | ['fit-content'] | Только mobile: snap-точки BottomSheet'а (высота листа). На desktop игнорируется. |
state | ActionButtonProps | BlockProps | BlockPropsWithIcon | DropdownState | — | Состояние |
stopPropagation | StopPropagationHandlers | { onClick: true, onMouseDown: true, onMouseUp: true, onTouchStart: true, onTouchEnd: true, onTouchMove: true } | Гасить всплытие pointer/touch-событий с floating-контейнера (stopPropagation). По умолчанию все хендлеры включены. Для drag&drop внутри поповера отключите onMouseUp / onTouchEnd, чтобы они дошли до document. |
title | ReactNode | — | Заголовок в шапке (topBar) |
trigger | "click" | "clickAndFocusVisible" | "focus" | "focusVisible" | "hover" | "hoverAndFocus" | "hoverAndFocusVisible" | — | Условие отображения поповера: - click - открывать по клику - hover - открывать по ховеру - focusVisible - открывать по focus-visible - focus - открывать по фокусу - hoverAndFocusVisible - открывать по ховеру и focus-visible - hoverAndFocus - открывать по ховеру и фокусу - clickAndFocusVisible - открывать по клику и focus-visible |
triggerClassName | string | — | CSS-класс триггера |
triggerClickByKeys | boolean | true | Вызывается ли попоповер по нажатию клавиш Enter/Space (при trigger = click) |
triggerRef | ForwardedRef<HTMLElement | ReferenceType | null> | — | Ref ссылка на триггер |
widthStrategy | "auto" | "eq" | "gte" | auto | Стратегия управления шириной контейнера поповера - auto - соответствует ширине контента, - gte - Great Than or Equal, равен ширине таргета или больше ее, если контент в поповере шире, - eq - Equal, строго равен ширине таргета. |
Related types
ActionButtonProps
| Prop | Type | Default | Description |
|---|---|---|---|
actionLabel | string | undefined | — | Лейбл кнопки-действия |
onActionClick | (() => void) | undefined | — | Действие при клике по кнопке |
BlockProps
| Prop | Type | Default | Description |
|---|---|---|---|
content | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | Подзаголовок |
BlockPropsWithIcon
| Prop | Type | Default | Description |
|---|---|---|---|
content | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | Подзаголовок |
icon | IconPredefinedProps | — | Иконка |
DropdownState={ type: typeof STATE.Loading; } | ({ type: typeof STATE.NotFound; } & ActionButtonProps & BlockProps) | ({ type: typeof STATE.NoData; } & ActionButtonProps & BlockPropsWithIcon) | ({ type: typeof STATE.DataError; } & ActionButtonProps & BlockPropsWithIcon)
Адаптивность
Dropdown — адаптивный компонент с переключением поверхности (surface-swap). Раскладку он берёт из AdaptiveProvider (контекст @cloud-ru/ds-adaptive); публичный API единый для обеих платформ:
- desktop (по умолчанию) — popover над триггером с позиционированием через floating-ui.
- mobile — контент рендерится в
BottomSheetиз@cloud-ru/ds-bottom-sheet(панель снизу со свайпом для закрытия).
Верстайте под desktop и поставьте один <AdaptiveProvider> в корне приложения — mobile-поверхность включается автоматически (desktop-first). Пропа layoutType у компонента нет: источник раскладки — только контекст.
Как форсировать платформу
Форс — только контекстом, не пропом:
- Поддерево — вложенный провайдер:
import { AdaptiveProvider } from '@cloud-ru/ds-adaptive' <AdaptiveProvider layoutType='mobile'> <Dropdown content={…}>…</Dropdown> </AdaptiveProvider> - Отдельный компонент —
withLayoutType(module-scope, сахар над провайдером):import { withLayoutType } from '@cloud-ru/ds-adaptive' import { Dropdown } from '@cloud-ru/ds-dropdown' const MobileDropdown = withLayoutType(Dropdown, 'mobile')
Платформенные пропы
Часть пропов управляет позиционированием desktop-popover'а и на mobile молча игнорируется (у BottomSheet своё позиционирование снизу). Таблица синхронизирована с type-level JSDoc у DropdownProps.
| Пропы | desktop | mobile |
|---|---|---|
placement, widthStrategy, offset, fallbackPlacements | используется | игнорируется |
hoverDelayOpen, hoverDelayClose, closeOnEscapeKey, triggerClickByKeys, outsideClick | используется | игнорируется |
disableSpanWrapper, triggerClassName, triggerRef, container | используется | игнорируется |
content, title, slotAfterTitle, search, footer, headerDivider, footerDivider | используется | используется |
state, open, onOpenChange, closeOnPopstate, className | используется | используется |
На mobile портал BottomSheet берётся из @cloud-ru/ds-portal-context, поэтому container не действует.
Подробнее о модели адаптивности — Adaptive.