# @cloud-ru/ds-drag-and-drop > Визуальные примитивы перетаскивания — поверхность копии, линия вставки и зона приёма. Docs: /snack-v2/components/drag-and-drop/ ## Установка ```sh pnpm add @cloud-ru/ds-drag-and-drop ``` ## Когда использовать - Перетаскивание собирается в своём компоненте, и нужен вид, совпадающий с остальной дизайн-системой. - Элемент переносится из одной зоны в другую, и нужно показать, какая зона его примет (`DropTarget`). - Порядок меняется вставкой, а не обменом местами — точку вставки показывает `DropIndicator` (статический перенос) либо пустой слот (динамический). Когда **не** нужен: - Готовое переупорядочивание уже есть в компоненте: - у `@cloud-ru/ds-list` переупорядочивание строк включается пропом `onItemsReorder` — примитивы там уже встроены. - Нужна сама механика переноса (сенсоры, коллизии, автоскролл): - нужна DnD-библиотека, эти примитивы — только визуальный слой. ## API ### DragGhost | Prop | Type | Default | Required | Description | |------|------|---------|----------|-------------| | `children` | `ReactNode` | — | no | Сущность, остающаяся на своей позиции на время переноса. | | `className` | `string` | — | no | CSS-класс | | `data-test-id` | `string` | — | no | | | `dragging` | `boolean` | `false` | no | Сущность переносится прямо сейчас. По умолчанию: false | | `innerRef` | `Ref` | — | no | Ref на корневой элемент — к нему привязывается sortable-узел DnD-библиотеки. | | `mode` | `dynamic \| static` | `static` | no | Режим переноса: `static` — соседи стоят на месте, сущность приглушается, точку вставки показывает `DropIndicator`; `dynamic` — соседи расступаются сразу, а слот сущности пустеет и сам показывает точку вставки (линия в этом режиме не нужна). По умолчанию: static | #### Related types - `DragMode` = `dynamic | static` ### DragPreview | Prop | Type | Default | Required | Description | |------|------|---------|----------|-------------| | `children` | `ReactNode` | — | no | Перетаскиваемая сущность: строка списка, карточка, чип — то, что едет за курсором. | | `className` | `string` | — | no | CSS-класс | | `data-test-id` | `string` | — | no | | | `innerRef` | `Ref` | — | no | Ref на корневой элемент | ### DropIndicator | Prop | Type | Default | Required | Description | |------|------|---------|----------|-------------| | `atEdge` | `boolean` | `false` | no | Линия стоит у края зоны приёма (первая или последняя позиция). Смещает её внутрь зоны, иначе линия ложится на обводку зоны либо обрезается скроллом. Работает вместе с `placement`. По умолчанию: false | | `className` | `string` | — | no | CSS-класс | | `data-test-id` | `string` | — | no | | | `innerRef` | `Ref` | — | no | Ref на корневой элемент | | `orientation` | `horizontal \| vertical` | `horizontal` | no | Ориентация линии: horizontal — вставка между строками, vertical — между колонками. По умолчанию: horizontal | | `placement` | `after \| before` | — | no | Край элемента-цели, у которого стоит линия. Задан — линия позиционируется абсолютно по этому краю и центрируется на границе с соседом; не задан — линия остаётся в потоке, и её размещает потребитель. Требует `position` на элементе-цели. | #### Related types - `Orientation` = `horizontal | vertical` - `Placement` = `after | before` ### DropTarget | Prop | Type | Default | Required | Description | |------|------|---------|----------|-------------| | `active` | `boolean` | `false` | no | Зона принимает перетаскиваемую сущность прямо сейчас — включает рамку и заливку. По умолчанию: false | | `children` | `ReactNode` | — | no | Содержимое зоны | | `className` | `string` | — | no | CSS-класс | | `data-test-id` | `string` | — | no | | | `innerRef` | `Ref` | — | no | Ref на корневой элемент — к нему привязывается droppable-узел DnD-библиотеки. | ## Примеры ### ActiveZone ```tsx import { DndContext, DragOverlay, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, } from '@dnd-kit/core'; import { DragGhost, DragPreview, DropTarget } from '@cloud-ru/ds-drag-and-drop'; import { useState } from 'react'; import styles from './demo.module.scss'; const ZONES = [ { id: 'zone-1', label: 'Зона 1' }, { id: 'zone-2', label: 'Зона 2' }, ]; const INITIAL_ITEMS = [ { id: 'item-1', label: 'ListItem 1', zoneId: 'zone-1' }, { id: 'item-2', label: 'ListItem 2', zoneId: 'zone-1' }, { id: 'item-3', label: 'ListItem 3', zoneId: 'zone-2' }, ]; type Item = (typeof INITIAL_ITEMS)[number]; function Row({ id, label }: Pick) { const { attributes, isDragging, listeners, setNodeRef } = useDraggable({ id }); return (
{label}
); } function Zone({ id, label, items, sourceZoneId }: { id: string; label: string; items: Item[]; sourceZoneId?: string }) { const { isOver, setNodeRef } = useDroppable({ id }); // Рамка — признак переноса между зонами: своя зона её не получает. const active = isOver && sourceZoneId !== undefined && sourceZoneId !== id; return (
{label}
{items.map(item => ( ))}
); } export function ActiveZone() { const [items, setItems] = useState(INITIAL_ITEMS); const [activeId, setActiveId] = useState(); // Порог в 4px: без него клик по строке уже считался бы началом переноса. const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const activeItem = items.find(item => item.id === activeId); return ( setActiveId(String(active.id))} onDragCancel={() => setActiveId(undefined)} onDragEnd={({ active, over }) => { setActiveId(undefined); if (!over) { return; } setItems(items => items.map(item => (item.id === active.id ? { ...item, zoneId: String(over.id) } : item))); }} >
{ZONES.map(zone => ( item.zoneId === zone.id)} sourceZoneId={activeItem?.zoneId} /> ))}
{activeItem && (
{activeItem.label}
)}
); } ``` ### DynamicGap ```tsx import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { DRAG_MODE, DragGhost, DragPreview } from '@cloud-ru/ds-drag-and-drop'; import { useState } from 'react'; import styles from './demo.module.scss'; const INITIAL_ROWS = [ { id: 'row-1', label: 'ListItem 1' }, { id: 'row-2', label: 'ListItem 2' }, { id: 'row-3', label: 'ListItem 3' }, { id: 'row-4', label: 'ListItem 4' }, ]; function Row({ id, label }: { id: string; label: string }) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }); return (
{label}
); } export function DynamicGap() { const [rows, setRows] = useState(INITIAL_ROWS); const [activeId, setActiveId] = useState(); // Порог в 4px: без него клик по строке уже считался бы началом переноса. const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const activeRow = rows.find(row => row.id === activeId); return ( setActiveId(String(active.id))} onDragCancel={() => setActiveId(undefined)} onDragEnd={({ active, over }) => { setActiveId(undefined); if (!over || active.id === over.id) { return; } setRows(rows => arrayMove( rows, rows.findIndex(row => row.id === active.id), rows.findIndex(row => row.id === over.id), ), ); }} > row.id)} strategy={verticalListSortingStrategy}>
{rows.map(row => ( ))}
{activeRow && (
{activeRow.label}
)}
); } ``` ### InsertionLine ```tsx import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { DRAG_MODE, DragGhost, DragPreview, DropIndicator, PLACEMENT } from '@cloud-ru/ds-drag-and-drop'; import { useState } from 'react'; import styles from './demo.module.scss'; const INITIAL_ROWS = [ { id: 'row-1', label: 'ListItem 1' }, { id: 'row-2', label: 'ListItem 2' }, { id: 'row-3', label: 'ListItem 3' }, { id: 'row-4', label: 'ListItem 4' }, ]; function Row({ id, label, lastIndex }: { id: string; label: string; lastIndex: number }) { const { activeIndex, attributes, index, isDragging, listeners, overIndex, setNodeRef } = useSortable({ id }); // Статический перенос: соседи стоят на месте, поэтому трансляцию от `@dnd-kit` строке // не применяем. Точку вставки отмечает линия на строке-цели, сторона — по тому, откуда // пришла перетаскиваемая строка. const showIndicator = activeIndex !== -1 && index === overIndex && index !== activeIndex; const placement = overIndex > activeIndex ? PLACEMENT.After : PLACEMENT.Before; return (
{label}
{showIndicator && ( )}
); } export function InsertionLine() { const [rows, setRows] = useState(INITIAL_ROWS); const [activeId, setActiveId] = useState(); // Порог в 4px: без него клик по строке уже считался бы началом переноса. const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const activeRow = rows.find(row => row.id === activeId); return ( setActiveId(String(active.id))} onDragCancel={() => setActiveId(undefined)} onDragEnd={({ active, over }) => { setActiveId(undefined); if (!over || active.id === over.id) { return; } setRows(rows => arrayMove( rows, rows.findIndex(row => row.id === active.id), rows.findIndex(row => row.id === over.id), ), ); }} > row.id)} strategy={verticalListSortingStrategy}>
{rows.map(row => ( ))}
{/* Копия за курсором: позиционирует её `DragOverlay`, поверхность даёт `DragPreview`. */} {activeRow && (
{activeRow.label}
)}
); } ``` ### PreviewSurface ```tsx import { DragPreview } from '@cloud-ru/ds-drag-and-drop'; import styles from './demo.module.scss'; export function PreviewSurface() { return (
ListItem 2
); } ```