diff --git a/README-ru.md b/README-ru.md index 5c10e37..9691c5e 100644 --- a/README-ru.md +++ b/README-ru.md @@ -419,6 +419,54 @@ const ColumnReorderingExample = () => { }; ``` +### Переупорядочивание строк и столбцов вместе + +Вложите `ColumnReorderingProvider` и `ReorderingProvider`, чтобы включить обе оси перетаскивания. Порядок провайдеров не важен — внутри используется один общий dnd-kit-контекст. + +```tsx +import type {ColumnReorderingProviderProps, ReorderingProviderProps} from '@gravity-ui/table'; +import {ColumnReorderingProvider, ReorderingProvider, dragHandleColumn} from '@gravity-ui/table'; + +const columns: ColumnDef[] = [ + dragHandleColumn, + {accessorKey: 'name', header: 'Name'}, + {accessorKey: 'age', header: 'Age'}, +]; + +const CombinedReorderingExample = () => { + const [data, setData] = React.useState(initialData); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, + }); + + const handleRowReorder = React.useCallback< + NonNullable['onReorder']> + >(({draggedItemKey, baseItemKey}) => { + // обновить массив data + }, []); + + const handleColumnReorder = React.useCallback< + NonNullable['onReorder']> + >(({columnOrder}) => { + setColumnOrder(columnOrder); + }, []); + + return ( + + + + + + ); +}; +``` + Если вы управляете `columnOrder` самостоятельно (например, чтобы сохранять его), передайте `onReorder` и примените полученный порядок: ```tsx diff --git a/README.md b/README.md index 48e72ed..d312ca7 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,54 @@ const ColumnReorderingExample = () => { }; ``` +### Row and column reordering together + +Nest `ColumnReorderingProvider` and `ReorderingProvider` to enable both drag axes at once. The order of providers does not matter — they share a single dnd-kit context internally. + +```tsx +import type {ColumnReorderingProviderProps, ReorderingProviderProps} from '@gravity-ui/table'; +import {ColumnReorderingProvider, ReorderingProvider, dragHandleColumn} from '@gravity-ui/table'; + +const columns: ColumnDef[] = [ + dragHandleColumn, + {accessorKey: 'name', header: 'Name'}, + {accessorKey: 'age', header: 'Age'}, +]; + +const CombinedReorderingExample = () => { + const [data, setData] = React.useState(initialData); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, + }); + + const handleRowReorder = React.useCallback< + NonNullable['onReorder']> + >(({draggedItemKey, baseItemKey}) => { + // update data array + }, []); + + const handleColumnReorder = React.useCallback< + NonNullable['onReorder']> + >(({columnOrder}) => { + setColumnOrder(columnOrder); + }, []); + + return ( + + +
+ + + ); +}; +``` + If you control `columnOrder` yourself (e.g. to persist it), pass `onReorder` and apply the resulting order: ```tsx diff --git a/src/components/BaseTable/BaseTable.tsx b/src/components/BaseTable/BaseTable.tsx index a3dde7c..ab0a542 100644 --- a/src/components/BaseTable/BaseTable.tsx +++ b/src/components/BaseTable/BaseTable.tsx @@ -13,6 +13,7 @@ import type {BaseHeaderRowProps} from '../BaseHeaderRow'; import {BaseHeaderRow} from '../BaseHeaderRow'; import type {BaseRowProps} from '../BaseRow'; import {BaseRow} from '../BaseRow'; +import {ColumnReorderingContext} from '../ColumnReorderingContext'; import {LastSelectedRowContextProvider} from '../LastSelectedRowContext'; import {SortableListContext} from '../SortableListContext'; @@ -166,7 +167,9 @@ export const BaseTable = React.forwardRef( ref: React.Ref, ) => { const draggableContext = React.useContext(SortableListContext); + const columnReorderingContext = React.useContext(ColumnReorderingContext); const draggingRowIndex = draggableContext?.activeItemIndex ?? -1; + const isColumnDragActive = Boolean(columnReorderingContext?.activeColumnId); const {rows, rowsById} = table.getRowModel(); @@ -272,6 +275,7 @@ export const BaseTable = React.forwardRef( ref={ref} className={b({'with-row-virtualization': Boolean(rowVirtualizer)}, className)} data-dragging-row-index={draggingRowIndex > -1 ? draggingRowIndex : undefined} + data-column-drag-active={isColumnDragActive || undefined} aria-colcount={colCount > 0 ? colCount : undefined} aria-rowcount={rowCount > 0 ? rowCount : undefined} aria-multiselectable={getAriaMultiselectable(table)} diff --git a/src/components/BaseTable/__stories__/BaseTable.stories.tsx b/src/components/BaseTable/__stories__/BaseTable.stories.tsx index 5925e1b..4c9b612 100644 --- a/src/components/BaseTable/__stories__/BaseTable.stories.tsx +++ b/src/components/BaseTable/__stories__/BaseTable.stories.tsx @@ -18,6 +18,7 @@ import {ReorderingStory} from './stories/ReorderingStory'; import {ReorderingTreeStory} from './stories/ReorderingTreeStory'; import {ReorderingWithVirtualizationStory} from './stories/ReorderingWithVirtualizationStory'; import {ResizingStory} from './stories/ResizingStory'; +import {RowAndColumnReorderingStory} from './stories/RowAndColumnReorderingStory'; import {SortingStory} from './stories/SortingStory'; import {StickyHeaderStory} from './stories/StickyHeaderStory'; import {TreeStory} from './stories/TreeStory'; @@ -94,6 +95,10 @@ export const ColumnReorderingWithPinning: StoryObj = { + render: RowAndColumnReorderingStory, +}; + export const Virtualization: StoryObj = { render: VirtualizationStory, }; diff --git a/src/components/BaseTable/__stories__/cells/DraggableTreeNameCell.tsx b/src/components/BaseTable/__stories__/cells/DraggableTreeNameCell.tsx index 99ace94..3c2d52c 100644 --- a/src/components/BaseTable/__stories__/cells/DraggableTreeNameCell.tsx +++ b/src/components/BaseTable/__stories__/cells/DraggableTreeNameCell.tsx @@ -1,7 +1,9 @@ -import {useSortable} from '@dnd-kit/sortable'; +import * as React from 'react'; + import type {Row, Table} from '@tanstack/react-table'; import {useDraggableRowDepth} from '../../../../hooks'; +import {SortableListContext} from '../../../SortableListContext'; import {TreeNameCell} from './TreeNameCell'; @@ -16,9 +18,8 @@ export const DraggableTreeNameCell = ({ table, value, }: DraggableTreeNameCellProps) => { - const {isDragging} = useSortable({ - id: row.id, - }); + const {useSortable} = React.useContext(SortableListContext) ?? {}; + const {isDragging = false} = useSortable?.({id: row.id}) ?? {}; const {depth} = useDraggableRowDepth({row, table, isDragging}); diff --git a/src/components/BaseTable/__stories__/stories/RowAndColumnReorderingStory.tsx b/src/components/BaseTable/__stories__/stories/RowAndColumnReorderingStory.tsx new file mode 100644 index 0000000..0602d29 --- /dev/null +++ b/src/components/BaseTable/__stories__/stories/RowAndColumnReorderingStory.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import {dragHandleColumn} from '../../../../constants'; +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import type {ColumnReorderingProviderProps} from '../../../ColumnReorderingProvider'; +import type {ReorderingProviderProps} from '../../../ReorderingProvider'; +import {ReorderingProvider} from '../../../ReorderingProvider'; +import {BaseTable} from '../../BaseTable'; +import {columns as originalColumns} from '../constants/columns'; +import {data as originalData} from '../constants/data'; +import type {Item} from '../types'; + +const columns: ColumnDef[] = [dragHandleColumn as ColumnDef, ...originalColumns]; + +export const RowAndColumnReorderingStory = () => { + const [data, setData] = React.useState(originalData); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnResizing: true, + columnResizeMode: 'onChange', + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, + }); + + const handleRowReorder = React.useCallback< + NonNullable['onReorder']> + >(({draggedItemKey, baseItemKey}) => { + setData((prevData) => { + const dataClone = prevData.slice(); + const index = dataClone.findIndex((item) => item.id === draggedItemKey); + + if (index >= 0) { + const dragged = dataClone.splice(index, 1)[0] as Item; + const insertIndex = dataClone.findIndex((item) => item.id === baseItemKey); + + if (insertIndex >= 0) { + dataClone.splice(insertIndex + 1, 0, dragged); + } else { + dataClone.unshift(dragged); + } + } + + return dataClone; + }); + }, []); + + const handleColumnReorder = React.useCallback< + NonNullable['onReorder']> + >(({columnOrder: nextColumnOrder}) => { + setColumnOrder(nextColumnOrder); + }, []); + + return ( + + +
+ +
+
+
+ ); +}; diff --git a/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.classname.ts b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.classname.ts new file mode 100644 index 0000000..6449f35 --- /dev/null +++ b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.classname.ts @@ -0,0 +1,3 @@ +import {block} from '../../utils'; + +export const b = block('column-drag-insertion-indicator'); diff --git a/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.scss b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.scss new file mode 100644 index 0000000..b7d285d --- /dev/null +++ b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.scss @@ -0,0 +1,15 @@ +@use '../variables'; + +$block: '.#{variables.$ns}column-drag-insertion-indicator'; + +#{$block} { + position: fixed; + + pointer-events: none; + + z-index: var(--gt-table-reordering-insertion-line-z-index, 2); + + width: var(--gt-table-reordering-insertion-line-width, 2px); + + background: var(--gt-table-reordering-insertion-line-color, #4d8bff); +} diff --git a/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.tsx b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.tsx new file mode 100644 index 0000000..1d490c8 --- /dev/null +++ b/src/components/ColumnDragInsertionIndicator/ColumnDragInsertionIndicator.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +import type {Table} from '@tanstack/react-table'; + +import {b} from './ColumnDragInsertionIndicator.classname'; +import {getInsertionLineSide} from './utils/getInsertionLineSide'; +import {measureIndicator} from './utils/measureIndicator'; + +import './ColumnDragInsertionIndicator.scss'; + +export interface ColumnDragInsertionIndicatorProps { + table: Table; + scopeRef: React.RefObject; + activeColumnId: string | null; + targetColumnId: string | null; +} + +export function ColumnDragInsertionIndicator({ + table, + scopeRef, + activeColumnId, + targetColumnId, +}: ColumnDragInsertionIndicatorProps) { + const side = + activeColumnId && targetColumnId + ? getInsertionLineSide(table, activeColumnId, targetColumnId) + : null; + + const [style, setStyle] = React.useState(null); + + React.useLayoutEffect(() => { + if (!side || !targetColumnId) { + setStyle(null); + return; + } + + const update = () => { + setStyle(measureIndicator(scopeRef, targetColumnId, side)); + }; + + update(); + + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [scopeRef, side, targetColumnId]); + + if (!style) { + return null; + } + + return
); } + +export const ColumnDragOverlay = React.memo( + ColumnDragOverlayInternal, +) as typeof ColumnDragOverlayInternal; diff --git a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss index ef53819..f8dffa1 100644 --- a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss +++ b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss @@ -16,4 +16,18 @@ $headerCellBlock: '#{$tableBlock}__header-cell'; &[data-drag-active='true'] { cursor: grabbing; } + + &[data-dragging='true'] { + pointer-events: none; + } + + &[data-drag-active='true']:hover { + * { + pointer-events: none; + } + } +} + +#{$tableBlock}[data-column-drag-active='true'] { + pointer-events: none; } diff --git a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx index 13f6eb5..54f242b 100644 --- a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx +++ b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx @@ -1,22 +1,21 @@ import * as React from 'react'; -import { - DndContext, - DragOverlay, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; +import {DragOverlay} from '@dnd-kit/core'; import {SortableContext, useSortable} from '@dnd-kit/sortable'; import type {ColumnDef} from '../../types/base'; +import {ColumnDragInsertionIndicator} from '../ColumnDragInsertionIndicator'; import {ColumnDragOverlay} from '../ColumnDragOverlay'; import type {ColumnReorderingContextValue} from '../ColumnReorderingContext'; import {ColumnReorderingContext} from '../ColumnReorderingContext'; +import { + REORDER_TYPE_COLUMN, + TableDndRegistryContext, + TableDndRegistryProvider, + TableDndScopeRegistrar, + toColumnSortableId, +} from '../TableDndRoot'; -import {autoScrollConfig} from './constants/autoScroll'; -import {measuring} from './constants/measuring'; import {useColumnDrag} from './hooks/useColumnDrag'; import {useReorderingStyles} from './hooks/useReorderingStyles'; import {useScope} from './hooks/useScope'; @@ -25,6 +24,13 @@ import {restrictToHorizontalAxis} from './utils/restrictToHorizontalAxis'; import './ColumnReorderingProvider.scss'; +const useColumnSortable: typeof useSortable = (args) => + useSortable({ + ...args, + id: toColumnSortableId(String(args.id)), + data: {...args.data, reorderType: REORDER_TYPE_COLUMN}, + }); + export const ColumnReorderingProvider = ({ table, children, @@ -35,20 +41,18 @@ export const ColumnReorderingProvider = ({ renderDragOverlay, onReorder, }: ColumnReorderingProviderProps) => { + const registry = React.useContext(TableDndRegistryContext); const {scopeRef, scopeClassName, wrapperClassName} = useScope(); - const { - activeColumnId, - targetColumnId, - overlayClassNames, - handleDragStart, - handleDragOver, - handleDragEnd, - resetState, - } = useColumnDrag({table, scopeRef, autoScroll, onReorder}); + const {activeColumnId, targetColumnId, overlayClassNames, handlers} = useColumnDrag({ + table, + scopeRef, + autoScroll, + onReorder, + }); const contextValue = React.useMemo( - () => ({activeColumnId, targetColumnId, useSortable}), + () => ({activeColumnId, targetColumnId, useSortable: useColumnSortable}), [activeColumnId, targetColumnId], ); @@ -56,52 +60,66 @@ export const ColumnReorderingProvider = ({ table, scopeClassName, activeColumnId, - targetColumnId, }); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: {distance: activationDistance}, - }), - ); - const modifiers = React.useMemo( () => dndModifiers ?? [restrictToHorizontalAxis], [dndModifiers], ); - const sortableColumnIds = table - .getVisibleLeafColumns() - .filter((column) => (column.columnDef as ColumnDef).enableColumnReordering !== false) - .map((column) => column.id); + const sortableColumnIds = React.useMemo( + () => + table + .getVisibleLeafColumns() + .filter( + (column) => + (column.columnDef as ColumnDef).enableColumnReordering !== false, + ) + .map((column) => toColumnSortableId(column.id)), + [table], + ); + + const scopeConfig = React.useMemo( + () => ({ + type: 'column' as const, + activationDistance, + modifiers, + autoScroll, + handlers, + }), + [activationDistance, autoScroll, handlers, modifiers], + ); - return ( + const content = ( - -
- {children} -
- - - -
+ +
+ + {children} + +
+ + + + {reorderingStyles ? : null}
); + + if (registry) { + return content; + } + + return {content}; }; diff --git a/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts b/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts index 5893245..b605cb9 100644 --- a/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts +++ b/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts @@ -5,6 +5,8 @@ import {arrayMove} from '@dnd-kit/sortable'; import type {ColumnPinningState, Table} from '@tanstack/react-table'; import {b} from '../../BaseTable/BaseTable.classname'; +import {REORDER_TYPE_COLUMN, fromColumnSortableId, getReorderType} from '../../TableDndRoot'; +import type {TableDndScopeHandlers} from '../../TableDndRoot'; import type {ColumnReorderResult, OverlayClassNames} from '../types'; import {escapeClassName} from '../utils/escapeClassName'; import {getColumnGroup} from '../utils/getColumnGroup'; @@ -34,134 +36,158 @@ export function useColumnDrag({ const {startAutoScroll, stopAutoScroll} = useAutoScroll({table, scopeRef}); - const captureOverlayClassNames = (columnId: string) => { - const root = scopeRef.current; - - if (!root) { - setOverlayClassNames(null); - return; - } - - const headerCellEl = root.querySelector( - `.${escapeClassName(`${b('header-cell')}_id_${columnId}`)}`, - ); - const cellEl = root.querySelector(`.${escapeClassName(`${b('cell')}_id_${columnId}`)}`); - - setOverlayClassNames({ - table: getElementClassName((cellEl ?? headerCellEl)?.closest('table')), - thead: getElementClassName(headerCellEl?.closest('thead')), - headerRow: getElementClassName(headerCellEl?.closest('tr')), - headerCell: getElementClassName(headerCellEl), - tbody: getElementClassName(cellEl?.closest('tbody')), - row: getElementClassName(cellEl?.closest('tr')), - cell: getElementClassName(cellEl), - }); - }; - - const handleDragStart = (event: DragStartEvent) => { - const columnId = event.active.id as string; + const captureOverlayClassNames = React.useCallback( + (columnId: string) => { + const root = scopeRef.current; - setActiveColumnId(columnId); - captureOverlayClassNames(columnId); - document.body.style.setProperty('cursor', 'grabbing'); - - // Auto-scroll only makes sense for center (non-pinned) columns - if (autoScroll && getColumnGroup(table, columnId) === 'center') { - startAutoScroll(event); - } - }; + if (!root) { + setOverlayClassNames(null); + return; + } - const handleDragOver = (event: DragOverEvent) => { - setTargetColumnId((event.over?.id as string) ?? null); - }; + const headerCellEl = root.querySelector( + `.${escapeClassName(`${b('header-cell')}_id_${columnId}`)}`, + ); + const cellEl = root.querySelector(`.${escapeClassName(`${b('cell')}_id_${columnId}`)}`); + + setOverlayClassNames({ + table: getElementClassName((cellEl ?? headerCellEl)?.closest('table')), + thead: getElementClassName(headerCellEl?.closest('thead')), + headerRow: getElementClassName(headerCellEl?.closest('tr')), + headerCell: getElementClassName(headerCellEl), + tbody: getElementClassName(cellEl?.closest('tbody')), + row: getElementClassName(cellEl?.closest('tr')), + cell: getElementClassName(cellEl), + }); + }, + [scopeRef], + ); - const resetState = () => { + const resetState = React.useCallback(() => { setActiveColumnId(null); setTargetColumnId(null); setOverlayClassNames(null); document.body.style.removeProperty('cursor'); stopAutoScroll(); - }; - - const handleDragEnd = (event: DragEndEvent) => { - const {active, over} = event; - - resetState(); - - if (!over || active.id === over.id) { - return; - } - - const draggedColumnId = active.id as string; - const droppedOnColumnId = over.id as string; - - const group = getColumnGroup(table, draggedColumnId); - - if (group !== getColumnGroup(table, droppedOnColumnId)) { - return; - } - - const columnPinning = table.getState().columnPinning; - - if (group === 'center') { - const currentOrder = getCurrentColumnOrder(table); - const oldIndex = currentOrder.indexOf(draggedColumnId); - const newIndex = currentOrder.indexOf(droppedOnColumnId); - - if (oldIndex === -1 || newIndex === -1) { - return; - } - - const columnOrder = arrayMove(currentOrder, oldIndex, newIndex); - - if (onReorder) { - onReorder({ - columnOrder, - columnPinning, - pinned: false, - draggedColumnId, - targetColumnId: droppedOnColumnId, - }); - } else { - table.setColumnOrder(columnOrder); - } - - return; - } - - const groupOrder = columnPinning[group] ?? []; - const oldIndex = groupOrder.indexOf(draggedColumnId); - const newIndex = groupOrder.indexOf(droppedOnColumnId); - - if (oldIndex === -1 || newIndex === -1) { - return; - } - - const nextColumnPinning: ColumnPinningState = { - ...columnPinning, - [group]: arrayMove(groupOrder, oldIndex, newIndex), - }; - - if (onReorder) { - onReorder({ - columnOrder: table.getState().columnOrder, - columnPinning: nextColumnPinning, - pinned: group, - draggedColumnId, - targetColumnId: droppedOnColumnId, - }); - } else { - table.setColumnPinning(nextColumnPinning); - } - }; + }, [stopAutoScroll]); + + const handlers = React.useMemo( + () => ({ + onDragStart: (event: DragStartEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_COLUMN) { + return; + } + + const columnId = fromColumnSortableId(event.active.id as string); + + setActiveColumnId(columnId); + captureOverlayClassNames(columnId); + document.body.style.setProperty('cursor', 'grabbing'); + + if (autoScroll && getColumnGroup(table, columnId) === 'center') { + startAutoScroll(event); + } + }, + onDragOver: (event: DragOverEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_COLUMN) { + return; + } + + setTargetColumnId( + event.over ? fromColumnSortableId(event.over.id as string) : null, + ); + }, + onDragEnd: (event: DragEndEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_COLUMN) { + return; + } + + const {active, over} = event; + + resetState(); + + if (!over || active.id === over.id) { + return; + } + + const draggedColumnId = fromColumnSortableId(active.id as string); + const droppedOnColumnId = fromColumnSortableId(over.id as string); + + const group = getColumnGroup(table, draggedColumnId); + + if (group !== getColumnGroup(table, droppedOnColumnId)) { + return; + } + + const columnPinning = table.getState().columnPinning; + + const applyReorder = () => { + if (group === 'center') { + const currentOrder = getCurrentColumnOrder(table); + const oldIndex = currentOrder.indexOf(draggedColumnId); + const newIndex = currentOrder.indexOf(droppedOnColumnId); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const columnOrder = arrayMove(currentOrder, oldIndex, newIndex); + + if (onReorder) { + onReorder({ + columnOrder, + columnPinning, + pinned: false, + draggedColumnId, + targetColumnId: droppedOnColumnId, + }); + } else { + table.setColumnOrder(columnOrder); + } + + return; + } + + const groupOrder = columnPinning[group] ?? []; + const oldIndex = groupOrder.indexOf(draggedColumnId); + const newIndex = groupOrder.indexOf(droppedOnColumnId); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const nextColumnPinning: ColumnPinningState = { + ...columnPinning, + [group]: arrayMove(groupOrder, oldIndex, newIndex), + }; + + if (onReorder) { + onReorder({ + columnOrder: table.getState().columnOrder, + columnPinning: nextColumnPinning, + pinned: group, + draggedColumnId, + targetColumnId: droppedOnColumnId, + }); + } else { + table.setColumnPinning(nextColumnPinning); + } + }; + + React.startTransition(applyReorder); + }, + onDragCancel: () => { + resetState(); + }, + }), + [autoScroll, captureOverlayClassNames, onReorder, resetState, startAutoScroll, table], + ); return { activeColumnId, targetColumnId, overlayClassNames, - handleDragStart, - handleDragOver, - handleDragEnd, + handlers, resetState, }; } diff --git a/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts b/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts index bde32d2..ee07853 100644 --- a/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts +++ b/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts @@ -10,14 +10,12 @@ interface UseReorderingStylesParams { table: Table; scopeClassName: string; activeColumnId: string | null; - targetColumnId: string | null; } export function useReorderingStyles({ table, scopeClassName, activeColumnId, - targetColumnId, }: UseReorderingStylesParams): string | null { return React.useMemo(() => { if (!activeColumnId) { @@ -46,24 +44,6 @@ export function useReorderingStyles({ ); } - if ( - targetColumnId && - targetColumnId !== activeColumnId && - group === getColumnGroup(table, targetColumnId) - ) { - const draggedIndex = table.getColumn(activeColumnId)?.getIndex(group) ?? -1; - const targetIndex = table.getColumn(targetColumnId)?.getIndex(group) ?? -1; - - if (draggedIndex !== -1 && targetIndex !== -1) { - const lineWidth = 'var(--gt-table-reordering-insertion-line-width, 2px)'; - const inset = targetIndex > draggedIndex ? `calc(-1 * ${lineWidth})` : lineWidth; - - rules.push( - `${getColumnSelector(targetColumnId)} { box-shadow: inset ${inset} 0 0 0 var(--gt-table-reordering-insertion-line-color, #4d8bff); }`, - ); - } - } - return rules.join('\n'); - }, [table, activeColumnId, targetColumnId, scopeClassName]); + }, [table, activeColumnId, scopeClassName]); } diff --git a/src/components/DragHandle/DragHandle.tsx b/src/components/DragHandle/DragHandle.tsx index 9e7070d..02bd10f 100644 --- a/src/components/DragHandle/DragHandle.tsx +++ b/src/components/DragHandle/DragHandle.tsx @@ -3,6 +3,8 @@ import {Grip as GripIcon} from '@gravity-ui/icons'; import {Icon} from '@gravity-ui/uikit'; import type {Row} from '@tanstack/react-table'; +import {REORDER_TYPE_ROW, toRowSortableId} from '../TableDndRoot'; + import {b} from './DragHandle.classname'; import './DragHandle.scss'; @@ -13,7 +15,8 @@ export interface DragHandleProps { export const DragHandle = ({row}: DragHandleProps) => { const {attributes, listeners} = useSortable({ - id: row.id, + id: toRowSortableId(row.id), + data: {reorderType: REORDER_TYPE_ROW}, }); return ( diff --git a/src/components/ReorderingProvider/ReorderingProvider.tsx b/src/components/ReorderingProvider/ReorderingProvider.tsx index affa395..7609a4f 100644 --- a/src/components/ReorderingProvider/ReorderingProvider.tsx +++ b/src/components/ReorderingProvider/ReorderingProvider.tsx @@ -4,7 +4,6 @@ import type {Table} from '@tanstack/react-table'; import type {SortableListProps} from '../SortableList'; import {SortableList} from '../SortableList'; -import {SortableListDndContext} from '../SortableListDndContext'; import type {SortableListDndContextProps} from '../SortableListDndContext'; import './ReorderingProvider.scss'; @@ -33,10 +32,13 @@ export const ReorderingProvider = ({ const rowIds = React.useMemo(() => rows.map((row) => row.id), [rows]); return ( - - - {children} - - + + {children} + ); }; diff --git a/src/components/SortIndicator/SortIndicator.tsx b/src/components/SortIndicator/SortIndicator.tsx index 355b37b..a4c9999 100644 --- a/src/components/SortIndicator/SortIndicator.tsx +++ b/src/components/SortIndicator/SortIndicator.tsx @@ -1,7 +1,10 @@ +import * as React from 'react'; + import {ActionTooltip, Icon} from '@gravity-ui/uikit'; import {getSortIndicatorIcon} from '../../utils'; import type {BaseSortIndicatorProps} from '../BaseSortIndicator'; +import {ColumnReorderingContext} from '../ColumnReorderingContext'; import {b} from './SortIndicator.classname'; import i18n from './i18n'; @@ -14,6 +17,9 @@ export const SortIndicator = ({ className, header, }: SortIndicatorProps) => { + const columnReorderingContext = React.useContext(ColumnReorderingContext); + const isColumnDragActive = Boolean(columnReorderingContext?.activeColumnId); + const order = header.column.getIsSorted(); const nextOrder = header.column.getNextSortingOrder(); @@ -23,11 +29,15 @@ export const SortIndicator = ({ label = nextOrder === 'desc' ? i18n('label-sort-desc') : i18n('label-sort-asc'); } - return ( - - - - - + const indicator = ( + + + ); + + if (isColumnDragActive) { + return indicator; + } + + return {indicator}; }; diff --git a/src/components/SortableList/SortableList.tsx b/src/components/SortableList/SortableList.tsx index 9d7c2f4..b604b82 100644 --- a/src/components/SortableList/SortableList.tsx +++ b/src/components/SortableList/SortableList.tsx @@ -6,9 +6,24 @@ import type {UseSortableListParams} from '../../hooks'; import {useSortableList} from '../../hooks'; import type {SortableListContextValue} from '../SortableListContext'; import {SortableListContext} from '../SortableListContext'; +import { + REORDER_TYPE_ROW, + TableDndRegistryContext, + TableDndRegistryProvider, + TableDndScopeRegistrar, + toRowSortableId, +} from '../TableDndRoot'; + +const useRowSortable: typeof useSortable = (args) => + useSortable({ + ...args, + id: toRowSortableId(String(args.id)), + data: {...args.data, reorderType: REORDER_TYPE_ROW}, + }); export interface SortableListProps extends UseSortableListParams { children?: React.ReactNode; + dndModifiers?: import('@dnd-kit/core').Modifier[]; } export const SortableList = ({ @@ -17,7 +32,10 @@ export const SortableList = ({ onDragStart, onDragEnd, enableNesting, + dndModifiers, }: SortableListProps) => { + const registry = React.useContext(TableDndRegistryContext); + const { activeItemKey, activeItemIndex, @@ -25,6 +43,7 @@ export const SortableList = ({ isParentMode, isNextChildMode, targetItemIndex, + handlers, } = useSortableList({ items, onDragStart, @@ -32,6 +51,17 @@ export const SortableList = ({ enableNesting, }); + const sortableRowIds = React.useMemo(() => items.map(toRowSortableId), [items]); + + const scopeConfig = React.useMemo( + () => ({ + type: 'row' as const, + modifiers: dndModifiers, + handlers, + }), + [dndModifiers, handlers], + ); + const contextValue = React.useMemo( () => ({ @@ -42,17 +72,30 @@ export const SortableList = ({ isParentMode, targetItemIndex, enableNesting, - useSortable, + useSortable: useRowSortable, }) satisfies SortableListContextValue, // eslint-disable-next-line react-hooks/exhaustive-deps [activeItemKey, isChildMode, isNextChildMode, isParentMode, targetItemIndex, enableNesting], ); - return ( - - - {children} - - + const content = ( + + + + + {children} + + + ); + + if (registry) { + return content; + } + + return {content}; }; diff --git a/src/components/SortableListContext/SortableListContext.tsx b/src/components/SortableListContext/SortableListContext.tsx index 728a7a7..2b274fe 100644 --- a/src/components/SortableListContext/SortableListContext.tsx +++ b/src/components/SortableListContext/SortableListContext.tsx @@ -4,7 +4,8 @@ import type {useSortable} from '@dnd-kit/sortable'; import type {useSortableList} from '../../hooks'; -export interface SortableListContextValue extends ReturnType { +export interface SortableListContextValue + extends Omit, 'handlers'> { enableNesting?: boolean; useSortable?: typeof useSortable; } diff --git a/src/components/SortableListDndContext/SortableListDndContext.tsx b/src/components/SortableListDndContext/SortableListDndContext.tsx index afb1713..fc055e2 100644 --- a/src/components/SortableListDndContext/SortableListDndContext.tsx +++ b/src/components/SortableListDndContext/SortableListDndContext.tsx @@ -1,38 +1,14 @@ import type * as React from 'react'; -import type {MeasuringConfiguration, Modifier} from '@dnd-kit/core'; -import { - DndContext, - MeasuringStrategy, - PointerSensor, - rectIntersection, - useSensor, - useSensors, -} from '@dnd-kit/core'; +import type {Modifier} from '@dnd-kit/core'; export interface SortableListDndContextProps { modifiers?: Modifier[]; children?: React.ReactNode; } -const measuring: MeasuringConfiguration = { - droppable: { - strategy: MeasuringStrategy.WhileDragging, - }, -}; - -export const SortableListDndContext = ({children, modifiers}: SortableListDndContextProps) => { - const pointerSensor = useSensor(PointerSensor); - const sensors = useSensors(pointerSensor); - - return ( - - {children} - - ); -}; +/** + * @deprecated SortableList now owns the shared TableDndRoot context. + * This component is kept as a passthrough for internal backward compatibility. + */ +export const SortableListDndContext = ({children}: SortableListDndContextProps) => children; diff --git a/src/components/Table/__stories__/Table.stories.tsx b/src/components/Table/__stories__/Table.stories.tsx index 36fca01..47b868d 100644 --- a/src/components/Table/__stories__/Table.stories.tsx +++ b/src/components/Table/__stories__/Table.stories.tsx @@ -10,6 +10,7 @@ import {GroupingStory} from './stories/GroupingStory'; import {GroupingWithSelectionStory} from './stories/GroupingWithSelectionStory'; import {ReorderingStory} from './stories/ReorderingStory'; import {ReorderingWithVirtualizationStory} from './stories/ReorderingWithVirtualizationStory'; +import {RowAndColumnReorderingStory} from './stories/RowAndColumnReorderingStory'; import {RowLinkStory} from './stories/RowLinkStory'; import {SizeSStory} from './stories/SizeSStory'; import {SortingStory} from './stories/SortingStory'; @@ -81,6 +82,10 @@ export const ColumnReorderingWithPinning: StoryObj = { + render: RowAndColumnReorderingStory, +}; + export const Virtualization: StoryObj = { render: VirtualizationStory, }; diff --git a/src/components/Table/__stories__/stories/RowAndColumnReorderingStory.tsx b/src/components/Table/__stories__/stories/RowAndColumnReorderingStory.tsx new file mode 100644 index 0000000..364fa3b --- /dev/null +++ b/src/components/Table/__stories__/stories/RowAndColumnReorderingStory.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import {dragHandleColumn} from '../../../../constants'; +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {columns as originalColumns} from '../../../BaseTable/__stories__/constants/columns'; +import {data as originalData} from '../../../BaseTable/__stories__/constants/data'; +import type {Item} from '../../../BaseTable/__stories__/types'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import type {ColumnReorderingProviderProps} from '../../../ColumnReorderingProvider'; +import type {ReorderingProviderProps} from '../../../ReorderingProvider'; +import {ReorderingProvider} from '../../../ReorderingProvider'; +import {Table} from '../../Table'; + +const columns: ColumnDef[] = [dragHandleColumn as ColumnDef, ...originalColumns]; + +export const RowAndColumnReorderingStory = () => { + const [data, setData] = React.useState(originalData); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnResizing: true, + columnResizeMode: 'onChange', + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, + }); + + const handleRowReorder = React.useCallback< + NonNullable['onReorder']> + >(({draggedItemKey, baseItemKey}) => { + setData((prevData) => { + const dataClone = prevData.slice(); + const index = dataClone.findIndex((item) => item.id === draggedItemKey); + + if (index >= 0) { + const dragged = dataClone.splice(index, 1)[0] as Item; + const insertIndex = dataClone.findIndex((item) => item.id === baseItemKey); + + if (insertIndex >= 0) { + dataClone.splice(insertIndex + 1, 0, dragged); + } else { + dataClone.unshift(dragged); + } + } + + return dataClone; + }); + }, []); + + const handleColumnReorder = React.useCallback< + NonNullable['onReorder']> + >(({columnOrder: nextColumnOrder}) => { + setColumnOrder(nextColumnOrder); + }, []); + + return ( + + +
+ + + + + ); +}; diff --git a/src/components/TableDndRoot/TableDndRegistryContext.tsx b/src/components/TableDndRoot/TableDndRegistryContext.tsx new file mode 100644 index 0000000..4b23a1e --- /dev/null +++ b/src/components/TableDndRoot/TableDndRegistryContext.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +import type {TableDndRegistryContextValue} from './types'; + +export const TableDndRegistryContext = React.createContext< + TableDndRegistryContextValue | undefined +>(undefined); diff --git a/src/components/TableDndRoot/TableDndRegistryProvider.tsx b/src/components/TableDndRoot/TableDndRegistryProvider.tsx new file mode 100644 index 0000000..58c1411 --- /dev/null +++ b/src/components/TableDndRoot/TableDndRegistryProvider.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; + +import {TableDndRegistryContext} from './TableDndRegistryContext'; +import {TableDndRoot} from './TableDndRoot'; +import type {TableDndRegistryContextValue, TableDndScopeConfig} from './types'; + +export interface TableDndRegistryProviderProps { + children?: React.ReactNode; +} + +export const TableDndRegistryProvider = ({children}: TableDndRegistryProviderProps) => { + const [scopes, setScopes] = React.useState>({}); + + const registerScope = React.useCallback((id: string, config: TableDndScopeConfig) => { + setScopes((prevScopes) => ({...prevScopes, [id]: config})); + }, []); + + const unregisterScope = React.useCallback((id: string) => { + setScopes((prevScopes) => { + const nextScopes = {...prevScopes}; + delete nextScopes[id]; + + return nextScopes; + }); + }, []); + + const contextValue = React.useMemo( + () => ({registerScope, unregisterScope}), + [registerScope, unregisterScope], + ); + + return ( + + {children} + + ); +}; diff --git a/src/components/TableDndRoot/TableDndRoot.tsx b/src/components/TableDndRoot/TableDndRoot.tsx new file mode 100644 index 0000000..44da934 --- /dev/null +++ b/src/components/TableDndRoot/TableDndRoot.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; + +import type { + AutoScrollOptions, + DragCancelEvent, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, +} from '@dnd-kit/core'; +import {DndContext, MeasuringStrategy, PointerSensor, useSensor, useSensors} from '@dnd-kit/core'; + +import {autoScrollConfig} from '../ColumnReorderingProvider/constants/autoScroll'; + +import type {TableDndScopeConfig} from './types'; +import {createMergedModifiers} from './utils/mergeModifiers'; +import {getReorderType} from './utils/reorderType'; +import {tableCollisionDetection} from './utils/tableCollisionDetection'; + +const defaultMeasuring = { + droppable: { + strategy: MeasuringStrategy.WhileDragging, + }, +}; + +export interface TableDndRootProps { + scopes: Record; + children?: React.ReactNode; +} + +export const TableDndRoot = ({scopes, children}: TableDndRootProps) => { + const scopeList = React.useMemo(() => Object.values(scopes), [scopes]); + + const columnScope = scopeList.find((scope) => scope.type === 'column'); + const activationDistance = columnScope?.activationDistance; + + const pointerSensor = useSensor( + PointerSensor, + activationDistance === undefined + ? undefined + : {activationConstraint: {distance: activationDistance}}, + ); + const sensors = useSensors(pointerSensor); + + const modifiers = React.useMemo(() => createMergedModifiers(scopeList), [scopeList]); + + const autoScroll = React.useMemo(() => { + if (!columnScope?.autoScroll) { + return false; + } + + return autoScrollConfig; + }, [columnScope?.autoScroll]); + + const dispatchToScope = React.useCallback( + ( + event: DragStartEvent | DragMoveEvent | DragOverEvent | DragEndEvent | DragCancelEvent, + ) => { + const type = getReorderType(event.active); + return scopeList.find((item) => item.type === type); + }, + [scopeList], + ); + + const handleDragStart = React.useCallback( + (event: DragStartEvent) => { + dispatchToScope(event)?.handlers.onDragStart?.(event); + }, + [dispatchToScope], + ); + + const handleDragMove = React.useCallback( + (event: DragMoveEvent) => { + dispatchToScope(event)?.handlers.onDragMove?.(event); + }, + [dispatchToScope], + ); + + const handleDragOver = React.useCallback( + (event: DragOverEvent) => { + dispatchToScope(event)?.handlers.onDragOver?.(event); + }, + [dispatchToScope], + ); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + dispatchToScope(event)?.handlers.onDragEnd?.(event); + }, + [dispatchToScope], + ); + + const handleDragCancel = React.useCallback( + (event: DragCancelEvent) => { + dispatchToScope(event)?.handlers.onDragCancel?.(event); + }, + [dispatchToScope], + ); + + return ( + + {children} + + ); +}; diff --git a/src/components/TableDndRoot/TableDndScopeRegistrar.tsx b/src/components/TableDndRoot/TableDndScopeRegistrar.tsx new file mode 100644 index 0000000..d20a50f --- /dev/null +++ b/src/components/TableDndRoot/TableDndScopeRegistrar.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import {TableDndRegistryContext} from './TableDndRegistryContext'; +import type {TableDndScopeConfig} from './types'; + +export interface TableDndScopeRegistrarProps { + scopeId: string; + config: TableDndScopeConfig; +} + +export const TableDndScopeRegistrar = ({scopeId, config}: TableDndScopeRegistrarProps) => { + const registry = React.useContext(TableDndRegistryContext); + + React.useLayoutEffect(() => { + registry?.registerScope(scopeId, config); + + return () => { + registry?.unregisterScope(scopeId); + }; + }, [registry, scopeId, config]); + + return null; +}; diff --git a/src/components/TableDndRoot/constants.ts b/src/components/TableDndRoot/constants.ts new file mode 100644 index 0000000..9c81254 --- /dev/null +++ b/src/components/TableDndRoot/constants.ts @@ -0,0 +1,2 @@ +export const REORDER_TYPE_ROW = 'row' as const; +export const REORDER_TYPE_COLUMN = 'column' as const; diff --git a/src/components/TableDndRoot/index.ts b/src/components/TableDndRoot/index.ts new file mode 100644 index 0000000..47b26b3 --- /dev/null +++ b/src/components/TableDndRoot/index.ts @@ -0,0 +1,12 @@ +export {TableDndRegistryProvider} from './TableDndRegistryProvider'; +export {TableDndScopeRegistrar} from './TableDndScopeRegistrar'; +export {TableDndRegistryContext} from './TableDndRegistryContext'; +export type {ReorderType, TableDndScopeConfig, TableDndScopeHandlers} from './types'; +export { + toRowSortableId, + toColumnSortableId, + fromRowSortableId, + fromColumnSortableId, +} from './utils/sortableIds'; +export {REORDER_TYPE_ROW, REORDER_TYPE_COLUMN} from './constants'; +export {getReorderType} from './utils/reorderType'; diff --git a/src/components/TableDndRoot/types.ts b/src/components/TableDndRoot/types.ts new file mode 100644 index 0000000..5c1c72e --- /dev/null +++ b/src/components/TableDndRoot/types.ts @@ -0,0 +1,37 @@ +import type * as React from 'react'; + +import type { + DragCancelEvent, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, + MeasuringConfiguration, + Modifier, +} from '@dnd-kit/core'; + +export type ReorderType = 'row' | 'column'; + +export interface TableDndScopeHandlers { + onDragStart?: (event: DragStartEvent) => void; + onDragMove?: (event: DragMoveEvent) => void; + onDragOver?: (event: DragOverEvent) => void; + onDragEnd?: (event: DragEndEvent) => void; + onDragCancel?: (event: DragCancelEvent) => void; +} + +export interface TableDndScopeConfig { + type: ReorderType; + activationDistance?: number; + modifiers?: Modifier[]; + autoScroll?: boolean; + measuring?: MeasuringConfiguration; + handlers: TableDndScopeHandlers; + renderOverlay?: () => React.ReactNode; + renderStyles?: () => React.ReactNode; +} + +export interface TableDndRegistryContextValue { + registerScope: (id: string, config: TableDndScopeConfig) => void; + unregisterScope: (id: string) => void; +} diff --git a/src/components/TableDndRoot/utils/mergeModifiers.ts b/src/components/TableDndRoot/utils/mergeModifiers.ts new file mode 100644 index 0000000..169bdce --- /dev/null +++ b/src/components/TableDndRoot/utils/mergeModifiers.ts @@ -0,0 +1,44 @@ +import type {Modifier} from '@dnd-kit/core'; + +import type {ReorderType, TableDndScopeConfig} from '../types'; + +import {getReorderType} from './reorderType'; + +function applyModifiers(modifiers: Modifier[], args: Parameters[0]) { + return modifiers.reduce>( + (transform, modifier) => modifier({...args, transform}), + args.transform, + ); +} + +export function createMergedModifiers(scopes: TableDndScopeConfig[]): Modifier[] { + const modifiersByType = scopes.reduce>>( + (acc, scope) => { + if (!scope.modifiers?.length) { + return acc; + } + + return {...acc, [scope.type]: scope.modifiers}; + }, + {}, + ); + + const scopeTypes = Object.keys(modifiersByType) as ReorderType[]; + + if (scopeTypes.length === 0) { + return []; + } + + return [ + (args) => { + const type = getReorderType(args.active); + const modifiers = type ? modifiersByType[type] : undefined; + + if (!modifiers?.length) { + return args.transform; + } + + return applyModifiers(modifiers, args); + }, + ]; +} diff --git a/src/components/TableDndRoot/utils/reorderType.ts b/src/components/TableDndRoot/utils/reorderType.ts new file mode 100644 index 0000000..b7d9671 --- /dev/null +++ b/src/components/TableDndRoot/utils/reorderType.ts @@ -0,0 +1,11 @@ +import type {Active, Over} from '@dnd-kit/core'; + +import type {ReorderType} from '../types'; + +export function getReorderType(active: Active | null | undefined): ReorderType | undefined { + return active?.data?.current?.reorderType as ReorderType | undefined; +} + +export function getOverReorderType(over: Over | null | undefined): ReorderType | undefined { + return over?.data?.current?.reorderType as ReorderType | undefined; +} diff --git a/src/components/TableDndRoot/utils/sortableIds.ts b/src/components/TableDndRoot/utils/sortableIds.ts new file mode 100644 index 0000000..08366f4 --- /dev/null +++ b/src/components/TableDndRoot/utils/sortableIds.ts @@ -0,0 +1,26 @@ +const ROW_PREFIX = 'row:'; +const COLUMN_PREFIX = 'column:'; + +export function toRowSortableId(id: string) { + return `${ROW_PREFIX}${id}`; +} + +export function toColumnSortableId(id: string) { + return `${COLUMN_PREFIX}${id}`; +} + +export function fromRowSortableId(sortableId: string) { + return sortableId.slice(ROW_PREFIX.length); +} + +export function fromColumnSortableId(sortableId: string) { + return sortableId.slice(COLUMN_PREFIX.length); +} + +export function isRowSortableId(sortableId: string) { + return sortableId.startsWith(ROW_PREFIX); +} + +export function isColumnSortableId(sortableId: string) { + return sortableId.startsWith(COLUMN_PREFIX); +} diff --git a/src/components/TableDndRoot/utils/tableCollisionDetection.ts b/src/components/TableDndRoot/utils/tableCollisionDetection.ts new file mode 100644 index 0000000..9cad7e8 --- /dev/null +++ b/src/components/TableDndRoot/utils/tableCollisionDetection.ts @@ -0,0 +1,18 @@ +import type {CollisionDetection} from '@dnd-kit/core'; +import {closestCenter, rectIntersection} from '@dnd-kit/core'; + +import {REORDER_TYPE_COLUMN} from '../constants'; + +export function tableCollisionDetection( + args: Parameters[0], +): ReturnType { + const type = args.active.data.current?.reorderType; + const containers = args.droppableContainers.filter( + (container) => container.data.current?.reorderType === type, + ); + const filteredArgs = {...args, droppableContainers: containers}; + + return type === REORDER_TYPE_COLUMN + ? closestCenter(filteredArgs) + : rectIntersection(filteredArgs); +} diff --git a/src/constants/dragHandleColumn.tsx b/src/constants/dragHandleColumn.tsx index d817075..9b5ee92 100644 --- a/src/constants/dragHandleColumn.tsx +++ b/src/constants/dragHandleColumn.tsx @@ -7,6 +7,7 @@ export const dragHandleColumn: ColumnDef = { cell: ({row}) => , size: 32, minSize: 32, + enableColumnReordering: false, meta: { hideInSettings: true, }, diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 30440fd..5b30771 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -1,8 +1,16 @@ import * as React from 'react'; -import {useDndMonitor} from '@dnd-kit/core'; +import type { + DragCancelEvent, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, +} from '@dnd-kit/core'; import type {SortableListDragResult} from '../components'; +import {REORDER_TYPE_ROW, fromRowSortableId, getReorderType} from '../components/TableDndRoot'; +import type {TableDndScopeHandlers} from '../components/TableDndRoot'; export interface UseSortableListParams { items: string[]; @@ -27,6 +35,14 @@ export const useSortableList = ({ const [isChildMode, setIsChildMode] = React.useState(false); const [isNextChildMode, setIsNextChildMode] = React.useState(false); + const isParentModeRef = React.useRef(isParentMode); + const isChildModeRef = React.useRef(isChildMode); + const isNextChildModeRef = React.useRef(isNextChildMode); + + isParentModeRef.current = isParentMode; + isChildModeRef.current = isChildMode; + isNextChildModeRef.current = isNextChildMode; + const itemIndexMap = React.useMemo(() => { const map = new Map(); @@ -37,101 +53,137 @@ export const useSortableList = ({ return map; }, [items]); - const getItemIndex = (item?: string | null) => { - if (typeof item !== 'undefined' && item !== null) { - return itemIndexMap.get(item) ?? -1; - } + const getItemIndex = React.useCallback( + (item?: string | null) => { + if (typeof item !== 'undefined' && item !== null) { + return itemIndexMap.get(item) ?? -1; + } - return -1; - }; + return -1; + }, + [itemIndexMap], + ); - const resetState = () => { + const resetState = React.useCallback(() => { setActiveItemKey(null); setTargetItemIndex(-1); setIsParentMode(false); setIsChildMode(false); setIsNextChildMode(false); - }; + }, []); - const getTargetItemKey = (activeId: string, overId?: string) => { - if (overId) { - const overItemIndex = getItemIndex(overId); - const activeItemIndex = getItemIndex(activeId); + const getTargetItemKey = React.useCallback( + (activeId: string, overId?: string) => { + if (overId) { + const overItemIndex = getItemIndex(overId); + const activeItemIndex = getItemIndex(activeId); - if (overItemIndex <= activeItemIndex) { - return items[overItemIndex - 1]; + if (overItemIndex <= activeItemIndex) { + return items[overItemIndex - 1]; + } } - } - - return overId; - }; - - useDndMonitor({ - onDragStart: (event) => { - setActiveItemKey(event.active.id as string); - onDragStart?.(event.active.id as string); - document.body.style.setProperty('cursor', 'grabbing'); + return overId; }, - onDragMove: (event) => { - if (enableNesting) { + [getItemIndex, items], + ); + + const handlers = React.useMemo( + () => ({ + onDragStart: (event: DragStartEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_ROW) { + return; + } + + const activeId = fromRowSortableId(event.active.id as string); + + setActiveItemKey(activeId); + onDragStart?.(activeId); + document.body.style.setProperty('cursor', 'grabbing'); + }, + onDragMove: (event: DragMoveEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_ROW || !enableNesting) { + return; + } + setIsParentMode(event.delta.x < -childModeOffset); setIsChildMode(event.delta.x > childModeOffset); - setIsNextChildMode( event.delta.x > nextChildModeOffset && event.delta.x <= childModeOffset, ); - } - }, - onDragOver: (event) => { - const targetItemKey = getTargetItemKey( - event.active.id as string, - event.over?.id as string, - ); - - setTargetItemIndex( - targetItemKey && targetItemKey !== event.active.id - ? getItemIndex(targetItemKey) - : -1, - ); - }, - onDragEnd: (event) => { - document.body.style.setProperty('cursor', 'default'); + }, + onDragOver: (event: DragOverEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_ROW) { + return; + } + + const activeId = fromRowSortableId(event.active.id as string); + const overId = event.over ? fromRowSortableId(event.over.id as string) : undefined; + const targetItemKey = getTargetItemKey(activeId, overId); + + setTargetItemIndex( + targetItemKey && targetItemKey !== activeId ? getItemIndex(targetItemKey) : -1, + ); + }, + onDragEnd: (event: DragEndEvent) => { + if (getReorderType(event.active) !== REORDER_TYPE_ROW) { + return; + } - if (!event.over) { - resetState(); - return; - } + document.body.style.setProperty('cursor', 'default'); - const draggedItemKey = event.active.id as string; - const targetItemKey = getTargetItemKey(draggedItemKey, event.over.id as string); + if (!event.over) { + resetState(); + return; + } - if (targetItemKey === draggedItemKey) { - resetState(); - return; - } - - if (isChildMode) { - onDragEnd?.({ - draggedItemKey, - targetItemKey, - enableNesting, - }); - } else { - onDragEnd?.({ + const draggedItemKey = fromRowSortableId(event.active.id as string); + const targetItemKey = getTargetItemKey( draggedItemKey, - baseItemKey: targetItemKey, - baseNextItemKey: items[targetItemKey ? getItemIndex(targetItemKey) + 1 : 0], - enableNesting, - nextChild: isNextChildMode, - pullFromParent: isParentMode, - }); - } + fromRowSortableId(event.over.id as string), + ); - resetState(); - }, - onDragCancel: resetState, - }); + if (targetItemKey === draggedItemKey) { + resetState(); + return; + } + + if (isChildModeRef.current) { + onDragEnd?.({ + draggedItemKey, + targetItemKey, + enableNesting, + }); + } else { + onDragEnd?.({ + draggedItemKey, + baseItemKey: targetItemKey, + baseNextItemKey: items[targetItemKey ? getItemIndex(targetItemKey) + 1 : 0], + enableNesting, + nextChild: isNextChildModeRef.current, + pullFromParent: isParentModeRef.current, + }); + } + + resetState(); + }, + onDragCancel: (_event: DragCancelEvent) => { + document.body.style.setProperty('cursor', 'default'); + resetState(); + }, + }), + [ + childModeOffset, + enableNesting, + getItemIndex, + getTargetItemKey, + items, + nextChildModeOffset, + onDragEnd, + onDragStart, + resetState, + ], + ); return { activeItemKey, @@ -140,5 +192,6 @@ export const useSortableList = ({ isParentMode, isChildMode, isNextChildMode, + handlers, }; };