Droplist
Тот же List в popover. Droplist оборачивает children-триггер, сам управляет открытием/закрытием и показывает список рядом с триггером. Почти все пропсы List (items, selection, collapse, search, virtualized, pinTop / pinBottom) доступны здесь напрямую.
Когда использовать
- Селектор-значение у кнопки/поля (валюта, язык, регион, сортировка).
- Меню действий (у toolbar-кнопки, у строки таблицы).
- Вторичная навигация, которую не хочется держать на странице постоянно.
Когда не нужен Droplist:
- Простое меню из 2–3 действий — проще держать inline.
- Список должен быть виден всегда (sidebar) — используйте List.
- Сложная форма с несколькими полями — используйте Popover + свой layout.
Droplist vs List
| Ситуация | Компонент |
|---|---|
| Список всегда виден на странице | List |
| Список открывается по клику / фокусу | Droplist |
| Список нужно вставить в форму как селект | Droplist + триггер-кнопка или FieldSelect |
| Mobile — полноэкранный выбор | List в собственном sheet / drawer |
Анатомия
Size (default s)
Размер задаёт высоту строки списка (Figma listItem): s = 40px, m = 52px, l = 66px. Совпадает с size у List.
s— дефолт. Компактные селекторы у кнопок toolbar/header.m— списки объектов с описанием.l— крупные меню, mobile.
Selection mode (default off)
Режим выбора идентичен List:
- без
selection— клик = навигация/действие, состояния «выбран» нет. selection={{ mode: 'single' }}— один выбранный элемент (value: ItemId); обычно сcloseDroplistOnItemClick.selection={{ mode: 'multiple' }}— множественный выбор (value: ItemId[]); popover не закрывается на клик.
Placement (default bottom-start)
bottom-start— дефолт. Для списков у кнопки в header/toolbar, анкорится по левому краю триггера.bottom-end— если триггер прижат к правому краю контейнера.top-*— намеренно ставьте только когда триггер физически у нижнего края страницы. У нижнего края viewport fallback отработает автоматически.
Trigger event (default click)
click— дефолт. Предсказуемо, работает на touch, доступнее для клавиатуры.hover— допустимо только для навигационных меню без чувствительных действий. Не используйте для селекторов и действий с последствиями.
Width strategy (default auto)
widthStrategy='auto'— дефолт. Ширина popover’а по контенту.widthStrategy='eq'— popover ровно по ширине триггера. Идеально для селектов в форме.widthStrategy='gte'— popover не уже триггера, но может быть шире по контенту.
Header / Footer
Шапка и подвал popover’а — кастомные слоты вокруг тела списка (Figma dropdownContainer.topBar / bottomBar):
header—ReactNodeнад списком (и над полем поиска, если оно есть). Заголовок раздела, справочный блок.headerDivider— рисует разделитель междуheader(вместе с полем поиска) и телом списка.footer—ReactNodeпод списком. Сводка, ссылка на полный список.footerDivider— рисует разделитель между телом списка иfooter.
Разделители включаются только вместе с соответствующим слотом: headerDivider без header ничего не рисует.
Close after selection
- В
single+ навигация →closeDroplistOnItemClickобычноtrue. - В
multiple→ всегдаfalse(по умолчанию), иначе пользователь не сможет проставить несколько галочек.
Установка
pnpm add @cloud-ru/ds-list
import { Droplist } from '@cloud-ru/ds-list'
import '@cloud-ru/ds-list/style.css'
Примеры использования
Селектор-кнопка
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
export function BasicDroplist() {
const [value, setValue] = useState<string | number | undefined>('rub');
return (
<div className={styles.wrapper}>
<Droplist
trigger='click'
placement='bottom-start'
closeDroplistOnItemClick
selection={{ mode: 'single', value, onChange: setValue }}
items={[
{ id: 'usd', content: { label: 'USD — Доллар США' } },
{ id: 'eur', content: { label: 'EUR — Евро' } },
{ id: 'rub', content: { label: 'RUB — Российский рубль' } },
{ id: 'cny', content: { label: 'CNY — Китайский юань' } },
]}
>
<Button size='s' appearance='neutral' view='outline' label={`Валюта: ${String(value).toUpperCase()}`} />
</Droplist>
</div>
);
}Multiple selection
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
export function DroplistMultiple() {
const [value, setValue] = useState<(string | number)[]>(['email']);
return (
<div className={styles.wrapper}>
<Droplist
trigger='click'
placement='bottom-start'
selection={{ mode: 'multiple', value, onChange: setValue }}
items={[
{ id: 'email', content: { label: 'Email' } },
{ id: 'push', content: { label: 'Push-уведомления' } },
{ id: 'sms', content: { label: 'SMS' } },
{ id: 'telegram', content: { label: 'Telegram' } },
]}
>
<Button size='s' appearance='neutral' view='outline' label={`Каналы: ${value.length}`} />
</Droplist>
</div>
);
}Поиск внутри Droplist
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useMemo, useState } from 'react';
import styles from './styles.module.scss';
const COUNTRIES = [
'Австрия',
'Армения',
'Беларусь',
'Бразилия',
'Германия',
'Грузия',
'Индия',
'Казахстан',
'Китай',
'Россия',
'США',
'Турция',
];
export function DroplistWithSearch() {
const [value, setValue] = useState<string | number | undefined>('Россия');
const [query, setQuery] = useState('');
const items = useMemo(
() =>
COUNTRIES.filter(name => name.toLowerCase().includes(query.toLowerCase())).map(name => ({
id: name,
content: { label: name },
})),
[query],
);
return (
<div className={styles.wrapper}>
<Droplist
trigger='click'
placement='bottom-start'
closeDroplistOnItemClick
selection={{ mode: 'single', value, onChange: setValue }}
search={{ value: query, onChange: setQuery, placeholder: 'Поиск страны' }}
items={items}
>
<Button size='s' appearance='neutral' view='outline' label={`Страна: ${value}`} />
</Droplist>
</div>
);
}Form select (widthStrategy="eq")
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
export function DroplistAsFormSelect() {
const [value, setValue] = useState<string | number | undefined>('m');
const options = [
{ id: 's', content: { label: 'Small (1 vCPU, 2 GB RAM)' } },
{ id: 'm', content: { label: 'Medium (2 vCPU, 4 GB RAM)' } },
{ id: 'l', content: { label: 'Large (4 vCPU, 8 GB RAM)' } },
{ id: 'xl', content: { label: 'X-Large (8 vCPU, 16 GB RAM)' } },
];
const label = options.find(o => o.id === value)?.content.label ?? 'Выбрать';
return (
<div className={styles.formSelect}>
<Droplist
trigger='click'
placement='bottom-start'
closeDroplistOnItemClick
widthStrategy='eq'
selection={{ mode: 'single', value, onChange: setValue }}
items={options}
>
<Button size='s' appearance='neutral' view='outline' label={label} fullWidth />
</Droplist>
</div>
);
}Шапка и подвал с разделителями
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
export function DroplistWithHeader() {
const [value, setValue] = useState<string | number | undefined>('relevance');
return (
<div className={styles.wrapper}>
<Droplist
trigger='click'
placement='bottom-start'
closeDroplistOnItemClick
selection={{ mode: 'single', value, onChange: setValue }}
header='Сортировать по'
headerDivider
footer='4 варианта сортировки'
footerDivider
items={[
{ id: 'relevance', content: { label: 'Релевантности' } },
{ id: 'date', content: { label: 'Дате создания' } },
{ id: 'name', content: { label: 'Имени' } },
{ id: 'size', content: { label: 'Размеру' } },
]}
>
<Button size='s' appearance='neutral' view='outline' label='Сортировка' />
</Droplist>
</div>
);
}Trigger
children — сам триггер. Поддерживаются две формы:
ReactNode— просто вложенный элемент (кнопка / поле). Droplist навешивает на него open/close.({ onKeyDown }) => ReactNode— render-prop сonKeyDown-хендлером, который нужно передать на триггер для клавиатуры. Используйте, если триггер — кастомный компонент без привычной клавиатурной поддержки.
Открытие и контроль
- Uncontrolled — компонент сам управляет
open, ничего передавать не нужно. - Controlled —
open+onOpenChange. Нужен для программного открытия (например, по keyboard shortcut) или синхронизации с URL. closeOnPopstateавтоматически закрывает popover приpopstate-событии — полезно в SPA с router’ом.
Selection и collapse
Всё, что относится к содержимому списка (items, pinTop, pinBottom, selection, collapse, search, footer, virtualized, marker, size, contentRender), работает так же, как в List.
Доступность
- Триггер получает
aria-expandedиaria-haspopup='listbox'автоматически (черезDropdown). - При открытии фокус переходит внутрь списка, при закрытии — возвращается к триггеру.
Escapeзакрывает popover,Tabпереключает на следующий focusable элемент страницы.- Стрелки,
Home/End,Enter/Spaceработают так же, как в List. - Цвет не единственный индикатор выбранного значения: используется marker и фоновая заливка.
Props
Types
DroplistProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
actionButton | ReactNode | — | no | Только mobile (`BottomSheet`): action-кнопка справа в шапке. |
barHideStrategy | "leave" | "move" | "never" | "scroll" | — | no | Управление скрытием скролл баров: <br> - `Never` - показывать всегда <br> - `Leave` - скрывать когда курсор покидает компонент <br> - `Scroll` - показывать только когда происходит скроллинг <br> - `Move` - показывать при движении курсора над компонентом |
children | ReactNode | ({onKeyDown}) => ReactNode * Рендер функция принимает аргументы `onKeyDown` - хендлер ввода, для поддержки управления с клавиатуры | — | yes | Триггер для дроплиста |
className | string | — | no | CSS-класс |
closeDroplistOnItemClick | boolean | false | no | Закрывать выпадающий список после клика на базовый айтем. Работает в режимах selection: 'none' | 'single' |
closeOnPopstate | boolean | — | no | Закрывать ли поповер при переходе по истории браузера |
collapse | CollapseState | — | no | Настройки раскрытия элементов |
container | RefObject<HTMLElement | null> | — | no | Контейнер портала (ref). Переопределяет `PortalContext` для этого дроплиста (по аналогии с `container` у Modal/Drawer). По умолчанию — из `PortalContextProvider`. |
contentRender | ((props: ContentRenderProps) => ReactNode) | — | no | Рендер функция основного контента айтема |
data-test-id | string | — | no | |
dataError | boolean | — | no | Загрузка данных завершилась ошибкой: показывается `errorDataState` |
dataFiltered | boolean | — | no | Текущий пустой список — результат поиска/фильтра: показывается `noResultsState` вместо `noDataState` |
errorDataState | EmptyStateProps | — | no | Экран при ошибке запроса |
footer | ReactNode ; | — | no | Кастомизируемый элемент в конце списка |
footerActiveElementsRefs | RefObject<HTMLElement>[] | — | no | Список ссылок на кастомные элементы, помещенные в специальную секцию внизу списка |
footerDivider | boolean | — | no | Показывать divider между body и footer (Figma `dropdownContainer.dividerWrapper` снизу) |
header | ReactNode ; | — | no | Кастомизируемый элемент в начале списка — Figma `dropdownContainer.topBar`. Подходит для заголовка / справочного блока над поиском. |
headerDivider | boolean | — | no | Показывать divider между header и body (Figma `dropdownContainer.dividerWrapper` сверху) |
items | Item[] | — | yes | Основные элементы списка |
label | string | — | no | Только mobile (`BottomSheet`): заголовок шапки. |
limitedScrollHeight | boolean | — | no | Ограничить максимальную высоту скролл-контейнера в зависимости от `size` |
listRef | RefObject<HTMLElement> | — | no | Ссылка на элемент выпадающего списка |
loading | boolean | — | no | Флаг, отвечающий за состояние загрузки списка |
marker | boolean | — | no | Отображать ли маркер у выбранного элемента списка |
noDataState | EmptyStateProps | — | no | Экран при отсутствии данных |
noResultsState | EmptyStateProps | — | no | Экран при отсутствии результатов поиска или фильтров |
onBackButtonClick | (() => void) | — | no | Только mobile (`BottomSheet`): callback back-кнопки. |
onOpenChange | ((isOpen: boolean) => void) | — | no | Колбек отображения компонента. Срабатывает при изменении состояния open. |
onScroll | ((event?: Event) => void) | — | no | Колбек на скролл прокручиваемого списка |
open | boolean | — | no | Управляет состоянием показан/не показан. |
pinBottom | Item[] | — | no | Элементы списка, закрепленные снизу |
pinTop | Item[] | — | no | Элементы списка, закрепленные сверху |
placement | "bottom" | "bottom-end" | "bottom-start" | "left" | "left-end" | "left-start" | "right" | "right-end" | "right-start" | "top" | "top-end" | "top-start" | top | no | Положение поповера относительно своего триггера (children). |
scroll | boolean | — | no | Включить ли скролл для основной части списка |
scrollContainerClassName | string | — | no | CSS-класс для scroll обертки основного списка айтемов |
scrollContainerRef | Ref<HTMLElement> | — | no | Ссылка на контейнер, который скроллится |
scrollRef | Ref<HTMLElement> | — | no | Ссылка на элемент, обозначающий самый конец прокручиваемого списка |
scrollToSelectedItem | boolean | — | no | Флаг, отвечающий за прокручивание до выбранного элемента |
search | SearchState | — | no | Настройки поисковой строки |
selection | SelectionMultipleState | SelectionSingleState | — | no | Настройки выбора элементов. `mode: 'single'` — один выбранный элемент (`value: ItemId`), `mode: 'multiple'` — множественный выбор (`value: ItemId[]`). Без `selection` выбора нет — клик вызывает только `onClick` элемента. |
size | "l" | "m" | "s" | — | no | Размер списка |
slotAfterTitle | ReactNode | — | no | Только mobile (`BottomSheet`): slot справа от заголовка. |
trigger | "click" | "clickAndFocusVisible" | "focus" | "focusVisible" | "hover" | "hoverAndFocus" | "hoverAndFocusVisible" | — | no | Условие отображения поповера: <br/> - `click` - открывать по клику <br/> - `hover` - открывать по ховеру <br/> - `focusVisible` - открывать по focus-visible <br/> - `focus` - открывать по фокусу <br/> - `hoverAndFocusVisible` - открывать по ховеру и focus-visible <br/> - `hoverAndFocus` - открывать по ховеру и фокусу <br/> - `clickAndFocusVisible` - открывать по клику и focus-visible |
triggerClassName | string | — | no | CSS-класс триггера |
triggerElemRef | RefObject<HTMLElement> | — | no | Ссылка на элемент-триггер для дроплиста |
untouchableScrollbars | boolean | — | no | Отключает возможность взаимодействовать со скролбарами мышью. |
virtualized | boolean | — | no | Включить виртуализацию элементов списка. Рекомендуется при количестве элементов от 1000. |
widthStrategy | "auto" | "eq" | "gte" | auto | no | Стратегия управления шириной контейнера поповера <br/> - `auto` - соответствует ширине контента, <br/> - `gte` - Great Than or Equal, равен ширине таргета или больше ее, если контент в поповере шире, <br/> - `eq` - Equal, строго равен ширине таргета. |
Unions
Types
DroplistProps
BaseItemWithoutNonGroup
CollapseState
CommonGroupItem
EmptyStateProps
Item
ScrollProps
SearchState
SelectionMultipleState
SelectionSingleState
Unions
Size
Related props
Placement
PopoverWidthStrategy
Trigger
Адаптивность
Droplist — адаптивный компонент с переключением поверхности (surface-swap). Раскладку он берёт из AdaptiveProvider (контекст @cloud-ru/ds-adaptive); публичный API единый для обеих платформ:
- desktop (по умолчанию) — анкорный popover рядом с триггером.
- mobile — список рендерится в
BottomSheetиз@cloud-ru/ds-bottom-sheet(панель снизу с шапкой и крупными строками sizel).
Верстайте под desktop и поставьте один <AdaptiveProvider> в корне приложения — mobile-поверхность включается автоматически (desktop-first). Пропа layoutType у компонента нет: источник раскладки — только контекст.
Как форсировать платформу
Форс — только контекстом, не пропом:
- Поддерево — вложенный провайдер:
import { AdaptiveProvider } from '@cloud-ru/ds-adaptive' <AdaptiveProvider layoutType='mobile'> <Droplist items={items}>{trigger}</Droplist> </AdaptiveProvider> - Отдельный компонент —
withLayoutType(module-scope, сахар над провайдером):import { withLayoutType } from '@cloud-ru/ds-adaptive' import { Droplist } from '@cloud-ru/ds-list' const MobileDroplist = withLayoutType(Droplist, 'mobile')
Платформенные пропы
Часть пропов привязана к одной поверхности и на другой молча игнорируется. Таблица синхронизирована с JSDoc-пометками у DroplistProps.
| Пропы | desktop | mobile |
|---|---|---|
trigger, placement, widthStrategy, triggerElemRef, listRef, triggerClassName | используется | игнорируется |
label, actionButton, slotAfterTitle, onBackButtonClick | игнорируется | используется |
items, selection, collapse, search, footer, headerDivider, footerDivider | используется | используется |
open, onOpenChange, closeOnPopstate, size | используется | используется |
Mobile — BottomSheet
import { AdaptiveProvider, LAYOUT_TYPE } from '@cloud-ru/ds-adaptive';
import { Button } from '@cloud-ru/ds-button';
import { Droplist } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
export function MobileDroplist() {
const [value, setValue] = useState<string | number | undefined>('rub');
return (
<AdaptiveProvider layoutType={LAYOUT_TYPE.Mobile}>
<div className={styles.wrapper}>
<Droplist
label='Валюта'
closeDroplistOnItemClick
selection={{ mode: 'single', value, onChange: setValue }}
items={[
{ id: 'usd', content: { label: 'USD — Доллар США' } },
{ id: 'eur', content: { label: 'EUR — Евро' } },
{ id: 'rub', content: { label: 'RUB — Российский рубль' } },
{ id: 'cny', content: { label: 'CNY — Китайский юань' } },
]}
>
<Button size='s' appearance='neutral' view='outline' label={`Валюта: ${String(value).toUpperCase()}`} />
</Droplist>
</div>
</AdaptiveProvider>
);
}Подробнее о модели адаптивности — Адаптивность — паттерн.