ReorderableList
ReorderableList — список с drag&drop-переупорядочиванием строк через @dnd-kit. У каждой строки появляется ручка слева — потяните её, чтобы поменять порядок. В поповере то же самое даёт ReorderableDroplist.
Когда использовать
- Пользователь сам управляет порядком строк: закладки, избранное, дашборд-виджеты, пункты меню в настройках.
Когда не нужен:
- Порядок строк не редактируется пользователем — обычный List дешевле.
- Нужна виртуализация (
virtualized) — несовместима с переупорядочиванием, см. ниже.
Анатомия
Отдельный компонент, а не режим List
ReorderableList — самостоятельный компонент, а не флаг на List, потому что у него другая модель айтемов: items типизируется как ReorderItem[] вместо обычного Item[], то есть collapse/group-select/next-list недоступны на уровне типов. Верхний уровень ReorderItem — это либо строка SimpleItem, либо группа с заголовком SimpleGroupItem. Остальной функционал List (поиск, pinTop/pinBottom, header/footer, loading/empty-состояния, selection, scroll) продолжает работать без ограничений.
ReorderableDroplist относится к ReorderableList так же, как Droplist к List: та же popover- и mobile-обвязка, та же модель айтемов.
Переупорядочивание — только внутри одного уровня
Drag&drop работает только среди «братьев» одного уровня:
- строки без группы переставляются между собой;
- строки внутри группы переставляются внутри своей группы.
Между группами строки не переносятся, и строку нельзя вынести из группы или вложить в неё перетаскиванием. Это ограничение, а не баг: оно упрощает контракт onItemsReorder и защищает от случайной потери структуры.
Рамку зоны приёма (DropTarget из @cloud-ru/ds-drag-and-drop) список не рисует: рамка — признак переноса между зонами, а здесь перестановка всегда идёт внутри своей. Доступный уровень видно по самому переносу: расступаются только «братья» перетаскиваемого элемента, остальные строки стоят на месте.
Один уровень — либо группы, либо строки
На одном уровне не должно быть одновременно групп и обычных строк: [{ type: 'group', … }, { id: 'catalog', … }] — некорректная структура items.
Причина в том, как выглядит перенос: соседи расступаются, и при смешанном уровне «брат» строки визуально ничем не отличается от «брата» группы — пользователь не понимает, что именно он двигает и куда сможет отпустить. Группа должна читаться как отдельный блок: свой заголовок и разделитель отделяют её от соседей.
- Верхний уровень состоит из групп — обычные строки убираются внутрь групп (заведите группу и для тех, что были без неё).
- Верхний уровень состоит из строк — групп на нём нет вовсе.
Типов это ограничение не касается (ReorderItem[] по-прежнему допускает оба вида) — это правило паттерна, соблюдать его должен потребитель.
SimpleItem — строка, SimpleGroupItem — группа
SimpleItem — это форма BaseItem (тот же content/beforeContent/afterContent/disabled) с обязательным id, без вложенности. Ручка драга рендерится автоматически перед остальным содержимым строки, потребитель её не настраивает.
SimpleGroupItem — группа: type: 'group', заголовок label (плюс остальные поля группы — beforeContent, divider, groupVariant) и items — сортируемые строки этой группы. Заголовок группы неинтерактивен и в переупорядочивании не участвует.
Перетаскиваемая строка — в DragOverlay
Копия строки во время драга рендерится в DragOverlay (портал над страницей) и едет за курсором в своей натуральной высоте, поэтому её не обрезает overflow контейнера List и не растягивает под строки другой высоты. Поверхность копии — DragPreview из @cloud-ru/ds-drag-and-drop: материал и тень, чтобы строка читалась над фоном страницы.
Точку вставки показывает пустой слот
Перенос динамический (см. режимы переноса): соседние строки расступаются сразу под курсором, а слот перетаскиваемой строки едет вместе с ними и стоит пустым на будущей позиции. Пунктирную линию (DropIndicator) список не рисует — точку вставки уже показывает пустота, и вместе они дублировали бы друг друга.
Виртуализации нет
Виртуализация (@tanstack/react-virtual) и @dnd-kit конкурируют за CSS transform строки во время скролла/драга, поэтому у ReorderableList пропа virtualized нет вовсе — это одна из причин, по которой он отдельный компонент, а не режим List. Если виртуализация всё же включена в обход публичных типов (прямым обращением к ListPrivate), она принудительно отключается в рантайме.
Установка
pnpm add @cloud-ru/ds-list
import { ReorderableList, SimpleItem } from '@cloud-ru/ds-list'
import '@cloud-ru/ds-list/style.css'
Примеры использования
Переупорядочивание строк
- Входящие12
- Отправленные
- Архив238
- КорзинаУдаляется через 30 дней
import { ReorderableList, SimpleItem } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
const INITIAL_ITEMS: SimpleItem[] = [
{ id: 'inbox', content: { label: 'Входящие', caption: '12' } },
{ id: 'sent', content: { label: 'Отправленные' } },
{ id: 'archive', content: { label: 'Архив', caption: '238' } },
{ id: 'trash', content: { label: 'Корзина', description: 'Удаляется через 30 дней' } },
];
export function ListReorder() {
const [items, setItems] = useState(INITIAL_ITEMS);
return (
<div className={styles.box}>
<ReorderableList size='s' items={items} onItemsReorder={setItems} />
</div>
);
}Группы с заголовками
- Каталог
- Заказы
- Избранное
- Настройки
- Корзина
import { ReorderableList, ReorderItem } from '@cloud-ru/ds-list';
import { useState } from 'react';
import styles from './styles.module.scss';
const INITIAL_ITEMS: ReorderItem[] = [
{
id: 'group-1',
type: 'group',
label: 'Избранное',
divider: true,
items: [
{ id: 'catalog', content: { label: 'Каталог' } },
{ id: 'orders', content: { label: 'Заказы' } },
{ id: 'favorites', content: { label: 'Избранное' } },
],
},
{
id: 'group-2',
type: 'group',
label: 'Система',
divider: true,
items: [
{ id: 'settings', content: { label: 'Настройки' } },
{ id: 'trash', content: { label: 'Корзина' } },
],
},
];
export function ListReorderGroups() {
const [items, setItems] = useState<ReorderItem[]>(INITIAL_ITEMS);
return (
<div className={styles.box}>
<ReorderableList size='s' items={items} onItemsReorder={setItems} />
</div>
);
}Controlled vs uncontrolled
ReorderableList не хранит порядок строк сам — он всегда управляемый. По окончании драга компонент вызывает onItemsReorder(items) с целиком обновлённым списком ReorderItem[] (строки и группы в новом порядке), потребитель обновляет свой state этим значением. Поэтому onItemsReorder — обязательный проп: без него компонент не имел бы смысла.
Доступность
- Ручка драга — фокусируемая кнопка с
aria-label(«Перетащить для изменения порядка» / «Drag to reorder»), поддерживает активацию и перемещение с клавиатуры (Space— взять, стрелки — переместить,Space— отпустить,Escape— отменить). - Контейнер и строки наследуют роли
menu/menuitemотList/BaseItem— те же ARIA-паттерны, что у обычного списка. disabledна строке отключает выбор иSwitch, но не ручку перетаскивания — задизейбленную строку всё равно можно переупорядочить.
Props
ReorderableList
Types
ReorderableListProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
barHideStrategy | "leave" | "move" | "never" | "scroll" | — | no | Управление скрытием скролл баров: <br> - `Never` - показывать всегда <br> - `Leave` - скрывать когда курсор покидает компонент <br> - `Scroll` - показывать только когда происходит скроллинг <br> - `Move` - показывать при движении курсора над компонентом |
className | string | — | no | CSS-класс |
collapse | CollapseState | {} | no | Настройки раскрытия элементов |
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` снизу) |
hasListInFocusChain | boolean | true | no | Флаг, отвечающий за включение самого родительского контейнера листа в цепочку фокусирующихся элементов |
header | ReactNode ; | — | no | Кастомизируемый элемент в начале списка — Figma `dropdownContainer.topBar`. Подходит для заголовка / справочного блока над поиском. |
headerDivider | boolean | — | no | Показывать divider между header и body (Figma `dropdownContainer.dividerWrapper` сверху) |
items | ReorderItem[] | [] | no | Основные элементы списка: строки `SimpleItem` и/или группы с заголовком `SimpleGroupItem` (`type: 'group'` + `label` + сортируемые `items`). |
keyboardNavigationRef | RefObject<{ focusItem(id: ItemId): void; }> | — | no | Ссылка на управление навигацией листа с клавиатуры |
limitedScrollHeight | boolean | — | no | Ограничить максимальную высоту скролл-контейнера в зависимости от `size` |
loading | boolean | — | no | Флаг, отвечающий за состояние загрузки списка |
marker | boolean | true | no | Отображать ли маркер у выбранного элемента списка |
noDataState | EmptyStateProps | — | no | Экран при отсутствии данных |
noResultsState | EmptyStateProps | — | no | Экран при отсутствии результатов поиска или фильтров |
onItemsReorder | (items: ReorderItem[]) => void | — | yes | Колбек по завершению drag&drop-переупорядочивания элементов списка. Список остаётся управляемым: сам не хранит порядок, а отдаёт наружу целиком обновлённое дерево `items` — потребитель обновляет свой стейт этим значением. Переупорядочивание работает только среди «братьев» одного уровня (строки без группы либо строки внутри одной группы; перенос между группами не поддерживается). |
onKeyDown | ((e: KeyboardEvent<HTMLElement>) => void) | — | no | Обработчик события по нажатию клавиш |
onScroll | ((event?: Event) => void) | — | no | Колбек на скролл прокручиваемого списка |
pinBottom | Item[] | [] | no | Элементы списка, закрепленные снизу |
pinTop | Item[] | [] | no | Элементы списка, закрепленные сверху |
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" | m | no | Размер списка |
tabIndex | number | 0 | no | `tabIndex` корневого элемента списка (для управления порядком фокуса) |
untouchableScrollbars | boolean | — | no | Отключает возможность взаимодействовать со скролбарами мышью. |
ReorderableDroplist
Types
ReorderableDroplistProps| 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 | ReorderItem[] | — | yes | Основные эл ементы списка: строки `SimpleItem` и/или группы с заголовком `SimpleGroupItem` (`type: 'group'` + `label` + сортируемые `items`). |
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-кнопки. |
onItemsReorder | (items: ReorderItem[]) => void | — | yes | Колбек по завершению drag&drop-переупорядочивания элементов списка. Список остаётся управляемым: сам не хранит порядок, а отдаёт наружу целиком обновлённое дерево `items` — потребитель обновляет свой стейт этим значением. Переупорядочивание работает только среди «братьев» одного уровня (строки без группы либо строки внутри одной группы; перенос между группами не поддерживается). |
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 | Отключает возможность взаимодействовать со скролбарами мышью. |
widthStrategy | "auto" | "eq" | "gte" | auto | no | Стратегия управления шириной контейнера поповера <br/> - `auto` - соответствует ширине контента, <br/> - `gte` - Great Than or Equal, равен ширине таргета или больше ее, если контент в поповере шире, <br/> - `eq` - Equal, строго равен ширине таргета. |
Unions
Types
ReorderableDroplistProps
BaseItemWithoutNonGroup
CollapseState
CommonGroupItem
EmptyStateProps
Item
ReorderItem
ScrollProps
SearchState
SelectionMultipleState
SelectionSingleState
Unions
Size
Related props
Placement
PopoverWidthStrategy
Trigger
Storybook
Смотри также
- List — полнофункциональный список с группами, раскрытием, поиском и выбором.
- Droplist — тот же список в поповере.
ReorderableDroplistотносится к нему так же, какReorderableListкList(включая mobileBottomSheetна корневом уровне; вложенный drill-downnext-listс reorder не совмещается). - ItemContent — каноничная разметка
contentстроки.