import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, monitorForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { attachClosestEdge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index'; import { announce } from '@atlaskit/pragmatic-drag-and-drop-live-region'; import { uid } from '../shared/dom'; import { clearDragging, clearDropTarget, flashDropped, markDragging, markDropTarget, } from '../shared/dnd'; import { SORTABLE_TABLE_REORDER_EVENT, SORTABLE_TABLE_RESIZE_EVENT, type SortableTableOptions, type SortableTableReorderDetail, type SortableTableResizeDetail, } from './sortable-table.types'; const SELECTORS = { col: '[data-c42-sortable-col]', colHandle: '[data-c42-sortable-col-handle]', row: '[data-c42-sortable-row]', rowHandle: '[data-c42-sortable-row-handle]', resize: '[data-c42-sortable-resize]', } as const; interface Column { th: HTMLElement; value: string; index: number; } /** In-progress column resize state (pointer). Reorder is handled by pragmatic-dnd. */ interface ResizeState { value: string; th: HTMLElement; startX: number; startWidth: number; } /** Minimal shape of a pragmatic-dnd drag source / drop target we rely on. */ interface DragRecord { element: Element; data: Record; } /** * Headless sortable-table controller. Adds drag- and keyboard-driven row and * column reordering (pointer drag powered by `@atlaskit/pragmatic-drag-and-drop`) * plus pointer/keyboard column resizing to an existing ``. It manipulates * DOM order and column widths, reflects `data-dragging`, and emits typed * events — it applies no visual styles. * * Assumes the Nth `[data-c42-sortable-col]` header maps to the Nth cell in * each row. * * Markup: * ```html *
* * * * * * * * * * *
* Name * * *
Ada
* ``` */ export class SortableTable { private readonly root: HTMLElement; private readonly reorderRows: boolean; private readonly reorderColumns: boolean; private readonly resizeColumns: boolean; private readonly resizeStep: number; private readonly minColumnWidth: number; private readonly colWidths = new Map(); /** Scopes pragmatic-dnd interactions to this table instance. */ private readonly dndKey = uid('sortable-table'); private resize: ResizeState | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: SortableTableOptions = {}) { this.root = root; this.reorderRows = options.reorderRows ?? true; this.reorderColumns = options.reorderColumns ?? false; this.resizeColumns = options.resizeColumns ?? false; this.resizeStep = options.resizeStep ?? 16; this.minColumnWidth = options.minColumnWidth ?? 48; this.init(); } private columns(): Column[] { return Array.from(this.root.querySelectorAll(SELECTORS.col)).map((th, index) => ({ th, value: th.dataset.value || String(index), index, })); } private rowEls(): HTMLElement[] { return Array.from(this.root.querySelectorAll(SELECTORS.row)); } private rowContainer(): HTMLElement | null { return this.rowEls()[0]?.parentElement ?? null; } private init(): void { if (this.reorderRows) { this.rowEls().forEach((row) => { const handle = row.querySelector(SELECTORS.rowHandle); if (!handle) { return; } this.prepareHandle(handle, 'Reorder row'); const onKeydown = (e: Event): void => this.onRowKeydown(e as KeyboardEvent, handle); handle.addEventListener('keydown', onKeydown); this.cleanups.push(() => handle.removeEventListener('keydown', onKeydown)); this.registerRowDnd(row, handle); }); } if (this.reorderColumns) { this.columns().forEach((col) => { const handle = col.th.querySelector(SELECTORS.colHandle); if (!handle) { return; } this.prepareHandle(handle, 'Reorder column'); const onKeydown = (e: Event): void => this.onColumnKeydown(e as KeyboardEvent, handle); handle.addEventListener('keydown', onKeydown); this.cleanups.push(() => handle.removeEventListener('keydown', onKeydown)); this.registerColumnDnd(col.th, handle); }); } if (this.reorderRows || this.reorderColumns) { this.cleanups.push( monitorForElements({ canMonitor: ({ source }) => source.data.board === this.dndKey, onDrop: ({ source, location }) => this.applyDrop(source, location.current.dropTargets), }), ); } if (this.resizeColumns) { this.root.querySelectorAll(SELECTORS.resize).forEach((handle) => { if (!handle.hasAttribute('role')) { handle.setAttribute('role', 'separator'); } handle.setAttribute('aria-orientation', 'vertical'); if (!handle.hasAttribute('tabindex')) { handle.setAttribute('tabindex', '0'); } const onPointerDown = (e: Event): void => this.onResizePointerDown(e as PointerEvent, handle); const onKeydown = (e: Event): void => this.onResizeKeydown(e as KeyboardEvent, handle); handle.addEventListener('pointerdown', onPointerDown); handle.addEventListener('keydown', onKeydown); this.cleanups.push( () => handle.removeEventListener('pointerdown', onPointerDown), () => handle.removeEventListener('keydown', onKeydown), ); }); } } private prepareHandle(handle: HTMLElement, label: string): void { if (handle instanceof HTMLButtonElement && !handle.hasAttribute('type')) { handle.type = 'button'; } if (!handle.hasAttribute('tabindex') && !(handle instanceof HTMLButtonElement)) { handle.setAttribute('tabindex', '0'); } if (!handle.hasAttribute('aria-label') && !handle.textContent?.trim()) { handle.setAttribute('aria-label', label); } } private emitReorder(axis: 'row' | 'column', from: number, to: number, order: string[]): void { const detail: SortableTableReorderDetail = { axis, from, to, order }; this.root.dispatchEvent( new CustomEvent(SORTABLE_TABLE_REORDER_EVENT, { detail, bubbles: true }), ); } // ─── Rows ────────────────────────────────────────────────────────────── /** Current row order as an array of `data-value`s. */ getRowOrder(): string[] { return this.rowEls().map((row, i) => row.dataset.value || String(i)); } /** Move a row (by `data-value`) to the position of another row. */ moveRow(fromValue: string, toValue: string): void { const rows = this.rowEls(); const from = rows.findIndex((r) => r.dataset.value === fromValue); const to = rows.findIndex((r) => r.dataset.value === toValue); if (from < 0 || to < 0 || from === to) { return; } const moving = rows[from]!; const target = rows[to]!; if (to > from) { target.after(moving); } else { target.before(moving); } this.emitReorder('row', from, to, this.getRowOrder()); } /** Move the row at `from` to land at final index `to`. Emits when it changes. */ private moveRowToIndex(from: number, to: number): void { if (from === to) { return; } const rows = this.rowEls(); const moving = rows[from]; const target = rows[to]; if (!moving || !target) { return; } if (to > from) { target.after(moving); } else { target.before(moving); } this.emitReorder('row', from, to, this.getRowOrder()); } private moveRowByOffset(row: HTMLElement, offset: -1 | 1 | 'start' | 'end'): void { const rows = this.rowEls(); const index = rows.indexOf(row); const container = this.rowContainer(); if (index < 0 || !container) { return; } let to = index; if (offset === -1 && index > 0) { rows[index - 1]!.before(row); to = index - 1; } else if (offset === 1 && index < rows.length - 1) { rows[index + 1]!.after(row); to = index + 1; } else if (offset === 'start' && index > 0) { container.prepend(row); to = 0; } else if (offset === 'end' && index < rows.length - 1) { container.append(row); to = rows.length - 1; } else { return; } this.emitReorder('row', index, to, this.getRowOrder()); } private onRowKeydown(event: KeyboardEvent, handle: HTMLElement): void { const row = handle.closest(SELECTORS.row); if (!row) { return; } switch (event.key) { case 'ArrowUp': event.preventDefault(); this.moveRowByOffset(row, -1); break; case 'ArrowDown': event.preventDefault(); this.moveRowByOffset(row, 1); break; case 'Home': event.preventDefault(); this.moveRowByOffset(row, 'start'); break; case 'End': event.preventDefault(); this.moveRowByOffset(row, 'end'); break; default: break; } } private registerRowDnd(row: HTMLElement, handle: HTMLElement): void { this.cleanups.push( combine( draggable({ element: row, dragHandle: handle, getInitialData: () => ({ board: this.dndKey, axis: 'row' }), onDragStart: () => markDragging(row), onDrop: () => clearDragging(row), }), dropTargetForElements({ element: row, canDrop: ({ source }) => source.data.board === this.dndKey && source.data.axis === 'row' && source.element !== row, getData: ({ input, element }) => attachClosestEdge( { board: this.dndKey, axis: 'row' }, { input, element, allowedEdges: ['top', 'bottom'] }, ), getIsSticky: () => true, onDragEnter: ({ self }) => markDropTarget(row, self.data), onDrag: ({ self }) => markDropTarget(row, self.data), onDragLeave: () => clearDropTarget(row), onDrop: () => clearDropTarget(row), }), ), ); } // ─── Columns ─────────────────────────────────────────────────────────── /** Current column order as an array of `data-value`s. */ getColumnOrder(): string[] { return this.columns().map((c) => c.value); } /** Move a column (by `data-value`) to the position of another column. */ moveColumn(fromValue: string, toValue: string): void { const cols = this.columns(); const from = cols.findIndex((c) => c.value === fromValue); const to = cols.findIndex((c) => c.value === toValue); if (from < 0 || to < 0 || from === to) { return; } this.moveColumnByIndex(from, to); } private moveColumnByIndex(from: number, to: number): void { if (from === to) { return; } const headerRow = this.columns()[0]?.th.parentElement; if (!headerRow) { return; } const rows: HTMLElement[] = [headerRow as HTMLElement, ...this.rowEls()]; for (const row of rows) { const cells = Array.from(row.children) as HTMLElement[]; const moving = cells[from]; const target = cells[to]; if (!moving || !target) { continue; } if (to > from) { target.after(moving); } else { target.before(moving); } } this.emitReorder('column', from, to, this.getColumnOrder()); } private onColumnKeydown(event: KeyboardEvent, handle: HTMLElement): void { const th = handle.closest(SELECTORS.col); if (!th) { return; } const cols = this.columns(); const index = cols.findIndex((c) => c.th === th); if (event.key === 'ArrowLeft' && index > 0) { event.preventDefault(); this.moveColumnByIndex(index, index - 1); } else if (event.key === 'ArrowRight' && index < cols.length - 1) { event.preventDefault(); this.moveColumnByIndex(index, index + 1); } } private registerColumnDnd(th: HTMLElement, handle: HTMLElement): void { this.cleanups.push( combine( draggable({ element: th, dragHandle: handle, getInitialData: () => ({ board: this.dndKey, axis: 'column' }), onDragStart: () => markDragging(th), onDrop: () => clearDragging(th), }), dropTargetForElements({ element: th, canDrop: ({ source }) => source.data.board === this.dndKey && source.data.axis === 'column' && source.element !== th, getData: ({ input, element }) => attachClosestEdge( { board: this.dndKey, axis: 'column' }, { input, element, allowedEdges: ['left', 'right'] }, ), getIsSticky: () => true, onDragEnter: ({ self }) => markDropTarget(th, self.data), onDrag: ({ self }) => markDropTarget(th, self.data), onDragLeave: () => clearDropTarget(th), onDrop: () => clearDropTarget(th), }), ), ); } // ─── Drop handling (pragmatic-dnd) ─────────────────────────────────────── private applyDrop(source: DragRecord, dropTargets: readonly DragRecord[]): void { const target = dropTargets[0]; if (!target) { return; } const edge = extractClosestEdge(target.data); if (source.data.axis === 'row') { const rows = this.rowEls(); const from = rows.indexOf(source.element as HTMLElement); const indexOfTarget = rows.indexOf(target.element as HTMLElement); if (from < 0 || indexOfTarget < 0) { return; } const to = getReorderDestinationIndex({ startIndex: from, indexOfTarget, closestEdgeOfTarget: edge, axis: 'vertical', }); if (to === from) { return; } this.moveRowToIndex(from, to); flashDropped(source.element as HTMLElement); announce(`Moved row to position ${to + 1} of ${rows.length}.`); return; } if (source.data.axis === 'column') { const cols = this.columns(); const from = cols.findIndex((c) => c.th === source.element); const indexOfTarget = cols.findIndex((c) => c.th === target.element); if (from < 0 || indexOfTarget < 0) { return; } const to = getReorderDestinationIndex({ startIndex: from, indexOfTarget, closestEdgeOfTarget: edge, axis: 'horizontal', }); if (to === from) { return; } this.moveColumnByIndex(from, to); flashDropped(source.element as HTMLElement); announce(`Moved column to position ${to + 1} of ${cols.length}.`); } } // ─── Resize ──────────────────────────────────────────────────────────── private getColWidth(value: string, th: HTMLElement): number { const tracked = this.colWidths.get(value); if (tracked !== undefined) { return tracked; } const rect = th.getBoundingClientRect(); return rect.width || parseFloat(th.style.width) || this.minColumnWidth; } /** Set a column's width (px) by its `data-value`. */ setColumnWidth(value: string, width: number): void { const col = this.columns().find((c) => c.value === value); if (!col) { return; } const next = Math.max(this.minColumnWidth, Math.round(width)); this.colWidths.set(value, next); col.th.style.width = `${next}px`; const detail: SortableTableResizeDetail = { value, width: next }; this.root.dispatchEvent(new CustomEvent(SORTABLE_TABLE_RESIZE_EVENT, { detail, bubbles: true })); } private onResizeKeydown(event: KeyboardEvent, handle: HTMLElement): void { const th = handle.closest(SELECTORS.col); if (!th) { return; } const value = th.dataset.value || ''; const current = this.getColWidth(value, th); if (event.key === 'ArrowRight') { event.preventDefault(); this.setColumnWidth(value, current + this.resizeStep); } else if (event.key === 'ArrowLeft') { event.preventDefault(); this.setColumnWidth(value, current - this.resizeStep); } } private onResizePointerDown(event: PointerEvent, handle: HTMLElement): void { const th = handle.closest(SELECTORS.col); if (!th) { return; } event.preventDefault(); const value = th.dataset.value || ''; this.resize = { value, th, startX: event.clientX, startWidth: this.getColWidth(value, th), }; document.addEventListener('pointermove', this.onResizeMove); document.addEventListener('pointerup', this.onResizeUp); } private readonly onResizeMove = (event: PointerEvent): void => { if (!this.resize) { return; } const delta = event.clientX - this.resize.startX; this.setColumnWidth(this.resize.value, this.resize.startWidth + delta); }; private readonly onResizeUp = (): void => { this.resize = null; document.removeEventListener('pointermove', this.onResizeMove); document.removeEventListener('pointerup', this.onResizeUp); }; /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.onResizeUp(); this.rowEls().forEach((row) => { clearDragging(row); clearDropTarget(row); }); this.columns().forEach((col) => { clearDragging(col.th); clearDropTarget(col.th); }); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }