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'

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

Переупорядочивание строк

Переупорядочивание строкУправляемый режим: onItemsReorder отдаёт обновлённый items целиком, потребитель сохраняет его в свой state.
tsx
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>
  );
}

Группы с заголовками

Группы с заголовкамиЭлемент type: group с label — заголовок группы; его items — сортируемые строки. Перетаскивание работает внутри каждой группы независимо, между группами строки не переносятся.
tsx
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

PropsReorderableListProps
PropTypeDefaultRequiredDescription
barHideStrategy"leave" | "move" | "never" | "scroll"noУправление скрытием скролл баров: <br> - `Never` - показывать всегда <br> - `Leave` - скрывать когда курсор покидает компонент <br> - `Scroll` - показывать только когда происходит скроллинг <br> - `Move` - показывать при движении курсора над компонентом
classNamestringnoCSS-класс
collapseCollapseState{}noНастройки раскрытия элементов
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` снизу)
hasListInFocusChainbooleantruenoФлаг, отвечающий за включение самого родительского контейнера листа в цепочку фокусирующихся элементов
headerReactNode ;noКастомизируемый элемент в начале списка — Figma `dropdownContainer.topBar`. Подходит для заголовка / справочного блока над поиском.
headerDividerbooleannoПоказывать divider между header и body (Figma `dropdownContainer.dividerWrapper` сверху)
itemsReorderItem[][]noОсновные элементы списка: строки `SimpleItem` и/или группы с заголовком `SimpleGroupItem` (`type: 'group'` + `label` + сортируемые `items`).
keyboardNavigationRefRefObject<{ focusItem(id: ItemId): void; }>noСсылка на управление навигацией листа с клавиатуры
limitedScrollHeightbooleannoОграничить максимальную высоту скролл-контейнера в зависимости от `size`
loadingbooleannoФлаг, отвечающий за состояние загрузки списка
markerbooleantruenoОтображать ли маркер у выбранного элемента списка
noDataStateEmptyStatePropsnoЭкран при отсутствии данных
noResultsStateEmptyStatePropsnoЭкран при отсутствии результатов поиска или фильтров
onItemsReorder(items: ReorderItem[]) => voidyesКолбек по завершению drag&drop-переупорядочивания элементов списка. Список остаётся управляемым: сам не хранит порядок, а отдаёт наружу целиком обновлённое дерево `items` — потребитель обновляет свой стейт этим значением. Переупорядочивание работает только среди «братьев» одного уровня (строки без группы либо строки внутри одной группы; перенос между группами не поддерживается).
onKeyDown((e: KeyboardEvent<HTMLElement>) => void)noОбработчик события по нажатию клавиш
onScroll((event?: Event) => void)noКолбек на скролл прокручиваемого списка
pinBottomItem[][]noЭлементы списка, закрепленные снизу
pinTopItem[][]noЭлементы списка, закрепленные сверху
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"mnoРазмер списка
tabIndexnumber0no`tabIndex` корневого элемента списка (для управления порядком фокуса)
untouchableScrollbarsbooleannoОтключает возможность взаимодействовать со скролбарами мышью.

ReorderableDroplist

Types

PropsReorderableDroplistProps
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` сверху)
itemsReorderItem[]yesОсновные элементы списка: строки `SimpleItem` и/или группы с заголовком `SimpleGroupItem` (`type: 'group'` + `label` + сортируемые `items`).
labelstringnoТолько mobile (`BottomSheet`): заголовок шапки.
limitedScrollHeightbooleannoОграничить максимальную высоту скролл-контейнера в зависимости от `size`
listRefRefObject<HTMLElement>noСсылка на элемент выпадающего списка
loadingbooleannoФлаг, отвечающий за состояние загрузки списка
markerbooleannoОтображать ли маркер у выбранного элемента списка
noDataStateEmptyStatePropsnoЭкран при отсутствии данных
noResultsStateEmptyStatePropsnoЭкран при отсутствии результатов поиска или фильтров
onBackButtonClick(() => void)noТолько mobile (`BottomSheet`): callback back-кнопки.
onItemsReorder(items: ReorderItem[]) => voidyesКолбек по завершению drag&drop-переупорядочивания элементов списка. Список остаётся управляемым: сам не хранит порядок, а отдаёт наружу целиком обновлённое дерево `items` — потребитель обновляет свой стейт этим значением. Переупорядочивание работает только среди «братьев» одного уровня (строки без группы либо строки внутри одной группы; перенос между группами не поддерживается).
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Отключает возможность взаимодействовать со скролбарами мышью.
widthStrategy"auto" | "eq" | "gte"autonoСтратегия управления шириной контейнера поповера <br/> - `auto` - соответствует ширине контента, <br/> - `gte` - Great Than or Equal, равен ширине таргета или больше ее, если контент в поповере шире, <br/> - `eq` - Equal, строго равен ширине таргета.

Unions

Types

ReorderableDroplistProps

Unions

Storybook

Смотри также

  • List — полнофункциональный список с группами, раскрытием, поиском и выбором.
  • Droplist — тот же список в поповере. ReorderableDroplist относится к нему так же, как ReorderableList к List (включая mobile BottomSheet на корневом уровне; вложенный drill-down next-list с reorder не совмещается).
  • ItemContent — каноничная разметка content строки.