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 не уже триггера, но может быть шире по контенту.

Шапка и подвал popover’а — кастомные слоты вокруг тела списка (Figma dropdownContainer.topBar / bottomBar):

  • headerReactNode над списком (и над полем поиска, если оно есть). Заголовок раздела, справочный блок.
  • headerDivider — рисует разделитель между header (вместе с полем поиска) и телом списка.
  • footerReactNode под списком. Сводка, ссылка на полный список.
  • 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'

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

Селектор-кнопка

Селектор-кнопкаКнопка-триггер + Droplist с single selection и закрытием после выбора.
tsx
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

Multiple selectionНесколько отметок без закрытия. closeDroplistOnItemClick по умолчанию false.
tsx
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

Поиск внутри Droplistsearch + фильтрация items на стороне потребителя — поведение идентично List.
tsx
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")

Form select (widthStrategy="eq")Popover ровно по ширине триггера — поведение нативного select.
tsx
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>
  );
}

Шапка и подвал с разделителями

Шапка и подвал с разделителямиheader / footer — слоты над и под списком; headerDivider / footerDivider рисуют разделители.
tsx
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, ничего передавать не нужно.
  • Controlledopen + 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

PropsDroplistProps
PropTypeDefaultRequiredDescription
actionButtonReactNodenoТолько mobile (`BottomSheet`): action-кнопка справа в шапке.
barHideStrategy"leave" | "move" | "never" | "scroll"noУправление скрытием скролл баров: <br> - `Never` - показывать всегда <br> - `Leave` - скрывать когда курсор покидает компонент <br> - `Scroll` - показывать только когда происходит скроллинг <br> - `Move` - показывать при движении курсора над компонентом
childrenReactNode | ({onKeyDown}) => ReactNode * Рендер функция принимает аргументы `onKeyDown` - хендлер ввода, для поддержки управления с клавиатурыyesТриггер для дроплиста
classNamestringnoCSS-класс
closeDroplistOnItemClickbooleanfalsenoЗакрывать выпадающий список после клика на базовый айтем. Работает в режимах selection: 'none' | 'single'
closeOnPopstatebooleannoЗакрывать ли поповер при переходе по истории браузера
collapseCollapseStatenoНастройки раскрытия элементов
containerRefObject<HTMLElement | null>noКонтейнер портала (ref). Переопределяет `PortalContext` для этого дроплиста (по аналогии с `container` у Modal/Drawer). По умолчанию — из `PortalContextProvider`.
contentRender((props: ContentRenderProps) => ReactNode)noРендер функция основного контента айтема
data-test-idstringno
dataErrorbooleannoЗагрузка данных завершилась ошибкой: показывается `errorDataState`
dataFilteredbooleannoТекущий пустой список — результат поиска/фильтра: показывается `noResultsState` вместо `noDataState`
errorDataStateEmptyStatePropsnoЭкран при ошибке запроса
footerReactNode ;noКастомизируемый элемент в конце списка
footerActiveElementsRefsRefObject<HTMLElement>[]noСписок ссылок на кастомные элементы, помещенные в специальную секцию внизу списка
footerDividerbooleannoПоказывать divider между body и footer (Figma `dropdownContainer.dividerWrapper` снизу)
headerReactNode ;noКастомизируемый элемент в начале списка — Figma `dropdownContainer.topBar`. Подходит для заголовка / справочного блока над поиском.
headerDividerbooleannoПоказывать divider между header и body (Figma `dropdownContainer.dividerWrapper` сверху)
itemsItem[]yesОсновные элементы списка
labelstringnoТолько mobile (`BottomSheet`): заголовок шапки.
limitedScrollHeightbooleannoОграничить максимальную высоту скролл-контейнера в зависимости от `size`
listRefRefObject<HTMLElement>noСсылка на элемент выпадающего списка
loadingbooleannoФлаг, отвечающий за состояние загрузки списка
markerbooleannoОтображать ли маркер у выбранного элемента списка
noDataStateEmptyStatePropsnoЭкран при отсутствии данных
noResultsStateEmptyStatePropsnoЭкран при отсутствии результатов поиска или фильтров
onBackButtonClick(() => void)noТолько mobile (`BottomSheet`): callback back-кнопки.
onOpenChange((isOpen: boolean) => void)noКолбек отображения компонента. Срабатывает при изменении состояния open.
onScroll((event?: Event) => void)noКолбек на скролл прокручиваемого списка
openbooleannoУправляет состоянием показан/не показан.
pinBottomItem[]noЭлементы списка, закрепленные снизу
pinTopItem[]noЭлементы списка, закрепленные сверху
placement"bottom" | "bottom-end" | "bottom-start" | "left" | "left-end" | "left-start" | "right" | "right-end" | "right-start" | "top" | "top-end" | "top-start"topnoПоложение поповера относительно своего триггера (children).
scrollbooleannoВключить ли скролл для основной части списка
scrollContainerClassNamestringnoCSS-класс для scroll обертки основного списка айтемов
scrollContainerRefRef<HTMLElement>noСсылка на контейнер, который скроллится
scrollRefRef<HTMLElement>noСсылка на элемент, обозначающий самый конец прокручиваемого списка
scrollToSelectedItembooleannoФлаг, отвечающий за прокручивание до выбранного элемента
searchSearchStatenoНастройки поисковой строки
selectionSelectionMultipleState | SelectionSingleStatenoНастройки выбора элементов. `mode: 'single'` — один выбранный элемент (`value: ItemId`), `mode: 'multiple'` — множественный выбор (`value: ItemId[]`). Без `selection` выбора нет — клик вызывает только `onClick` элемента.
size"l" | "m" | "s"noРазмер списка
slotAfterTitleReactNodenoТолько 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
triggerClassNamestringnoCSS-класс триггера
triggerElemRefRefObject<HTMLElement>noСсылка на элемент-триггер для дроплиста
untouchableScrollbarsbooleannoОтключает возможность взаимодействовать со скролбарами мышью.
virtualizedbooleannoВключить виртуализацию элементов списка. Рекомендуется при количестве элементов от 1000.
widthStrategy"auto" | "eq" | "gte"autonoСтратегия управления шириной контейнера поповера <br/> - `auto` - соответствует ширине контента, <br/> - `gte` - Great Than or Equal, равен ширине таргета или больше ее, если контент в поповере шире, <br/> - `eq` - Equal, строго равен ширине таргета.

Unions

Types

DroplistProps

Unions

Адаптивность

Droplist — адаптивный компонент с переключением поверхности (surface-swap). Раскладку он берёт из AdaptiveProvider (контекст @cloud-ru/ds-adaptive); публичный API единый для обеих платформ:

  • desktop (по умолчанию) — анкорный popover рядом с триггером.
  • mobile — список рендерится в BottomSheet из @cloud-ru/ds-bottom-sheet (панель снизу с шапкой и крупными строками size l).

Верстайте под 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.

Пропыdesktopmobile
trigger, placement, widthStrategy, triggerElemRef, listRef, triggerClassNameиспользуетсяигнорируется
label, actionButton, slotAfterTitle, onBackButtonClickигнорируетсяиспользуется
items, selection, collapse, search, footer, headerDivider, footerDividerиспользуетсяиспользуется
open, onOpenChange, closeOnPopstate, sizeиспользуетсяиспользуется

Mobile — BottomSheet

Mobile — BottomSheetРаскладка форсирована в mobile: по клику триггера список открывается в BottomSheet.
tsx
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>
  );
}

Подробнее о модели адаптивности — Адаптивность — паттерн.

Storybook

Figma