From f99e2473c56a60b0e8c40b46729105570ca030a0 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Thu, 14 May 2026 13:52:41 +0200 Subject: [PATCH 1/2] refactor(design-system): dsTable reorderable via extra column [AR-62832] --- .../ds-table-header.module.scss | 4 --- .../ds-table-header/ds-table-header.tsx | 5 +--- .../components/ds-table-row/ds-table-row.tsx | 29 ++++++++++++------- .../src/components/ds-table/ds-table.tsx | 21 ++++++++++++-- .../components/ds-table/utils/constants.ts | 11 +++++++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss b/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss index 33731c3d7..8ecb2b706 100644 --- a/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss +++ b/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.module.scss @@ -29,10 +29,6 @@ width: 100%; } -.reorderColumn { - width: vars.$reorder-column-width; -} - .headerCell { @include typography.body-sm-md; color: var(--font-main); diff --git a/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.tsx b/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.tsx index 0471feb7e..e71d6810c 100644 --- a/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.tsx +++ b/packages/design-system/src/components/ds-table/components/ds-table-header/ds-table-header.tsx @@ -11,7 +11,7 @@ import { SELECT_COLUMN_ID } from '../../utils/constants'; import { DsStack } from '../../../ds-stack'; const DsTableHeader = ({ table }: DsTableHeaderProps) => { - const { stickyHeader, bordered, reorderable, virtualized } = useDsTableContext(); + const { stickyHeader, bordered, virtualized } = useDsTableContext(); return ( ({ table }: DsTableHeaderProps) => { virtualized && styles.headerRowVirtualized, )} > - {reorderable && ( - Order - )} {headerGroup.headers.map((header) => { const headerStyle = getColumnSizeStyle(header.column.getSize(), virtualized); const canSort = header.column.getCanSort(); diff --git a/packages/design-system/src/components/ds-table/components/ds-table-row/ds-table-row.tsx b/packages/design-system/src/components/ds-table/components/ds-table-row/ds-table-row.tsx index 6853afa15..1b1388875 100644 --- a/packages/design-system/src/components/ds-table/components/ds-table-row/ds-table-row.tsx +++ b/packages/design-system/src/components/ds-table/components/ds-table-row/ds-table-row.tsx @@ -10,20 +10,22 @@ import styles from './ds-table-row.module.scss'; import { useDsTableContext } from '../../context/ds-table-context'; import { mergeRefs } from '../../../../utils/merge-refs'; import { getColumnSizeStyle } from '../../utils/column-size'; -import { EXPANDER_COLUMN_ID, SELECT_COLUMN_ID } from '../../utils/constants'; +import { EXPANDER_COLUMN_ID, REORDER_COLUMN_ID, SELECT_COLUMN_ID } from '../../utils/constants'; interface DsRowDragHandleProps { isDragging: boolean; attributes: ReturnType['attributes']; listeners: ReturnType['listeners']; + style?: React.CSSProperties; } -const DsRowDragHandle = ({ isDragging, attributes, listeners }: DsRowDragHandleProps) => { +const DsRowDragHandle = ({ isDragging, attributes, listeners, style }: DsRowDragHandleProps) => { return ( ({ ref, row, isSelected }: DsTableRowProps) => onClick={() => onRowClick?.(row.original)} onDoubleClick={() => onRowDoubleClick?.(row.original)} > - {reorderable && ( - - )} {row.getVisibleCells().map((cell, idx) => { - const isLastColumn = idx === row.getVisibleCells().length - 1; const cellStyle = getColumnSizeStyle(cell.column.getSize()); + if (cell.column.id === REORDER_COLUMN_ID) { + return ( + + ); + } + + const isLastColumn = idx === row.getVisibleCells().length - 1; + return ( ({ ref, row, isSelected }: DsTableRowProps) => {isExpanded && renderExpandedRow && ( - + {renderExpandedRow(row.original)} diff --git a/packages/design-system/src/components/ds-table/ds-table.tsx b/packages/design-system/src/components/ds-table/ds-table.tsx index 79eea18fb..280159cb1 100644 --- a/packages/design-system/src/components/ds-table/ds-table.tsx +++ b/packages/design-system/src/components/ds-table/ds-table.tsx @@ -29,6 +29,8 @@ import { EMPTY_TABLE_STATE_TEXT, EXPANDER_COLUMN_ID, EXPANDER_COLUMN_WIDTH, + REORDER_COLUMN_ID, + REORDER_COLUMN_WIDTH, SELECT_COLUMN_ID, SELECT_COLUMN_WIDTH, } from './utils/constants'; @@ -136,6 +138,7 @@ const DsTable = ({ const hasExpanderColumn = !!expandable; const hasSelectColumn = !!selectable; + const hasReorderColumn = reorderable && !virtualized; const columns = useMemo[]>(() => { const augmentedColumns: ColumnDef[] = [...columnsProp]; @@ -164,8 +167,22 @@ const DsTable = ({ augmentedColumns.unshift(expanderColumn); } + if (hasReorderColumn) { + // Cell is rendered inline by DsTableRow when it encounters REORDER_COLUMN_ID, + // since the drag handle needs row-level useSortable state. + const reorderColumn: ColumnDef = { + id: REORDER_COLUMN_ID, + size: REORDER_COLUMN_WIDTH, + enableSorting: false, + enableResizing: false, + header: 'Order', + cell: () => null, + }; + augmentedColumns.unshift(reorderColumn); + } + return augmentedColumns; - }, [columnsProp, hasExpanderColumn, hasSelectColumn, showSelectAllCheckbox]); + }, [columnsProp, hasExpanderColumn, hasReorderColumn, hasSelectColumn, showSelectAllCheckbox]); const table = useReactTable({ data: reorderable ? data : tableData, @@ -237,7 +254,7 @@ const DsTable = ({ const renderEmptyState = () => ( - + {emptyState || EMPTY_TABLE_STATE_TEXT} diff --git a/packages/design-system/src/components/ds-table/utils/constants.ts b/packages/design-system/src/components/ds-table/utils/constants.ts index fc26046f7..35806a064 100644 --- a/packages/design-system/src/components/ds-table/utils/constants.ts +++ b/packages/design-system/src/components/ds-table/utils/constants.ts @@ -22,3 +22,14 @@ export const SELECT_COLUMN_ID = 'select'; * Width (in px) of the selection column. */ export const SELECT_COLUMN_WIDTH = 36; + +/** + * Column id used for the synthetic reorder column injected when `reorderable` is set + * (non-virtualized tables only). + */ +export const REORDER_COLUMN_ID = 'reorder'; + +/** + * Width (in px) of the reorder column. + */ +export const REORDER_COLUMN_WIDTH = 60; From ce20c43e99a9c64a714ee905aed3ed84f162e057 Mon Sep 17 00:00:00 2001 From: Ihor Romanchuk Date: Thu, 14 May 2026 15:36:05 +0200 Subject: [PATCH 2/2] Enhance the test for reorderable with D&D --- .../ds-table-row-actions.browser.test.tsx | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/design-system/src/components/ds-table/__tests__/ds-table-row-actions.browser.test.tsx b/packages/design-system/src/components/ds-table/__tests__/ds-table-row-actions.browser.test.tsx index f7122db7e..d24500cee 100644 --- a/packages/design-system/src/components/ds-table/__tests__/ds-table-row-actions.browser.test.tsx +++ b/packages/design-system/src/components/ds-table/__tests__/ds-table-row-actions.browser.test.tsx @@ -5,7 +5,7 @@ import type { Action } from '../ds-table.types'; import { columns, defaultData, type Person } from '../stories/common/story-data'; describe('DsTable - Row Actions', () => { - it('should render reorderable table with order column', async () => { + it('should reorder rows when dragging the handle past the next row', async () => { const onOrderChange = vi.fn(); const fiveItems = defaultData.slice(0, 5); @@ -14,12 +14,50 @@ describe('DsTable - Row Actions', () => { ); const dataRows = page.getByRole('row').all().slice(1); - expect(dataRows).toHaveLength(5); await expect.element(page.getByText('Order')).toBeVisible(); await expect.element(page.getByRole('row').nth(1)).toHaveTextContent('Tanner'); await expect.element(page.getByRole('row').nth(2)).toHaveTextContent('Kevin'); + + const handle = page.getByRole('row').nth(1).getByRole('button').element() as HTMLElement; + const kevinRow = page.getByRole('row').nth(2).element() as HTMLTableRowElement; + + const handleRect = handle.getBoundingClientRect(); + const kevinRect = kevinRow.getBoundingClientRect(); + const startX = handleRect.left + handleRect.width / 2; + const startY = handleRect.top + handleRect.height / 2; + const endY = kevinRect.top + kevinRect.height / 2 + 5; + + // dnd-kit's MouseSensor measures droppable rects on requestAnimationFrame + // after activation, and the resulting state update schedules another render. + // Yielding a few frames between events lets collision detection see the row + // rects before each drag move runs. + const settle = async () => { + for (let i = 0; i < 3; i += 1) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }; + + handle.dispatchEvent( + new MouseEvent('mousedown', { clientX: startX, clientY: startY, button: 0, bubbles: true }), + ); + await settle(); + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: startX, clientY: endY, bubbles: true })); + await settle(); + + document.dispatchEvent(new MouseEvent('mouseup', { clientX: startX, clientY: endY, bubbles: true })); + + await vi.waitFor(() => { + expect(onOrderChange).toHaveBeenCalledTimes(1); + }); + + const newOrder = onOrderChange.mock.calls[0]?.[0] as Person[] | undefined; + expect(newOrder?.map((p) => p.firstName)).toEqual(['Kevin', 'Tanner', 'John', 'Jane', 'Peter']); + + await expect.element(page.getByRole('row').nth(1)).toHaveTextContent('Kevin'); + await expect.element(page.getByRole('row').nth(2)).toHaveTextContent('Tanner'); }); it('should show row action buttons on hover and respect disabled state', async () => {