diff --git a/README-ru.md b/README-ru.md index b018a8b..5c10e37 100644 --- a/README-ru.md +++ b/README-ru.md @@ -392,6 +392,102 @@ const ReorderingExample = () => { }; ``` +### Переупорядочивание столбцов + +Оберните таблицу в `ColumnReorderingProvider`, чтобы включить перетаскивание столбцов за их заголовки. + +```tsx +import {ColumnReorderingProvider} from '@gravity-ui/table'; + +const columns: ColumnDef[] = [ + {accessorKey: 'name', header: 'Name', size: 100}, + {accessorKey: 'age', header: 'Age', size: 100}, +]; + +const ColumnReorderingExample = () => { + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + }); + + return ( + + + + ); +}; +``` + +Если вы управляете `columnOrder` самостоятельно (например, чтобы сохранять его), передайте `onReorder` и примените полученный порядок: + +```tsx +const [columnOrder, setColumnOrder] = React.useState([]); + +const table = useTable({ + columns, + data, + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, +}); + +return ( + setColumnOrder(columnOrder)} + > +
+ +); +``` + +Во время перетаскивания: + +- плавающее превью столбца (его заголовок и первые строки) следует за курсором; +- перетаскиваемый столбец становится полупрозрачным; +- в месте вставки рисуется синяя линия; + +```tsx + } +> +
+ +``` + +CSS API: + +| CSS-переменная | Значение по умолчанию | Описание | +| -------------------------------------------- | ----------------------------- | ------------------------------------- | +| `--gt-table-reordering-insertion-line-color` | `#4d8bff` | Цвет линии вставки | +| `--gt-table-reordering-insertion-line-width` | `2px` | Толщина линии вставки | +| `--gt-table-reordering-dragged-opacity` | `0.4` | Прозрачность перетаскиваемого столбца | +| `--gt-table-drag-overlay-background` | `#fff` | Фон превью | +| `--gt-table-drag-overlay-shadow` | `0 3px 12px rgba(0,0,0,0.15)` | Тень превью | +| `--gt-table-drag-overlay-border-radius` | `6px` | Скругление превью | + +Чтобы запретить переупорядочивание конкретного столбца, задайте `enableColumnReordering: false` в его определении. Плейсхолдерные (сгруппированные) столбцы перетаскивать нельзя. Параметр `activationDistance` (по умолчанию `8`) задаёт, на сколько должен сдвинуться курсор перед началом перетаскивания, что сохраняет работу кликов по заголовку (например, сортировки). + +Закреплённые (pinned) столбцы тоже можно переупорядочивать, но только внутри своей группы: столбец перемещается в пределах левой закреплённой группы, правой закреплённой группы или центральной (незакреплённой) группы — перетаскиванием он никогда не пересекает границу закрепления. + +```tsx + { + if (pinned) { + setColumnPinning(columnPinning); + } else { + setColumnOrder(columnOrder); + } + }} +> +
+ +``` + ### Виртуализация Если необходимо настроить контейнер сетки в качестве элемента прокрутки, используйте виртуализацию. Обязательно задайте контейнеру фиксированную высоту, иначе виртуализация работать не будет. diff --git a/README.md b/README.md index 054480c..ae004f1 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,102 @@ const ReorderingExample = () => { }; ``` +### Column reordering + +Wrap the table with `ColumnReorderingProvider` to enable drag-and-drop reordering of columns by their headers. + +```tsx +import {ColumnReorderingProvider} from '@gravity-ui/table'; + +const columns: ColumnDef[] = [ + {accessorKey: 'name', header: 'Name', size: 100}, + {accessorKey: 'age', header: 'Age', size: 100}, +]; + +const ColumnReorderingExample = () => { + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + }); + + return ( + +
+ + ); +}; +``` + +If you control `columnOrder` yourself (e.g. to persist it), pass `onReorder` and apply the resulting order: + +```tsx +const [columnOrder, setColumnOrder] = React.useState([]); + +const table = useTable({ + columns, + data, + state: {columnOrder}, + onColumnOrderChange: setColumnOrder, +}); + +return ( + setColumnOrder(columnOrder)} + > +
+ +); +``` + +CSS API: + +| CSS variable | Default | Description | +| -------------------------------------------- | ----------------------------- | -------------------------------- | +| `--gt-table-reordering-insertion-line-color` | `#4d8bff` | Color of the drop insertion line | +| `--gt-table-reordering-insertion-line-width` | `2px` | Width of the drop insertion line | +| `--gt-table-reordering-dragged-opacity` | `0.4` | Opacity of the dragged column | +| `--gt-table-drag-overlay-background` | `#fff` | Drag preview background | +| `--gt-table-drag-overlay-shadow` | `0 3px 12px rgba(0,0,0,0.15)` | Drag preview box-shadow | +| `--gt-table-drag-overlay-border-radius` | `6px` | Drag preview border radius | + +To forbid reordering a specific column, set `enableColumnReordering: false` in its column definition. Placeholder (grouped) columns are not draggable. Use `activationDistance` (default `8`) to tune how far the pointer must move before a drag starts, which keeps header clicks (like sorting) working. + +Pinned columns can be reordered too, but only among themselves: a column can be moved within the left-pinned group, the right-pinned group, or the center (non-pinned) group — it never crosses a pin boundary by dragging. + +```tsx + { + if (pinned) { + setColumnPinning(columnPinning); + } else { + setColumnOrder(columnOrder); + } + }} +> +
+ +``` + +While dragging: + +- a floating preview of the column (its header plus the first rows) follows the pointer in a drag overlay; +- the dragged column becomes semi-transparent; +- a blue insertion line is drawn where the column will be dropped; + +```tsx + } +> +
+ +``` + ### Virtualization Use if you want to use grid container as the scroll element (if you want to use window see window virtualization section). Be sure to set a fixed height on the container; otherwise, virtualization will not work. diff --git a/src/components/BaseDraggableHeaderCell/BaseDraggableHeaderCell.tsx b/src/components/BaseDraggableHeaderCell/BaseDraggableHeaderCell.tsx new file mode 100644 index 0000000..3ebc564 --- /dev/null +++ b/src/components/BaseDraggableHeaderCell/BaseDraggableHeaderCell.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import type {Header} from '../../types/base'; +import type {BaseHeaderCellProps} from '../BaseHeaderCell'; +import {BaseHeaderCell} from '../BaseHeaderCell'; +import {b} from '../BaseTable/BaseTable.classname'; +import {ColumnReorderingContext} from '../ColumnReorderingContext'; + +export interface BaseDraggableHeaderCellProps + extends BaseHeaderCellProps {} + +export const BaseDraggableHeaderCell = ({ + attributes: attributesProp, + header, + ...restProps +}: BaseDraggableHeaderCellProps) => { + const {activeColumnId, useSortable} = React.useContext(ColumnReorderingContext) ?? {}; + + const {setNodeRef, listeners, isDragging = false} = useSortable?.({id: header.column.id}) ?? {}; + + const isDragActive = Boolean(activeColumnId); + + const getAttributes = React.useCallback( + (cellHeader: Header, parentHeader?: Header) => { + const resolvedAttributes = + typeof attributesProp === 'function' + ? attributesProp(cellHeader, parentHeader) + : attributesProp; + + const handlePointerDown = (event: React.PointerEvent) => { + resolvedAttributes?.onPointerDown?.(event); + + if (event.defaultPrevented) { + return; + } + + if ((event.target as HTMLElement).closest(`.${b('resize-handle')}`)) { + return; + } + + listeners?.onPointerDown?.(event); + }; + + return { + ...resolvedAttributes, + ...listeners, + onPointerDown: handlePointerDown, + 'data-draggable': true, + 'data-dragging': isDragging, + 'data-drag-active': isDragActive, + }; + }, + [attributesProp, isDragActive, isDragging, listeners], + ); + + return ( + + ); +}; + +BaseDraggableHeaderCell.displayName = 'BaseDraggableHeaderCell'; diff --git a/src/components/BaseDraggableHeaderCell/index.ts b/src/components/BaseDraggableHeaderCell/index.ts new file mode 100644 index 0000000..d99c2a6 --- /dev/null +++ b/src/components/BaseDraggableHeaderCell/index.ts @@ -0,0 +1 @@ +export * from './BaseDraggableHeaderCell'; diff --git a/src/components/BaseHeaderCell/BaseHeaderCell.tsx b/src/components/BaseHeaderCell/BaseHeaderCell.tsx index 8b5eb8d..1b087f9 100644 --- a/src/components/BaseHeaderCell/BaseHeaderCell.tsx +++ b/src/components/BaseHeaderCell/BaseHeaderCell.tsx @@ -35,81 +35,93 @@ export interface BaseHeaderCellProps { ) => React.ThHTMLAttributes); } -export const BaseHeaderCell = ({ - className: classNameProp, - header, - parentHeader, - renderHeaderCellContent, - renderResizeHandle, - renderSortIndicator, - resizeHandleClassName, - sortIndicatorClassName, - attributes: attributesProp, -}: BaseHeaderCellProps) => { - const attributes = - typeof attributesProp === 'function' - ? attributesProp(header, parentHeader) - : attributesProp; +export const BaseHeaderCell = React.forwardRef( + ( + { + className: classNameProp, + header, + parentHeader, + renderHeaderCellContent, + renderResizeHandle, + renderSortIndicator, + resizeHandleClassName, + sortIndicatorClassName, + attributes: attributesProp, + }: BaseHeaderCellProps, + ref: React.Ref, + ) => { + const attributes = + typeof attributesProp === 'function' + ? attributesProp(header, parentHeader) + : attributesProp; - const className = - typeof classNameProp === 'function' ? classNameProp(header, parentHeader) : classNameProp; + const className = + typeof classNameProp === 'function' + ? classNameProp(header, parentHeader) + : classNameProp; - const rowSpan = header.isPlaceholder ? header.getLeafHeaders().length : 1; + const rowSpan = header.isPlaceholder ? header.getLeafHeaders().length : 1; - const renderContent = () => { - if (renderHeaderCellContent) { - return renderHeaderCellContent({ - header, - }); - } + const renderContent = () => { + if (renderHeaderCellContent) { + return renderHeaderCellContent({ + header, + }); + } - return ( - - {header.column.getCanSort() ? ( - - {flexRender(header.column.columnDef.header, header.getContext())}{' '} - {renderSortIndicator ? ( - renderSortIndicator({ - className: b('sort-indicator', sortIndicatorClassName), + return ( + + {header.column.getCanSort() ? ( + + {flexRender(header.column.columnDef.header, header.getContext())}{' '} + {renderSortIndicator ? ( + renderSortIndicator({ + className: b('sort-indicator', sortIndicatorClassName), + header, + }) + ) : ( + + )} + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} + {header.column.getCanResize() && + (renderResizeHandle ? ( + renderResizeHandle({ + className: b('resize-handle', resizeHandleClassName), header, }) ) : ( - - )} - - ) : ( - flexRender(header.column.columnDef.header, header.getContext()) - )} - {header.column.getCanResize() && - (renderResizeHandle ? ( - renderResizeHandle({ - className: b('resize-handle', resizeHandleClassName), - header, - }) - ) : ( - - ))} - + ))} + + ); + }; + + return ( + ); - }; + }, +) as (( + props: BaseHeaderCellProps & {ref?: React.Ref}, +) => React.ReactElement) & {displayName: string}; - return ( - - ); -}; +BaseHeaderCell.displayName = 'BaseHeaderCell'; diff --git a/src/components/BaseHeaderRow/BaseHeaderRow.tsx b/src/components/BaseHeaderRow/BaseHeaderRow.tsx index 8bbe55e..cbd3ddc 100644 --- a/src/components/BaseHeaderRow/BaseHeaderRow.tsx +++ b/src/components/BaseHeaderRow/BaseHeaderRow.tsx @@ -1,11 +1,15 @@ -import type * as React from 'react'; +import * as React from 'react'; import type {Header, HeaderGroup} from '../../types/base'; import {shouldRenderHeaderCell} from '../../utils'; +import {BaseDraggableHeaderCell} from '../BaseDraggableHeaderCell'; import type {BaseHeaderCellProps} from '../BaseHeaderCell'; import {BaseHeaderCell} from '../BaseHeaderCell'; import type {BaseResizeHandleProps} from '../BaseResizeHandle'; import {b} from '../BaseTable/BaseTable.classname'; +import {ColumnReorderingContext} from '../ColumnReorderingContext'; + +import {getCanReorderHeader} from './utils/getCanReorderHeader'; export interface BaseHeaderRowProps extends Omit, 'className'> { @@ -43,6 +47,8 @@ export const BaseHeaderRow = ({ cellAttributes, ...restProps }: BaseHeaderRowProps) => { + const columnReordering = React.useContext(ColumnReorderingContext); + const attributes = typeof attributesProp === 'function' ? attributesProp(headerGroup, parentHeaderGroup) @@ -60,8 +66,17 @@ export const BaseHeaderRow = ({ (item) => header.column.id === item.column.id, ); - return shouldRenderHeaderCell(header, parentHeader) ? ( - } @@ -73,7 +88,7 @@ export const BaseHeaderRow = ({ sortIndicatorClassName={sortIndicatorClassName} attributes={cellAttributes} /> - ) : null; + ); })} ); diff --git a/src/components/BaseHeaderRow/utils/getCanReorderHeader.ts b/src/components/BaseHeaderRow/utils/getCanReorderHeader.ts new file mode 100644 index 0000000..aba561a --- /dev/null +++ b/src/components/BaseHeaderRow/utils/getCanReorderHeader.ts @@ -0,0 +1,9 @@ +import type {Header} from '../../../types/base'; + +export function getCanReorderHeader(header: Header) { + return ( + !header.isPlaceholder && + header.subHeaders.length === 0 && + header.column.columnDef.enableColumnReordering !== false + ); +} diff --git a/src/components/BaseTable/__stories__/BaseTable.stories.tsx b/src/components/BaseTable/__stories__/BaseTable.stories.tsx index 4d6c6b1..5925e1b 100644 --- a/src/components/BaseTable/__stories__/BaseTable.stories.tsx +++ b/src/components/BaseTable/__stories__/BaseTable.stories.tsx @@ -3,6 +3,8 @@ import type {Meta, StoryObj} from '@storybook/react'; import {BaseTable} from '../index'; import {ColumnPinningStory} from './stories/ColumnPinningStory'; +import {ColumnReorderingStory} from './stories/ColumnReorderingStory'; +import {ColumnReorderingWithPinningStory} from './stories/ColumnReorderingWithPinningStory'; import {CustomRowStory} from './stories/CustomRowStory'; import {DefaultStory} from './stories/DefaultStory'; import {EmptyContentStory} from './stories/EmptyContentStory'; @@ -84,6 +86,14 @@ export const ReorderingTree: StoryObj = { render: ReorderingTreeStory, }; +export const ColumnReordering: StoryObj = { + render: ColumnReorderingStory, +}; + +export const ColumnReorderingWithPinning: StoryObj = { + render: ColumnReorderingWithPinningStory, +}; + export const Virtualization: StoryObj = { render: VirtualizationStory, }; diff --git a/src/components/BaseTable/__stories__/stories/ColumnReorderingStory.tsx b/src/components/BaseTable/__stories__/stories/ColumnReorderingStory.tsx new file mode 100644 index 0000000..6d57c3a --- /dev/null +++ b/src/components/BaseTable/__stories__/stories/ColumnReorderingStory.tsx @@ -0,0 +1,44 @@ +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import {BaseTable} from '../../BaseTable'; +import type {Item} from '../types'; +import {generateData} from '../utils'; + +const columns: ColumnDef[] = [ + {accessorKey: 'name', header: 'Name', size: 200}, + {accessorKey: 'age', header: 'Age', size: 200}, + {accessorKey: 'status', header: 'Status', size: 200}, + { + id: 'name-age', + accessorFn: (item) => `${item.name}: ${item.age}`, + header: 'Name and age', + size: 200, + }, + { + id: 'age-status', + accessorFn: (item) => `${item.age} / ${item.status}`, + header: 'Age and status', + size: 200, + }, +]; + +const data = generateData(30); + +export const ColumnReorderingStory = () => { + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnResizing: true, + columnResizeMode: 'onChange', + }); + + return ( + +
+ +
+
+ ); +}; diff --git a/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts new file mode 100644 index 0000000..6f34b05 --- /dev/null +++ b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts @@ -0,0 +1,3 @@ +import {cn} from '../../../../utils'; + +export const cnColumnReorderingWithPinningStory = cn('column-reordering-with-pinning-story'); diff --git a/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.scss b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.scss new file mode 100644 index 0000000..42cae77 --- /dev/null +++ b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.scss @@ -0,0 +1,44 @@ +.column-reordering-with-pinning-story { + --default-border: 1px solid var(--g-color-line-generic); + + display: flex; + flex-direction: column; + row-gap: var(--g-spacing-3); + + &__hint { + color: var(--g-color-text-secondary); + } + + &__scroll { + width: 900px; + overflow: auto; + + border-inline: var(--default-border); + border-block-end: var(--default-border); + } + + &__table { + --gt-table-reordering-insertion-line-color: var(--g-color-line-info); + + th, + td { + border: var(--default-border); + + padding: var(--g-spacing-1); + + box-sizing: border-box; + + background: var(--g-color-base-background); + } + + .gt-table__header-cell_last-pinned-left, + .gt-table__cell_last-pinned-left { + box-shadow: var(--g-color-line-generic-active) -2px 0 2px -2px inset; + } + + .gt-table__header-cell_first-pinned-right, + .gt-table__cell_first-pinned-right { + box-shadow: var(--g-color-line-generic-active) 2px 0 2px -2px inset; + } + } +} diff --git a/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.tsx b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.tsx new file mode 100644 index 0000000..30ecbd5 --- /dev/null +++ b/src/components/BaseTable/__stories__/stories/ColumnReorderingWithPinningStory.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; + +import type {ColumnOrderState, ColumnPinningState} from '@tanstack/react-table'; + +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import {BaseTable} from '../../BaseTable'; +import type {Item} from '../types'; +import {generateData} from '../utils'; + +import {cnColumnReorderingWithPinningStory} from './ColumnReorderingWithPinningStory.classname'; + +import './ColumnReorderingWithPinningStory.scss'; + +const column = (def: ColumnDef & {size: number}): ColumnDef => ({ + ...def, + minSize: def.size, + maxSize: def.size, +}); + +const columns: ColumnDef[] = [ + column({accessorKey: 'name', header: 'Name', size: 120}), + column({accessorKey: 'age', header: 'Age', size: 110}), + column({accessorKey: 'status', header: 'Status', size: 150}), + column({ + id: 'name-age', + accessorFn: (item) => `${item.name}: ${item.age}`, + header: 'Name + age', + size: 160, + }), + column({ + id: 'name-upper', + accessorFn: (item) => item.name.toUpperCase(), + header: 'Name upper', + size: 170, + }), + column({ + id: 'age-x2', + accessorFn: (item) => `${item.age * 2}`, + header: 'Age × 2', + size: 130, + }), + column({ + id: 'status-copy', + accessorFn: (item) => item.status ?? '', + header: 'Status copy', + size: 160, + }), + column({ + id: 'name-status', + accessorFn: (item) => `${item.name} (${item.status})`, + header: 'Name + status', + size: 150, + }), + column({ + id: 'age-status', + accessorFn: (item) => `${item.age} / ${item.status}`, + header: 'Age + status', + size: 150, + }), +]; + +const data = generateData(30); + +export const ColumnReorderingWithPinningStory = () => { + const [columnPinning, setColumnPinning] = React.useState({ + left: ['name', 'age'], + right: ['name-status', 'age-status'], + }); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnPinning: true, + onColumnPinningChange: setColumnPinning, + onColumnOrderChange: setColumnOrder, + state: {columnPinning, columnOrder}, + }); + + return ( +
+
+ Drag the left pinned pair (Name / Age), the right pinned pair (Name + status / Age + + status), or the center pair (Status / Name + age) to reorder within each group. +
+ { + if (result.pinned) { + setColumnPinning(result.columnPinning); + } else { + setColumnOrder(result.columnOrder); + } + }} + > +
+ +
+
+
+ ); +}; diff --git a/src/components/ColumnDragOverlay/ColumnDragOverlay.classname.ts b/src/components/ColumnDragOverlay/ColumnDragOverlay.classname.ts new file mode 100644 index 0000000..72b4c17 --- /dev/null +++ b/src/components/ColumnDragOverlay/ColumnDragOverlay.classname.ts @@ -0,0 +1,3 @@ +import {block} from '../../utils'; + +export const b = block('drag-overlay'); diff --git a/src/components/ColumnDragOverlay/ColumnDragOverlay.scss b/src/components/ColumnDragOverlay/ColumnDragOverlay.scss new file mode 100644 index 0000000..cb0d9aa --- /dev/null +++ b/src/components/ColumnDragOverlay/ColumnDragOverlay.scss @@ -0,0 +1,13 @@ +@use '../variables'; + +$block: '.#{variables.$ns}drag-overlay'; + +#{$block} { + overflow: hidden; + + cursor: grabbing; + + background: var(--gt-table-drag-overlay-background, #fff); + box-shadow: var(--gt-table-drag-overlay-shadow, 0 3px 12px rgba(0, 0, 0, 0.15)); + border-radius: var(--gt-table-drag-overlay-border-radius, 6px); +} diff --git a/src/components/ColumnDragOverlay/ColumnDragOverlay.tsx b/src/components/ColumnDragOverlay/ColumnDragOverlay.tsx new file mode 100644 index 0000000..2899efc --- /dev/null +++ b/src/components/ColumnDragOverlay/ColumnDragOverlay.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; + +import {flexRender} from '@tanstack/react-table'; +import type {Table} from '@tanstack/react-table'; + +import {getCellClassModes, getCellStyles, getHeaderCellClassModes} from '../../utils'; +import {b as cnTable} from '../BaseTable/BaseTable.classname'; +import type { + ColumnReorderingProviderProps, + OverlayClassNames, +} from '../ColumnReorderingProvider/types'; + +import {b} from './ColumnDragOverlay.classname'; +import {overlayCellStyles} from './utils/overlayCellStyles'; + +import './ColumnDragOverlay.scss'; + +export interface ColumnDragOverlayProps { + table: Table; + /** Id of the column currently being dragged, or `null` when nothing is dragged */ + activeColumnId: string | null; + /** Class names captured from the real table DOM, used to mirror its look in the overlay */ + overlayClassNames: OverlayClassNames | null; + /** Number of leading rows rendered below the header in the preview */ + dragOverlayRowCount?: number; + /** Custom overlay renderer; when provided it fully replaces the default preview */ + renderDragOverlay?: ColumnReorderingProviderProps['renderDragOverlay']; +} + +export function ColumnDragOverlay({ + table, + activeColumnId, + overlayClassNames, + dragOverlayRowCount, + renderDragOverlay, +}: ColumnDragOverlayProps) { + if (!activeColumnId) { + return null; + } + + if (renderDragOverlay) { + return {renderDragOverlay({columnId: activeColumnId})}; + } + + const activeHeader = table + .getFlatHeaders() + .find((header) => header.column.id === activeColumnId); + + if (!activeHeader) { + return null; + } + + const allRows = table.getRowModel().rows; + const previewRows = + typeof dragOverlayRowCount === 'number' + ? allRows.slice(0, Math.max(0, dragOverlayRowCount)) + : allRows; + + const cn = overlayClassNames ?? {}; + + return ( +
1 ? header.colSpan : undefined} + rowSpan={rowSpan > 1 ? rowSpan : undefined} + aria-sort={getAriaSort(header.column.getIsSorted())} + aria-colindex={getHeaderCellAriaColIndex(header)} + {...attributes} + style={getCellStyles(header, attributes?.style)} + > + {renderContent()} + 1 ? header.colSpan : undefined} - rowSpan={rowSpan > 1 ? rowSpan : undefined} - aria-sort={getAriaSort(header.column.getIsSorted())} - aria-colindex={getHeaderCellAriaColIndex(header)} - {...attributes} - style={getCellStyles(header, attributes?.style)} - > - {renderContent()} -
+ + + + + + + {previewRows.map((row) => { + const cell = row + .getVisibleCells() + .find((item) => item.column.id === activeColumnId); + + if (!cell) { + return null; + } + + return ( + + + + ); + })} + +
+ {flexRender( + activeHeader.column.columnDef.header, + activeHeader.getContext(), + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); +} diff --git a/src/components/ColumnDragOverlay/index.ts b/src/components/ColumnDragOverlay/index.ts new file mode 100644 index 0000000..9e1bd66 --- /dev/null +++ b/src/components/ColumnDragOverlay/index.ts @@ -0,0 +1,2 @@ +export {ColumnDragOverlay} from './ColumnDragOverlay'; +export type {ColumnDragOverlayProps} from './ColumnDragOverlay'; diff --git a/src/components/ColumnDragOverlay/utils/overlayCellStyles.ts b/src/components/ColumnDragOverlay/utils/overlayCellStyles.ts new file mode 100644 index 0000000..55dba2f --- /dev/null +++ b/src/components/ColumnDragOverlay/utils/overlayCellStyles.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export function overlayCellStyles(style: React.CSSProperties | undefined): React.CSSProperties { + return { + ...style, + position: 'static', + left: undefined, + right: undefined, + zIndex: undefined, + }; +} diff --git a/src/components/ColumnReorderingContext/ColumnReorderingContext.tsx b/src/components/ColumnReorderingContext/ColumnReorderingContext.tsx new file mode 100644 index 0000000..150b07c --- /dev/null +++ b/src/components/ColumnReorderingContext/ColumnReorderingContext.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import type {useSortable} from '@dnd-kit/sortable'; + +export interface ColumnReorderingContextValue { + /** Id of the column currently being dragged */ + activeColumnId: string | null; + /** Id of the column the dragged column is currently hovered over */ + targetColumnId: string | null; + useSortable?: typeof useSortable; +} + +export const ColumnReorderingContext = React.createContext< + ColumnReorderingContextValue | undefined +>(undefined); diff --git a/src/components/ColumnReorderingContext/index.ts b/src/components/ColumnReorderingContext/index.ts new file mode 100644 index 0000000..019256d --- /dev/null +++ b/src/components/ColumnReorderingContext/index.ts @@ -0,0 +1 @@ +export * from './ColumnReorderingContext'; diff --git a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.classname.ts b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.classname.ts new file mode 100644 index 0000000..67bff9d --- /dev/null +++ b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.classname.ts @@ -0,0 +1,3 @@ +import {block} from '../../utils'; + +export const b = block('reordering-scope'); diff --git a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss new file mode 100644 index 0000000..ef53819 --- /dev/null +++ b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.scss @@ -0,0 +1,19 @@ +@use '../variables'; + +$block: '.#{variables.$ns}reordering-scope'; +$tableBlock: '.#{variables.$ns}table'; +$headerCellBlock: '#{$tableBlock}__header-cell'; + +#{$block} { + display: contents; +} + +#{$headerCellBlock}[data-draggable='true'] { + cursor: grab; + touch-action: none; + user-select: none; + + &[data-drag-active='true'] { + cursor: grabbing; + } +} diff --git a/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx new file mode 100644 index 0000000..13f6eb5 --- /dev/null +++ b/src/components/ColumnReorderingProvider/ColumnReorderingProvider.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; + +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import {SortableContext, useSortable} from '@dnd-kit/sortable'; + +import type {ColumnDef} from '../../types/base'; +import {ColumnDragOverlay} from '../ColumnDragOverlay'; +import type {ColumnReorderingContextValue} from '../ColumnReorderingContext'; +import {ColumnReorderingContext} from '../ColumnReorderingContext'; + +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'; +import type {ColumnReorderingProviderProps} from './types'; +import {restrictToHorizontalAxis} from './utils/restrictToHorizontalAxis'; + +import './ColumnReorderingProvider.scss'; + +export const ColumnReorderingProvider = ({ + table, + children, + activationDistance = 8, + autoScroll = true, + dndModifiers, + dragOverlayRowCount, + renderDragOverlay, + onReorder, +}: ColumnReorderingProviderProps) => { + const {scopeRef, scopeClassName, wrapperClassName} = useScope(); + + const { + activeColumnId, + targetColumnId, + overlayClassNames, + handleDragStart, + handleDragOver, + handleDragEnd, + resetState, + } = useColumnDrag({table, scopeRef, autoScroll, onReorder}); + + const contextValue = React.useMemo( + () => ({activeColumnId, targetColumnId, useSortable}), + [activeColumnId, targetColumnId], + ); + + const reorderingStyles = useReorderingStyles({ + 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); + + return ( + + +
+ {children} +
+ + + +
+ {reorderingStyles ? : null} +
+ ); +}; diff --git a/src/components/ColumnReorderingProvider/constants/autoScroll.ts b/src/components/ColumnReorderingProvider/constants/autoScroll.ts new file mode 100644 index 0000000..4f0ec33 --- /dev/null +++ b/src/components/ColumnReorderingProvider/constants/autoScroll.ts @@ -0,0 +1,4 @@ +export const AUTO_SCROLL_EDGE_SIZE = 48; +export const AUTO_SCROLL_MAX_SPEED = 16; + +export const autoScrollConfig = {threshold: {x: 0, y: 0.2}}; diff --git a/src/components/ColumnReorderingProvider/constants/measuring.ts b/src/components/ColumnReorderingProvider/constants/measuring.ts new file mode 100644 index 0000000..a57183c --- /dev/null +++ b/src/components/ColumnReorderingProvider/constants/measuring.ts @@ -0,0 +1,8 @@ +import type {MeasuringConfiguration} from '@dnd-kit/core'; +import {MeasuringStrategy} from '@dnd-kit/core'; + +export const measuring: MeasuringConfiguration = { + droppable: { + strategy: MeasuringStrategy.WhileDragging, + }, +}; diff --git a/src/components/ColumnReorderingProvider/hooks/useAutoScroll.ts b/src/components/ColumnReorderingProvider/hooks/useAutoScroll.ts new file mode 100644 index 0000000..819c4e6 --- /dev/null +++ b/src/components/ColumnReorderingProvider/hooks/useAutoScroll.ts @@ -0,0 +1,118 @@ +import * as React from 'react'; + +import type {DragStartEvent} from '@dnd-kit/core'; +import type {Table} from '@tanstack/react-table'; + +import {AUTO_SCROLL_EDGE_SIZE, AUTO_SCROLL_MAX_SPEED} from '../constants/autoScroll'; +import {findHorizontalScrollContainer} from '../utils/findHorizontalScrollContainer'; + +export interface UseAutoScrollProps { + table: Table; + scopeRef: React.RefObject; +} + +export function useAutoScroll({table, scopeRef}: UseAutoScrollProps) { + const scrollContainerRef = React.useRef(null); + const pointerXRef = React.useRef(null); + const rafRef = React.useRef(null); + + const getPinnedWidth = React.useCallback( + (position: 'left' | 'right') => { + const columns = + position === 'left' + ? table.getLeftVisibleLeafColumns() + : table.getRightVisibleLeafColumns(); + + return columns.reduce((width, column) => width + column.getSize(), 0); + }, + [table], + ); + + const handlePointerMove = React.useCallback((event: PointerEvent) => { + pointerXRef.current = event.clientX; + }, []); + + const autoScrollStep = React.useCallback(() => { + const container = scrollContainerRef.current; + + if (!container) { + rafRef.current = null; + return; + } + + const pointerX = pointerXRef.current; + + if (pointerX === null) { + rafRef.current = requestAnimationFrame(autoScrollStep); + return; + } + + const rect = container.getBoundingClientRect(); + const centerLeft = rect.left + getPinnedWidth('left'); + const centerRight = rect.right - getPinnedWidth('right'); + + let delta = 0; + + if (pointerX < centerLeft + AUTO_SCROLL_EDGE_SIZE) { + const intensity = Math.min( + 1, + (centerLeft + AUTO_SCROLL_EDGE_SIZE - pointerX) / AUTO_SCROLL_EDGE_SIZE, + ); + delta = -Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED); + } else if (pointerX > centerRight - AUTO_SCROLL_EDGE_SIZE) { + const intensity = Math.min( + 1, + (pointerX - (centerRight - AUTO_SCROLL_EDGE_SIZE)) / AUTO_SCROLL_EDGE_SIZE, + ); + delta = Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED); + } + + if (delta !== 0) { + container.scrollLeft += delta; + } + + rafRef.current = requestAnimationFrame(autoScrollStep); + }, [getPinnedWidth]); + + const stopAutoScroll = React.useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + + pointerXRef.current = null; + scrollContainerRef.current = null; + document.removeEventListener('pointermove', handlePointerMove); + }, [handlePointerMove]); + + const startAutoScroll = React.useCallback( + (event: DragStartEvent) => { + const tableEl = scopeRef.current?.querySelector('table') ?? null; + scrollContainerRef.current = findHorizontalScrollContainer(tableEl); + + if (!scrollContainerRef.current) { + return; + } + + const activator = event.activatorEvent; + pointerXRef.current = activator instanceof PointerEvent ? activator.clientX : null; + + document.addEventListener('pointermove', handlePointerMove); + rafRef.current = requestAnimationFrame(autoScrollStep); + }, + [scopeRef, handlePointerMove, autoScrollStep], + ); + + React.useEffect( + () => () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + + document.removeEventListener('pointermove', handlePointerMove); + }, + [handlePointerMove], + ); + + return {startAutoScroll, stopAutoScroll}; +} diff --git a/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts b/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts new file mode 100644 index 0000000..5893245 --- /dev/null +++ b/src/components/ColumnReorderingProvider/hooks/useColumnDrag.ts @@ -0,0 +1,167 @@ +import * as React from 'react'; + +import type {DragEndEvent, DragOverEvent, DragStartEvent} from '@dnd-kit/core'; +import {arrayMove} from '@dnd-kit/sortable'; +import type {ColumnPinningState, Table} from '@tanstack/react-table'; + +import {b} from '../../BaseTable/BaseTable.classname'; +import type {ColumnReorderResult, OverlayClassNames} from '../types'; +import {escapeClassName} from '../utils/escapeClassName'; +import {getColumnGroup} from '../utils/getColumnGroup'; +import {getCurrentColumnOrder} from '../utils/getCurrentColumnOrder'; +import {getElementClassName} from '../utils/getElementClassName'; + +import {useAutoScroll} from './useAutoScroll'; + +interface UseColumnDragParams { + table: Table; + scopeRef: React.RefObject; + autoScroll: boolean; + onReorder?: (result: ColumnReorderResult) => void; +} + +export function useColumnDrag({ + table, + scopeRef, + autoScroll, + onReorder, +}: UseColumnDragParams) { + const [activeColumnId, setActiveColumnId] = React.useState(null); + const [targetColumnId, setTargetColumnId] = React.useState(null); + const [overlayClassNames, setOverlayClassNames] = React.useState( + null, + ); + + 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; + + 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); + } + }; + + const handleDragOver = (event: DragOverEvent) => { + setTargetColumnId((event.over?.id as string) ?? null); + }; + + const resetState = () => { + 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); + } + }; + + return { + activeColumnId, + targetColumnId, + overlayClassNames, + handleDragStart, + handleDragOver, + handleDragEnd, + resetState, + }; +} diff --git a/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts b/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts new file mode 100644 index 0000000..bde32d2 --- /dev/null +++ b/src/components/ColumnReorderingProvider/hooks/useReorderingStyles.ts @@ -0,0 +1,69 @@ +import * as React from 'react'; + +import type {Table} from '@tanstack/react-table'; + +import {b} from '../../BaseTable/BaseTable.classname'; +import {escapeClassName} from '../utils/escapeClassName'; +import {getColumnGroup} from '../utils/getColumnGroup'; + +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) { + return null; + } + + const getColumnSelector = (columnId: string, suffix = '') => { + const cellClass = escapeClassName(`${b('cell')}_id_${columnId}`); + const headerCellClass = escapeClassName(`${b('header-cell')}_id_${columnId}`); + + return `.${scopeClassName} .${cellClass}${suffix}, .${scopeClassName} .${headerCellClass}${suffix}`; + }; + + const group = getColumnGroup(table, activeColumnId); + + const rules: string[] = []; + + const draggedOpacity = 'var(--gt-table-reordering-dragged-opacity, 0.4)'; + + if (group === 'center') { + rules.push(`${getColumnSelector(activeColumnId)} { opacity: ${draggedOpacity}; }`); + } else { + rules.push( + `${getColumnSelector(activeColumnId)} { color: color-mix(in srgb, currentColor calc(${draggedOpacity} * 100%), transparent); }`, + `${getColumnSelector(activeColumnId, ' :is(img, svg, picture, video, canvas)')} { opacity: ${draggedOpacity}; }`, + ); + } + + 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]); +} diff --git a/src/components/ColumnReorderingProvider/hooks/useScope.ts b/src/components/ColumnReorderingProvider/hooks/useScope.ts new file mode 100644 index 0000000..54a9cdd --- /dev/null +++ b/src/components/ColumnReorderingProvider/hooks/useScope.ts @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import {b} from '../ColumnReorderingProvider.classname'; + +let scopeCounter = 0; + +export function useScope() { + const scopeRef = React.useRef(null); + + const scopeClassNameRef = React.useRef(); + if (!scopeClassNameRef.current) { + scopeCounter += 1; + scopeClassNameRef.current = `${b()}-${scopeCounter}`; + } + + const scopeClassName = scopeClassNameRef.current; + + return {scopeRef, scopeClassName, wrapperClassName: b(null, scopeClassName)}; +} diff --git a/src/components/ColumnReorderingProvider/index.ts b/src/components/ColumnReorderingProvider/index.ts new file mode 100644 index 0000000..25043a8 --- /dev/null +++ b/src/components/ColumnReorderingProvider/index.ts @@ -0,0 +1,2 @@ +export {ColumnReorderingProvider} from './ColumnReorderingProvider'; +export type {ColumnReorderResult, ColumnReorderingProviderProps} from './types'; diff --git a/src/components/ColumnReorderingProvider/types.ts b/src/components/ColumnReorderingProvider/types.ts new file mode 100644 index 0000000..d2efd63 --- /dev/null +++ b/src/components/ColumnReorderingProvider/types.ts @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import type {Modifier} from '@dnd-kit/core'; +import type {ColumnPinningState, Table} from '@tanstack/react-table'; + +export interface OverlayClassNames { + table?: string; + thead?: string; + headerRow?: string; + headerCell?: string; + tbody?: string; + row?: string; + cell?: string; +} + +export interface ColumnReorderResult { + /** + * The resulting full leaf column order. Changes only when reordering non-pinned (center) columns; + * otherwise it equals the current order. + */ + columnOrder: string[]; + /** + * The resulting column pinning state. Changes only when reordering columns within a pinned group + * (`left`/`right`); otherwise it equals the current pinning state. + */ + columnPinning: ColumnPinningState; + /** + * The pin group the reorder happened in: `'left'`/`'right'` for pinned columns, `false` for center. + * Use this to know whether `columnOrder` or `columnPinning` changed. + */ + pinned: 'left' | 'right' | false; + /** Id of the dragged column */ + draggedColumnId: string; + /** Id of the column the dragged one was dropped on */ + targetColumnId: string; +} + +export interface ColumnReorderingProviderProps { + /** The table instance returned from the `useTable` hook */ + table: Table; + /** Children */ + children?: React.ReactNode; + /** Pointer movement (in px) required before a drag starts. Keeps header clicks (e.g. sorting) working. Default: `8` */ + activationDistance?: number; + /** + * Enables auto-scrolling of the nearest scrollable container while dragging near its edges. + * Passed through to the dnd-kit `DndContext`. Default: `true` + */ + autoScroll?: boolean; + /** A list of the dnd-kit modifiers. Defaults to restricting movement to the horizontal axis */ + dndModifiers?: Modifier[]; + /** Number of leading rows rendered (below the header) in the drag preview. Defaults to all rows in the table */ + dragOverlayRowCount?: number; + /** + * Renders the floating preview shown under the pointer while dragging a column. + * By default the column header together with all of its cells is rendered. + * Return `null` to disable the overlay. + */ + renderDragOverlay?: (props: {columnId: string}) => React.ReactNode; + /** + * Called when a column is dropped in a new position. + * If omitted, the provider updates the table state automatically: `table.setColumnOrder` for center + * columns and `table.setColumnPinning` when reordering within a pinned (`left`/`right`) group. + */ + onReorder?: (result: ColumnReorderResult) => void; +} diff --git a/src/components/ColumnReorderingProvider/utils/escapeClassName.ts b/src/components/ColumnReorderingProvider/utils/escapeClassName.ts new file mode 100644 index 0000000..ec7cead --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/escapeClassName.ts @@ -0,0 +1,5 @@ +export function escapeClassName(className: string) { + return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(className) + : className; +} diff --git a/src/components/ColumnReorderingProvider/utils/findHorizontalScrollContainer.ts b/src/components/ColumnReorderingProvider/utils/findHorizontalScrollContainer.ts new file mode 100644 index 0000000..828dc39 --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/findHorizontalScrollContainer.ts @@ -0,0 +1,19 @@ +export function findHorizontalScrollContainer(start: Element | null): HTMLElement | null { + let element: HTMLElement | null = + start instanceof HTMLElement ? start : (start?.parentElement ?? null); + + while (element) { + const {overflowX} = getComputedStyle(element); + + if ( + (overflowX === 'auto' || overflowX === 'scroll') && + element.scrollWidth > element.clientWidth + ) { + return element; + } + + element = element.parentElement; + } + + return null; +} diff --git a/src/components/ColumnReorderingProvider/utils/getColumnGroup.ts b/src/components/ColumnReorderingProvider/utils/getColumnGroup.ts new file mode 100644 index 0000000..f10de1e --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/getColumnGroup.ts @@ -0,0 +1,8 @@ +import type {Table} from '@tanstack/react-table'; + +export function getColumnGroup( + table: Table, + columnId: string, +): 'left' | 'right' | 'center' { + return table.getColumn(columnId)?.getIsPinned() || 'center'; +} diff --git a/src/components/ColumnReorderingProvider/utils/getCurrentColumnOrder.ts b/src/components/ColumnReorderingProvider/utils/getCurrentColumnOrder.ts new file mode 100644 index 0000000..208a891 --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/getCurrentColumnOrder.ts @@ -0,0 +1,7 @@ +import type {Table} from '@tanstack/react-table'; + +export function getCurrentColumnOrder(table: Table): string[] { + const order = table.getState().columnOrder; + + return order.length ? order : table.getAllLeafColumns().map((column) => column.id); +} diff --git a/src/components/ColumnReorderingProvider/utils/getElementClassName.ts b/src/components/ColumnReorderingProvider/utils/getElementClassName.ts new file mode 100644 index 0000000..65012c8 --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/getElementClassName.ts @@ -0,0 +1,3 @@ +export function getElementClassName(element?: Element | null) { + return element instanceof HTMLElement ? element.className : undefined; +} diff --git a/src/components/ColumnReorderingProvider/utils/restrictToHorizontalAxis.ts b/src/components/ColumnReorderingProvider/utils/restrictToHorizontalAxis.ts new file mode 100644 index 0000000..361abd5 --- /dev/null +++ b/src/components/ColumnReorderingProvider/utils/restrictToHorizontalAxis.ts @@ -0,0 +1,3 @@ +import type {Modifier} from '@dnd-kit/core'; + +export const restrictToHorizontalAxis: Modifier = ({transform}) => ({...transform, y: 0}); diff --git a/src/components/Table/Table.scss b/src/components/Table/Table.scss index 05735f5..4cddcfb 100644 --- a/src/components/Table/Table.scss +++ b/src/components/Table/Table.scss @@ -4,6 +4,8 @@ $block: '.#{variables.$ns}styled-table'; #{$block} { + --gt-table-reordering-insertion-line-color: var(--g-color-line-info); + &__header { background: var(--g-color-base-background); } diff --git a/src/components/Table/__stories__/Table.stories.tsx b/src/components/Table/__stories__/Table.stories.tsx index eae71df..36fca01 100644 --- a/src/components/Table/__stories__/Table.stories.tsx +++ b/src/components/Table/__stories__/Table.stories.tsx @@ -2,6 +2,8 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Table} from '../index'; +import {ColumnReorderingStory} from './stories/ColumnReorderingStory'; +import {ColumnReorderingWithPinningStory} from './stories/ColumnReorderingWithPinningStory'; import {DefaultStory} from './stories/DefaultStory'; import {FilteringStory} from './stories/FilteringStory'; import {GroupingStory} from './stories/GroupingStory'; @@ -71,6 +73,14 @@ export const Reordering: StoryObj = { render: ReorderingStory, }; +export const ColumnReordering: StoryObj = { + render: ColumnReorderingStory, +}; + +export const ColumnReorderingWithPinning: StoryObj = { + render: ColumnReorderingWithPinningStory, +}; + export const Virtualization: StoryObj = { render: VirtualizationStory, }; diff --git a/src/components/Table/__stories__/stories/ColumnReorderingStory.tsx b/src/components/Table/__stories__/stories/ColumnReorderingStory.tsx new file mode 100644 index 0000000..62b7351 --- /dev/null +++ b/src/components/Table/__stories__/stories/ColumnReorderingStory.tsx @@ -0,0 +1,51 @@ +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {generateData} from '../../../BaseTable/__stories__/utils'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import {Table} from '../../index'; +import type {TableProps} from '../../index'; + +interface Item { + id: string; + name: string; + age: number; + status: string; +} + +const columns: ColumnDef[] = [ + {accessorKey: 'name', header: 'Name', size: 200}, + {accessorKey: 'age', header: 'Age', size: 200}, + {accessorKey: 'status', header: 'Status', size: 200}, + { + id: 'name-age', + accessorFn: (item) => `${item.name}: ${item.age}`, + header: 'Name and age', + size: 200, + }, + { + id: 'age-status', + accessorFn: (item) => `${item.age} / ${item.status}`, + header: 'Age and status', + size: 200, + }, +]; + +const data = generateData(30) as Item[]; + +export const ColumnReorderingStory = (props: Omit, 'table'>) => { + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnResizing: true, + columnResizeMode: 'onChange', + }); + + return ( + +
+ + + + ); +}; diff --git a/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts new file mode 100644 index 0000000..f95e9f0 --- /dev/null +++ b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.classname.ts @@ -0,0 +1,5 @@ +import {cn} from '../../../../utils'; + +export const cnTableColumnReorderingWithPinningStory = cn( + 'table-column-reordering-with-pinning-story', +); diff --git a/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.scss b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.scss new file mode 100644 index 0000000..cb65406 --- /dev/null +++ b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.scss @@ -0,0 +1,29 @@ +.table-column-reordering-with-pinning-story { + display: flex; + flex-direction: column; + row-gap: var(--g-spacing-3); + + &__hint { + color: var(--g-color-text-secondary); + } + + &__scroll { + width: 900px; + overflow: auto; + } + + .gt-styled-table__cell, + .gt-styled-table__header-cell { + background: var(--g-color-base-background); + } + + .gt-styled-table__header-cell_last-pinned-left, + .gt-styled-table__cell_last-pinned-left { + box-shadow: var(--g-color-line-generic-active) -2px 0 2px -2px inset; + } + + .gt-styled-table__header-cell_first-pinned-right, + .gt-styled-table__cell_first-pinned-right { + box-shadow: var(--g-color-line-generic-active) 2px 0 2px -2px inset; + } +} diff --git a/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.tsx b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.tsx new file mode 100644 index 0000000..4fc3fa9 --- /dev/null +++ b/src/components/Table/__stories__/stories/ColumnReorderingWithPinningStory.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; + +import type {ColumnOrderState, ColumnPinningState} from '@tanstack/react-table'; + +import {useTable} from '../../../../hooks'; +import type {ColumnDef} from '../../../../types/base'; +import {generateData} from '../../../BaseTable/__stories__/utils'; +import {ColumnReorderingProvider} from '../../../ColumnReorderingProvider'; +import {Table} from '../../index'; +import type {TableProps} from '../../index'; + +import {cnTableColumnReorderingWithPinningStory} from './ColumnReorderingWithPinningStory.classname'; + +import './ColumnReorderingWithPinningStory.scss'; + +interface Item { + id: string; + name: string; + age: number; + status: string; +} + +const column = (def: ColumnDef & {size: number}): ColumnDef => ({ + ...def, + minSize: def.size, + maxSize: def.size, +}); + +const columns: ColumnDef[] = [ + column({accessorKey: 'name', header: 'Name', size: 120}), + column({accessorKey: 'age', header: 'Age', size: 110}), + column({accessorKey: 'status', header: 'Status', size: 150}), + column({ + id: 'name-age', + accessorFn: (item) => `${item.name}: ${item.age}`, + header: 'Name + age', + size: 160, + }), + column({ + id: 'name-upper', + accessorFn: (item) => item.name.toUpperCase(), + header: 'Name upper', + size: 170, + }), + column({ + id: 'age-x2', + accessorFn: (item) => `${item.age * 2}`, + header: 'Age × 2', + size: 130, + }), + column({ + id: 'status-copy', + accessorFn: (item) => item.status, + header: 'Status copy', + size: 160, + }), + column({ + id: 'name-status', + accessorFn: (item) => `${item.name} (${item.status})`, + header: 'Name + status', + size: 150, + }), + column({ + id: 'age-status', + accessorFn: (item) => `${item.age} / ${item.status}`, + header: 'Age + status', + size: 150, + }), +]; + +const data = generateData(30) as Item[]; + +export const ColumnReorderingWithPinningStory = (props: Omit, 'table'>) => { + const [columnPinning, setColumnPinning] = React.useState({ + left: ['name', 'age'], + right: ['name-status', 'age-status'], + }); + const [columnOrder, setColumnOrder] = React.useState([]); + + const table = useTable({ + columns, + data, + getRowId: (item) => item.id, + enableColumnPinning: true, + onColumnPinningChange: setColumnPinning, + onColumnOrderChange: setColumnOrder, + state: {columnPinning, columnOrder}, + }); + + return ( +
+
+ Drag the left pinned pair (Name / Age), the right pinned pair (Name + status / Age + + status), or the center pair (Status / Name + age) to reorder within each group. +
+ { + if (result.pinned) { + setColumnPinning(result.columnPinning); + } else { + setColumnOrder(result.columnOrder); + } + }} + > +
+
+ + + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index 27f7d7b..c189474 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,9 +1,12 @@ export * from './BaseCell'; +export * from './BaseDraggableHeaderCell'; export * from './BaseDraggableRow'; export * from './BaseFooterCell'; export * from './BaseGroupHeader'; export * from './BaseHeaderCell'; export * from './BaseHeaderRow'; +export * from './ColumnReorderingContext'; +export * from './ColumnReorderingProvider'; export * from './ReorderingProvider'; export * from './BaseResizeHandle'; export * from './BaseRow'; diff --git a/src/index.ts b/src/index.ts index ab0df41..263d8b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { BaseTable, ReorderingProvider, + ColumnReorderingProvider, + ColumnReorderingContext, SortIndicator, Table, TableSettings, @@ -12,6 +14,8 @@ export { export type { BaseTableProps, ReorderingProviderProps, + ColumnReorderingProviderProps, + ColumnReorderResult, SortIndicatorProps, TableProps, TableSettingsOptions, diff --git a/src/types/base.ts b/src/types/base.ts index 4cab677..dca8698 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -13,6 +13,8 @@ export type ColumnDef = BaseColumnDef = Omit<