diff --git a/package/DataTable.css b/package/DataTable.css index 54040acc..265f1341 100644 --- a/package/DataTable.css +++ b/package/DataTable.css @@ -79,18 +79,18 @@ border: rem(1px) solid var(--mantine-datatable-border-color); } -.mantine-datatable-resizable-columns { - table-layout: fixed; -} - +/* Resizable columns stay in `table-layout: auto` so declarative widths + (including `width: '0%'` on the actions column) keep working as intended. + Pixel widths are locked at drag-start by the resize handle, and + `overflow: hidden` on the th creates a block formatting context that + makes the browser honor those pixel widths precisely. */ .mantine-datatable-resizable-columns th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* Allow overflow for pinned cells so the ::after shadow pseudo-element is not clipped. - This overrides overflow: hidden from resizable columns (th) and ellipsis (td). */ +/* Allow overflow for pinned cells so the ::after shadow pseudo-element is not clipped. */ .mantine-datatable-pin-first-column.mantine-datatable-resizable-columns th:first-of-type, .mantine-datatable-pin-first-column.mantine-datatable-selection-column-visible.mantine-datatable-resizable-columns th:nth-of-type(2), @@ -109,23 +109,6 @@ max-width: 44px !important; } -/* When not using fixed layout, allow natural table sizing */ -.mantine-datatable:not(.mantine-datatable-resizable-columns) th { - white-space: nowrap; - /* Allow natural width calculation */ - width: auto; - min-width: auto; - max-width: none; -} - -/* But selection column should still be fixed even in auto layout */ -.mantine-datatable:not(.mantine-datatable-resizable-columns) th[data-accessor='__selection__'], -.mantine-datatable:not(.mantine-datatable-resizable-columns) td[data-accessor='__selection__'] { - width: 44px !important; - min-width: 44px !important; - max-width: 44px !important; -} - .mantine-datatable-table { border-collapse: separate; border-spacing: 0; diff --git a/package/DataTable.tsx b/package/DataTable.tsx index 761a080e..8ff023ee 100644 --- a/package/DataTable.tsx +++ b/package/DataTable.tsx @@ -2,7 +2,7 @@ import { Box, Table, type MantineSize } from '@mantine/core'; import { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; import type { RefObject } from 'react'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { DataTableColumnsProvider } from './DataTableDragToggleProvider'; import { DataTableEmptyRow } from './DataTableEmptyRow'; import { DataTableEmptyState } from './DataTableEmptyState'; @@ -133,14 +133,10 @@ export function DataTable({ tableWrapper, ...otherProps }: DataTableProps) { - const effectiveColumns = useMemo(() => { + const flatColumns = useMemo(() => { return groups ? flattenColumns(groups) : columns!; }, [columns, groups]); - // When columns are resizable, start with auto layout to let the browser - // compute natural widths, then capture them and switch to fixed layout. - const [fixedLayoutEnabled, setFixedLayoutEnabled] = useState(false); - const { refs, onScroll: handleScrollPositionChange } = useDataTableInjectCssVariables({ scrollCallbacks: { onScroll, @@ -154,12 +150,15 @@ export function DataTable({ const dragToggle = useDataTableColumns({ key: storeColumnsKey, - columns: effectiveColumns, + columns: flatColumns, headerRef: refs.header as RefObject, scrollViewportRef: refs.scrollViewport as RefObject, - onFixedLayoutChange: setFixedLayoutEnabled, }); + // Use the columns enriched with order/visibility/width from the hook so + // resize widths actually reach the rendered / cells. + const effectiveColumns = dragToggle.effectiveColumns; + const mergedTableRef = useMergedRef(refs.table, tableRef); const mergedViewportRef = useMergedRef(refs.scrollViewport, scrollViewportRef); const rowExpansionInfo = useRowExpansion({ rowExpansion, records, idAccessor }); @@ -305,12 +304,16 @@ export function DataTable({ 'mantine-datatable-pin-last-column': pinLastColumn, 'mantine-datatable-selection-column-visible': selectionColumnVisible, 'mantine-datatable-pin-first-column': pinFirstColumn, - 'mantine-datatable-resizable-columns': dragToggle.hasResizableColumns && fixedLayoutEnabled, + 'mantine-datatable-resizable-columns': dragToggle.hasResizableColumns, + 'mantine-datatable-resize-locked': dragToggle.isLocked, + 'mantine-datatable-resizing': dragToggle.isResizing, }, classNames?.table )} style={{ ...styles?.table, + ...(dragToggle.isLocked ? { tableLayout: 'fixed' } : null), + ...(dragToggle.tableWidth != null ? { width: `${dragToggle.tableWidth}px` } : null), }} data-striped={(recordsLength && striped) || undefined} data-highlight-on-hover={highlightOnHover || undefined} diff --git a/package/DataTableColumns.context.ts b/package/DataTableColumns.context.ts index 0a512f28..26c3d2dd 100644 --- a/package/DataTableColumns.context.ts +++ b/package/DataTableColumns.context.ts @@ -24,6 +24,10 @@ interface DataTableColumnsContext { setColumnWidth: (accessor: string, width: string | number) => void; setMultipleColumnWidths: (updates: Array<{ accessor: string; width: string | number }>) => void; resetColumnsWidth: () => void; + + // Drag lifecycle: snapshot DOM widths, lock the table, then persist on release + beginResize: () => void; + endResize: () => void; } export const [DataTableColumnsContextProvider, useDataTableColumnsContext] = createSafeContext( diff --git a/package/DataTableDragToggleProvider.tsx b/package/DataTableDragToggleProvider.tsx index 1435243d..10f0c6e8 100644 --- a/package/DataTableDragToggleProvider.tsx +++ b/package/DataTableDragToggleProvider.tsx @@ -16,6 +16,9 @@ type DataTableColumnsProviderProps = PropsWithChildren<{ setColumnWidth: (accessor: string, width: string | number) => void; setMultipleColumnWidths: (updates: Array<{ accessor: string; width: string | number }>) => void; resetColumnsWidth: () => void; + + beginResize: () => void; + endResize: () => void; }>; export const DataTableColumnsProvider = (props: DataTableColumnsProviderProps) => { @@ -32,6 +35,9 @@ export const DataTableColumnsProvider = (props: DataTableColumnsProviderProps) = setColumnWidth, setMultipleColumnWidths, resetColumnsWidth, + + beginResize, + endResize, } = props; const [sourceColumn, setSourceColumn] = useState(''); @@ -70,6 +76,9 @@ export const DataTableColumnsProvider = (props: DataTableColumnsProviderProps) = setColumnWidth, setMultipleColumnWidths, resetColumnsWidth, + + beginResize, + endResize, }} > {children} diff --git a/package/DataTableResizableHeaderHandle.tsx b/package/DataTableResizableHeaderHandle.tsx index 72594e14..ef71ca49 100644 --- a/package/DataTableResizableHeaderHandle.tsx +++ b/package/DataTableResizableHeaderHandle.tsx @@ -8,6 +8,8 @@ type DataTableResizableHeaderHandleProps = { columnRef: RefObject; }; +const MIN_COLUMN_WIDTH = 50; + export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHandleProps) => { const { accessor, columnRef } = props; const [isResizing, setIsResizing] = useState(false); @@ -17,7 +19,7 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa const { dir } = useDirection(); const isRTL = dir === 'rtl'; - const { setMultipleColumnWidths } = useDataTableColumnsContext(); + const { setMultipleColumnWidths, beginResize, endResize } = useDataTableColumnsContext(); const handleMouseDown = useCallback( (event: React.MouseEvent) => { @@ -33,125 +35,75 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa while (nextColumn) { const nextAccessor = nextColumn.getAttribute('data-accessor'); if (nextAccessor && nextAccessor !== '__selection__') { - break; // Found a valid data column + break; } nextColumn = nextColumn.nextElementSibling as HTMLTableCellElement | null; } if (!nextColumn) { - return; // No next column to resize with + return; } const nextAccessor = nextColumn.getAttribute('data-accessor'); if (!nextAccessor) { - return; // Next column missing data-accessor + return; } - // Special handling for next column being selection column - const isNextSelection = nextAccessor === '__selection__'; + // Capture pixel widths BEFORE switching the table to fixed layout — + // these are the natural rendered widths the user is starting from. + const startCurrentWidth = currentColumn.offsetWidth; + const startNextWidth = nextColumn.offsetWidth; + + // Snapshot every header cell into pixel widths and switch to fixed layout + beginResize(); - // Store initial state setIsResizing(true); startXRef.current = event.clientX; - - // Get current computed widths (not getBoundingClientRect which might include borders/padding) - const currentWidth = currentColumn.offsetWidth; - const nextWidth = nextColumn.offsetWidth; - originalWidthsRef.current = { - current: currentWidth, - next: nextWidth, + current: startCurrentWidth, + next: startNextWidth, }; - // Global mouse event handlers const handleMouseMove = (moveEvent: MouseEvent) => { - if (!columnRef.current) return; - - const currentCol = columnRef.current; - const nextCol = currentCol.nextElementSibling as HTMLTableCellElement | null; - if (!nextCol) return; - let deltaX = moveEvent.clientX - startXRef.current; + if (isRTL) deltaX = -deltaX; - // In RTL, reverse the deltaX to make resizing follow mouse movement naturally - if (isRTL) { - deltaX = -deltaX; - } - - const minWidth = 50; + const maxShrinkCurrent = originalWidthsRef.current.current - MIN_COLUMN_WIDTH; + const maxShrinkNext = originalWidthsRef.current.next - MIN_COLUMN_WIDTH; - // Calculate the maximum possible movement in both directions - const maxShrinkCurrent = originalWidthsRef.current.current - minWidth; - const maxShrinkNext = originalWidthsRef.current.next - minWidth; - - // Limit deltaX to respect both columns' minimum widths - const constrainedDelta = Math.max( - -maxShrinkCurrent, // Don't shrink current below minimum - Math.min(deltaX, maxShrinkNext) // Don't shrink next below minimum - ); + const constrainedDelta = Math.max(-maxShrinkCurrent, Math.min(deltaX, maxShrinkNext)); const finalCurrentWidth = originalWidthsRef.current.current + constrainedDelta; const finalNextWidth = originalWidthsRef.current.next - constrainedDelta; - // Apply to DOM immediately for smooth visual feedback - currentCol.style.width = `${finalCurrentWidth}px`; - nextCol.style.width = `${finalNextWidth}px`; - - // Ensure the table maintains fixed layout during resize - currentCol.style.minWidth = `${finalCurrentWidth}px`; - currentCol.style.maxWidth = `${finalCurrentWidth}px`; - nextCol.style.minWidth = `${finalNextWidth}px`; - nextCol.style.maxWidth = `${finalNextWidth}px`; + // State-driven update: pushes the new widths through `effectiveColumns` + // and keeps React in sync with the DOM. With table-layout: fixed the + // browser honors the new pixel widths precisely. + setMultipleColumnWidths([ + { accessor, width: `${finalCurrentWidth}px` }, + { accessor: nextAccessor, width: `${finalNextWidth}px` }, + ]); }; const handleMouseUp = () => { - if (!columnRef.current) return; - - const currentCol = columnRef.current; - const nextCol = currentCol.nextElementSibling as HTMLTableCellElement | null; - setIsResizing(false); - // Reset global styles document.body.style.cursor = 'initial'; document.body.style.userSelect = 'initial'; - // Get final widths from the applied styles - const finalCurrentWidth = parseInt(currentCol.style.width) || currentCol.offsetWidth; - const finalNextWidth = nextCol ? parseInt(nextCol.style.width) || nextCol.offsetWidth : 0; - - // Update context with final widths - const updates = [{ accessor, width: `${finalCurrentWidth}px` }]; - - if (nextCol && !isNextSelection) { - const nextAccessor = nextCol.getAttribute('data-accessor'); - if (nextAccessor) { - updates.push({ - accessor: nextAccessor, - width: `${finalNextWidth}px`, - }); - } - } - - // Update the context AFTER we've applied the styles - setTimeout(() => { - setMultipleColumnWidths(updates); - }, 0); + endResize(); - // Remove event listeners document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - // Set global styles document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; - // Add event listeners document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }, - [accessor, columnRef, isRTL, setMultipleColumnWidths] + [accessor, columnRef, isRTL, setMultipleColumnWidths, beginResize, endResize] ); const handleDoubleClick = useCallback(() => { @@ -160,30 +112,16 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa const currentColumn = columnRef.current; const nextColumn = currentColumn.nextElementSibling as HTMLTableCellElement | null; - // Clear any inline styles that might interfere with natural sizing - currentColumn.style.width = ''; - currentColumn.style.minWidth = ''; - currentColumn.style.maxWidth = ''; - - // Reset current column to auto width const updates = [{ accessor, width: 'auto' }]; if (nextColumn) { - nextColumn.style.width = ''; - nextColumn.style.minWidth = ''; - nextColumn.style.maxWidth = ''; - const nextAccessor = nextColumn.getAttribute('data-accessor'); - // Only reset next column if it's not the selection column if (nextAccessor && nextAccessor !== '__selection__') { updates.push({ accessor: nextAccessor, width: 'auto' }); } } - // Use setTimeout to ensure DOM changes are applied before context update - setTimeout(() => { - setMultipleColumnWidths(updates); - }, 0); + setMultipleColumnWidths(updates); }, [accessor, columnRef, setMultipleColumnWidths]); return ( diff --git a/package/hooks/useDataTableColumnReorder.ts b/package/hooks/useDataTableColumnReorder.ts index 0072f3d5..cb95b5a9 100644 --- a/package/hooks/useDataTableColumnReorder.ts +++ b/package/hooks/useDataTableColumnReorder.ts @@ -1,4 +1,5 @@ import { useLocalStorage } from '@mantine/hooks'; +import { useEffect, useMemo } from 'react'; import type { DataTableColumn } from '../types/DataTableColumn'; /** @@ -24,33 +25,15 @@ export function useDataTableColumnReorder({ */ getInitialValueInEffect?: boolean; }) { - // Align order with current columns definition - function alignColumnsOrder(columnsOrder: string[], columns: DataTableColumn[]) { - const updatedColumnsOrder: string[] = []; - - // Keep existing order for columns that still exist - columnsOrder.forEach((col) => { - if (columns.find((c) => c.accessor === col)) { - updatedColumnsOrder.push(col); - } - }); - - // Add new columns to the end - columns.forEach((col) => { - if (!updatedColumnsOrder.includes(col.accessor as string)) { - updatedColumnsOrder.push(col.accessor as string); - } - }); - - return updatedColumnsOrder; - } - // Default columns order is the order of the columns in the array - const defaultColumnsOrder = (columns && columns.map((column) => column.accessor)) || []; + const defaultColumnsOrder = useMemo( + () => (columns ? (columns.map((column) => column.accessor) as string[]) : []), + [columns] + ); const [columnsOrder, _setColumnsOrder] = useLocalStorage({ key: key ? `${key}-columns-order` : '', - defaultValue: key ? (defaultColumnsOrder as string[]) : undefined, + defaultValue: key ? defaultColumnsOrder : undefined, getInitialValueInEffect, }); @@ -60,11 +43,37 @@ export function useDataTableColumnReorder({ } } + // Align stored order with current column definitions: drop accessors that no + // longer exist, append accessors added since last persisted state. + const alignedColumnsOrder = useMemo(() => { + if (!columnsOrder) return defaultColumnsOrder; + const aligned: string[] = []; + columnsOrder.forEach((col) => { + if (columns.find((c) => c.accessor === col)) { + aligned.push(col); + } + }); + columns.forEach((col) => { + if (!aligned.includes(col.accessor as string)) { + aligned.push(col.accessor as string); + } + }); + return aligned; + }, [columnsOrder, columns, defaultColumnsOrder]); + + // Persist alignment in an effect — never set state during render + useEffect(() => { + if (!key) return; + if (!columnsOrder) return; + if (JSON.stringify(alignedColumnsOrder) !== JSON.stringify(columnsOrder)) { + _setColumnsOrder(alignedColumnsOrder); + } + }, [key, alignedColumnsOrder, columnsOrder, _setColumnsOrder]); + const resetColumnsOrder = () => { - setColumnsOrder(defaultColumnsOrder as string[]); + setColumnsOrder(defaultColumnsOrder); }; - // If no key is provided, return unmanaged state if (!key) { return { columnsOrder: columnsOrder as string[], @@ -73,14 +82,6 @@ export function useDataTableColumnReorder({ } as const; } - // Align order with current columns - const alignedColumnsOrder = alignColumnsOrder(columnsOrder, columns); - const prevColumnsOrder = JSON.stringify(columnsOrder); - - if (JSON.stringify(alignedColumnsOrder) !== prevColumnsOrder) { - setColumnsOrder(alignedColumnsOrder); - } - return { columnsOrder: alignedColumnsOrder, setColumnsOrder, diff --git a/package/hooks/useDataTableColumnResize.ts b/package/hooks/useDataTableColumnResize.ts index 57e3ce6a..b5bf3f34 100644 --- a/package/hooks/useDataTableColumnResize.ts +++ b/package/hooks/useDataTableColumnResize.ts @@ -5,7 +5,17 @@ import type { DataTableColumn } from '../types/DataTableColumn'; type DataTableColumnWidth = Record; /** - * Hook to handle column resizing with localStorage persistence and auto-resize calculation. + * Hook to handle column resizing with localStorage persistence. + * + * Strategy (mirrors `mantine-list-view-table`): + * 1. Until the user grabs a handle, the table stays in `table-layout: auto` + * so declarative widths — including `width: '0%'` on actions columns — + * work as documented. + * 2. On mousedown we snapshot every header cell into pixel widths and switch + * to `table-layout: fixed` so pixel widths are honored strictly during + * the drag and afterwards. + * 3. The lock stays in place until `resetColumnsWidth` is called. + * * @see https://icflorescu.github.io/mantine-datatable/examples/column-resizing/ */ export function useDataTableColumnResize({ @@ -13,130 +23,124 @@ export function useDataTableColumnResize({ columns = [], getInitialValueInEffect = true, headerRef, - onFixedLayoutChange, }: { - /** - * The key to use in localStorage to store the columns width. - */ + /** The key to use in localStorage to store the columns width. */ key: string | undefined; - /** - * Columns definitions. - */ + /** Columns definitions. */ columns: DataTableColumn[]; /** * If set to true, value will be updated in useEffect after mount. * @default true */ getInitialValueInEffect?: boolean; - /** - * Reference to the table header element for measuring column widths. - */ + /** Reference to the table header element for measuring column widths. */ headerRef?: RefObject; /** * Reference to the scroll viewport for calculating overflow. + * Kept for backwards compatibility; no longer used internally. */ scrollViewportRef?: RefObject; - /** - * Callback to control fixed layout state in the parent component. - */ - onFixedLayoutChange?: (enabled: boolean) => void; }) { - const isInitializedRef = useRef(false); - const naturalWidthsRef = useRef>({}); - const [isSSR, setIsSSR] = useState(true); - - // Check if columns have resizable feature const hasResizableColumns = useMemo(() => { return columns.some((c) => c.resizable && !c.hidden && c.accessor !== '__selection__'); }, [columns]); - // Get resizable columns - const resizableColumns = useMemo(() => { - return columns.filter((c) => c.resizable && !c.hidden && c.accessor !== '__selection__'); - }, [columns]); - - // Check if we need to measure natural widths (columns without explicit width) - const needsNaturalMeasurement = useMemo(() => { - return resizableColumns.some((c) => c.width === undefined || c.width === '' || c.width === 'initial'); - }, [resizableColumns]); - - // Create default column widths - use explicit widths or 'auto' for natural sizing - // Exclude selection column from width management const getDefaultColumnsWidth = useCallback(() => { return columns .filter((column) => column.accessor !== '__selection__') - .map((column) => ({ - [column.accessor]: column.width ?? 'auto', - })); + .map((column) => ({ [column.accessor]: column.width ?? 'auto' })); }, [columns]); + // Honor caller's `getInitialValueInEffect`: when false the read is synchronous + // and we can seed `effectiveColumnsWidth` directly; when true the value lands + // after mount and the hydration effect below picks it up. const [storedColumnsWidth, setStoredColumnsWidth] = useLocalStorage({ key: key ? `${key}-columns-width` : '', defaultValue: key ? getDefaultColumnsWidth() : undefined, - getInitialValueInEffect: false, // We'll handle initialization manually + getInitialValueInEffect, }); - // Current effective column widths (combines stored + measured natural widths) - const [effectiveColumnsWidth, setEffectiveColumnsWidth] = useState(() => - getDefaultColumnsWidth() - ); - - // Handle SSR - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsSSR(false); - }, []); + const [effectiveColumnsWidth, setEffectiveColumnsWidth] = useState(() => { + if (key && storedColumnsWidth && storedColumnsWidth.length > 0) { + return storedColumnsWidth; + } + return getDefaultColumnsWidth(); + }); - // Measure natural widths of columns - const measureNaturalWidths = useCallback(() => { - if (!headerRef?.current || isSSR) return {}; + // True only during an active drag — drives cursor / user-select styling + const [isResizing, setIsResizing] = useState(false); - const thead = headerRef.current; - const headerCells = Array.from(thead.querySelectorAll('th[data-accessor]')); - const naturalWidths: Record = {}; + // Marks effective widths as user-changed so the persistence effect knows to + // sync them to localStorage. Hydration / column re-alignment never set this. + const dirtyRef = useRef(false); - headerCells.forEach((cell) => { - const accessor = cell.getAttribute('data-accessor'); - if (!accessor || accessor === '__selection__') return; + // Sync stored → effective whenever the persisted value lands or changes + // (covers `getInitialValueInEffect: true`, cross-tab updates, etc.). + // Idempotent: when values are unchanged React bails out of the re-render. + useEffect(() => { + if (!key) return; + if (!storedColumnsWidth || storedColumnsWidth.length === 0) return; - const column = resizableColumns.find((c) => c.accessor === accessor); - if (!column) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setEffectiveColumnsWidth(storedColumnsWidth); + }, [key, storedColumnsWidth]); - // Only measure if column doesn't have explicit width - if (column.width === undefined || column.width === '' || column.width === 'initial') { - const rect = cell.getBoundingClientRect(); - naturalWidths[accessor] = Math.round(rect.width); + // Re-align effective state with the current column set: drop entries for + // removed accessors, add defaults for newly introduced columns. Bails out + // when nothing actually changed to avoid extra renders. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setEffectiveColumnsWidth((prev) => { + const validAccessors = new Set( + columns.filter((c) => c.accessor !== '__selection__').map((c) => String(c.accessor)) + ); + const filtered = prev.filter((entry) => validAccessors.has(Object.keys(entry)[0])); + const present = new Set(filtered.map((entry) => Object.keys(entry)[0])); + let added = false; + for (const column of columns) { + if (column.accessor === '__selection__') continue; + const accessor = String(column.accessor); + if (present.has(accessor)) continue; + filtered.push({ [accessor]: column.width ?? 'auto' }); + added = true; } + const removed = filtered.length !== prev.length || added; + return removed ? filtered : prev; }); + }, [columns]); - return naturalWidths; - }, [headerRef, resizableColumns, isSSR]); - - // Update column widths (both stored and effective) - // Filter out selection column from updates const updateColumnWidths = useCallback( (updates: Array<{ accessor: string; width: string | number }>) => { - // Filter out any updates to the selection column - const filteredUpdates = updates.filter((update) => update.accessor !== '__selection__'); - - const newWidths = effectiveColumnsWidth.map((column) => { - const accessor = Object.keys(column)[0]; - const update = filteredUpdates.find((u) => u.accessor === accessor); - - if (update) { - return { [accessor]: update.width }; - } - return column; - }); + const filtered = updates.filter((u) => u.accessor !== '__selection__'); + if (filtered.length === 0) return; + + dirtyRef.current = true; + setEffectiveColumnsWidth((prev) => + prev.map((col) => { + const accessor = Object.keys(col)[0]; + const update = filtered.find((u) => u.accessor === accessor); + return update ? { [accessor]: update.width } : col; + }) + ); + }, + [] + ); - setEffectiveColumnsWidth(newWidths); + // Persist user-initiated width changes — but only when no drag is in flight. + // `localStorage.setItem` is synchronous and writing it on every mousemove + // would block the main thread and visibly hurt drag smoothness. + useEffect(() => { + if (!key || !dirtyRef.current) return; + if (isResizing) return; + dirtyRef.current = false; + setStoredColumnsWidth(effectiveColumnsWidth); + }, [key, effectiveColumnsWidth, isResizing, setStoredColumnsWidth]); - // Also update stored widths if we have a key - if (key) { - setStoredColumnsWidth(newWidths); - } + const setColumnWidth = useCallback( + (accessor: string, width: string | number) => { + updateColumnWidths([{ accessor, width }]); }, - [effectiveColumnsWidth, key, setStoredColumnsWidth] + [updateColumnWidths] ); const setMultipleColumnWidths = useCallback( @@ -146,146 +150,64 @@ export function useDataTableColumnResize({ [updateColumnWidths] ); - // Initialize column widths (measure natural widths and apply stored widths) - const initializeColumnWidths = useCallback(() => { - if (!headerRef?.current || !onFixedLayoutChange || isSSR) return; - - // First, measure natural widths if needed - if (needsNaturalMeasurement) { - // Temporarily use auto layout to get natural widths - onFixedLayoutChange(false); - - // Wait for layout to settle, then measure - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const naturalWidths = measureNaturalWidths(); - naturalWidthsRef.current = { ...naturalWidthsRef.current, ...naturalWidths }; - - // Create effective widths combining stored and natural widths - // Exclude selection column from width management - const newEffectiveWidths = columns - .filter((column) => column.accessor !== '__selection__') - .map((column) => { - const accessor = column.accessor as string; - - // Check if we have a stored width for this column - const storedWidth = storedColumnsWidth?.find((w) => Object.keys(w)[0] === accessor); - if (storedWidth && storedWidth[accessor] !== 'auto') { - return { [accessor]: storedWidth[accessor] }; - } - - // Use natural width if available, otherwise use column definition or auto - const naturalWidth = naturalWidths[accessor]; - if (naturalWidth) { - return { [accessor]: `${naturalWidth}px` }; - } - - return { [accessor]: column.width ?? 'auto' }; - }); - - setEffectiveColumnsWidth(newEffectiveWidths); + /** + * Snapshot every header cell width into the React state. Called the moment + * the user grabs a handle. Idempotent: if all cells are already pixel-locked + * the snapshot reads back the same values. + */ + const beginResize = useCallback(() => { + setIsResizing(true); - // Switch to fixed layout for resizing - setTimeout(() => { - onFixedLayoutChange(true); - isInitializedRef.current = true; - }, 10); - }); - }); - } else { - // All columns have explicit widths, use them directly - // Exclude selection column from width management - const explicitWidths = columns - .filter((column) => column.accessor !== '__selection__') - .map((column) => ({ - [column.accessor]: column.width ?? 'auto', - })); + const thead = headerRef?.current; + if (!thead) return; - setEffectiveColumnsWidth(explicitWidths); - onFixedLayoutChange(true); - isInitializedRef.current = true; - } - }, [ - headerRef, - onFixedLayoutChange, - isSSR, - needsNaturalMeasurement, - measureNaturalWidths, - columns, - storedColumnsWidth, - ]); + const cells = Array.from(thead.querySelectorAll('th[data-accessor]')); + if (cells.length === 0) return; - const measureAndSetColumnWidths = initializeColumnWidths; + const snapshot: DataTableColumnWidth[] = []; - // Initialize on mount and when columns change - useEffect(() => { - if (!hasResizableColumns || !onFixedLayoutChange || isSSR) { - onFixedLayoutChange?.(false); - return; + for (const cell of cells) { + const accessor = cell.getAttribute('data-accessor'); + if (!accessor) continue; + if (accessor === '__selection__') continue; + const width = Math.round(cell.getBoundingClientRect().width); + snapshot.push({ [accessor]: `${width}px` }); } - // Reset initialization flag when columns change - isInitializedRef.current = false; - - // Initialize after a short delay to ensure DOM is ready - const timeoutId = setTimeout(() => { - initializeColumnWidths(); - }, 50); + dirtyRef.current = true; + setEffectiveColumnsWidth(snapshot); + }, [headerRef]); - return () => clearTimeout(timeoutId); - }, [hasResizableColumns, onFixedLayoutChange, isSSR, initializeColumnWidths]); - - // Load stored widths on client-side hydration - useEffect(() => { - if (isSSR || !key || !getInitialValueInEffect) return; + /** Clear the active-drag flag. Persistence runs from the effect once `isResizing` flips off. */ + const endResize = useCallback(() => { + setIsResizing(false); + }, []); - // Apply stored widths if available - if (storedColumnsWidth && storedColumnsWidth.length > 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setEffectiveColumnsWidth(storedColumnsWidth); - } - }, [isSSR, key, getInitialValueInEffect, storedColumnsWidth]); - // Reset all columns to their natural/initial widths const resetColumnsWidth = useCallback(() => { - // Clear stored widths - if (key) { - setStoredColumnsWidth(getDefaultColumnsWidth()); - } + const defaults = getDefaultColumnsWidth(); + dirtyRef.current = true; + setEffectiveColumnsWidth(defaults); + setIsResizing(false); + }, [getDefaultColumnsWidth]); - // Reset to natural widths - naturalWidthsRef.current = {}; - isInitializedRef.current = false; - - // Re-initialize to measure natural widths - if (onFixedLayoutChange) { - onFixedLayoutChange(false); - setTimeout(() => { - initializeColumnWidths(); - }, 10); - } - }, [key, setStoredColumnsWidth, getDefaultColumnsWidth, onFixedLayoutChange, initializeColumnWidths]); - - // Set width for a single column - const setColumnWidth = useCallback( - (accessor: string, width: string | number) => { - updateColumnWidths([{ accessor, width }]); - }, - [updateColumnWidths] - ); - - // Check if all resizable columns are using auto/natural widths const allResizableWidthsInitial = useMemo(() => { if (!hasResizableColumns) return false; + const resizable = columns.filter((c) => c.resizable && !c.hidden && c.accessor !== '__selection__'); return effectiveColumnsWidth - .filter((colWidth) => { - const accessor = Object.keys(colWidth)[0]; - return resizableColumns.some((c) => c.accessor === accessor); + .filter((cw) => { + const accessor = Object.keys(cw)[0]; + return resizable.some((c) => c.accessor === accessor); }) - .every((colWidth) => { - const width = Object.values(colWidth)[0]; - return width === 'auto' || width === 'initial'; + .every((cw) => { + const w = Object.values(cw)[0]; + return w === 'auto' || w === 'initial'; }); - }, [hasResizableColumns, effectiveColumnsWidth, resizableColumns]); + }, [hasResizableColumns, columns, effectiveColumnsWidth]); + + // No-op kept for backwards compatibility — measurement is now lazy + const measureAndSetColumnWidths = useCallback(() => { + /* noop */ + }, []); return { columnsWidth: effectiveColumnsWidth, @@ -296,5 +218,8 @@ export function useDataTableColumnResize({ hasResizableColumns, allResizableWidthsInitial, measureAndSetColumnWidths, + isResizing, + beginResize, + endResize, } as const; } diff --git a/package/hooks/useDataTableColumns.ts b/package/hooks/useDataTableColumns.ts index 2c3fa149..da5b7f08 100644 --- a/package/hooks/useDataTableColumns.ts +++ b/package/hooks/useDataTableColumns.ts @@ -16,7 +16,6 @@ export const useDataTableColumns = ({ getInitialValueInEffect = true, headerRef, scrollViewportRef, - onFixedLayoutChange, }: { /** * The key to use in localStorage to store the columns order and toggle state. @@ -39,10 +38,6 @@ export const useDataTableColumns = ({ * Reference to the scroll viewport for calculating overflow. */ scrollViewportRef?: RefObject; - /** - * Callback to control fixed layout state in the parent component. - */ - onFixedLayoutChange?: (enabled: boolean) => void; }) => { // Use specialized hooks for each feature const { columnsOrder, setColumnsOrder, resetColumnsOrder } = useDataTableColumnReorder({ @@ -66,51 +61,74 @@ export const useDataTableColumns = ({ hasResizableColumns, allResizableWidthsInitial, measureAndSetColumnWidths, + isResizing, + beginResize, + endResize, } = useDataTableColumnResize({ key, columns, getInitialValueInEffect, headerRef, scrollViewportRef, - onFixedLayoutChange, }); - // Compute effective columns based on order, toggle, and width + // Compute effective columns based on order, toggle, and width. + // Width is applied unconditionally — even without a `storeColumnsKey` — + // so that `setMultipleColumnWidths` updates from the resize handle + // actually flow back into the rendered cell `style.width`. const effectiveColumns = useMemo(() => { - if (!columnsOrder) { - return columns; - } - - const result = columnsOrder - .map((order) => columns.find((column) => column.accessor === order)) - .map((column) => { - return { + let result: DataTableColumn[]; + if (columnsOrder) { + result = columnsOrder + .map((order) => columns.find((column) => column.accessor === order)) + .map((column) => ({ ...column, hidden: column?.hidden || - !columnsToggle.find((toggle) => { - return toggle.accessor === column?.accessor; - })?.toggled, - }; - }) as DataTableColumn[]; - - const newWidths = result.map((column) => { - // Skip width application for selection column - if (column?.accessor === '__selection__') { - return column; - } + !columnsToggle.find((toggle) => toggle.accessor === column?.accessor)?.toggled, + })) as DataTableColumn[]; + } else { + result = columns; + } - return { - ...column, - width: columnsWidth.find((width) => { - return width[column?.accessor as string]; - })?.[column?.accessor as string], - }; + return result.map((column) => { + if (column?.accessor === '__selection__') return column; + const accessor = column?.accessor as string; + const widthEntry = columnsWidth.find((entry) => accessor in entry); + if (!widthEntry) return column; + const width = widthEntry[accessor]; + // Treat 'auto' / 'initial' as "no override" so declarative widths win + if (width === undefined || width === 'auto' || width === 'initial') return column; + return { ...column, width }; }); - - return newWidths; }, [columns, columnsOrder, columnsToggle, columnsWidth]); + // Lock the table layout to `fixed` only when *every* visible column has a + // pixel width. Mixed states (some 'auto', some pixels) stay in auto layout + // so the browser keeps the auto cells visible — the user can re-resize to + // produce a complete pixel snapshot. + // Recomputes on column visibility changes too, so resize + toggle stays + // consistent. + const isLocked = useMemo(() => { + const visible = effectiveColumns.filter((c) => !c.hidden && c.accessor !== '__selection__'); + if (visible.length === 0) return false; + return visible.every((c) => typeof c.width === 'string' && /px$/.test(c.width)); + }, [effectiveColumns]); + + const tableWidth = useMemo(() => { + if (!isLocked) return null; + let sum = 0; + for (const col of effectiveColumns) { + if (col.hidden || col.accessor === '__selection__') continue; + const w = col.width; + if (typeof w !== 'string') continue; + const n = parseInt(w, 10); + if (Number.isNaN(n)) continue; + sum += n; + } + return sum > 0 ? sum : null; + }, [isLocked, effectiveColumns]); + return { effectiveColumns: effectiveColumns as DataTableColumn[], @@ -133,5 +151,10 @@ export const useDataTableColumns = ({ hasResizableColumns, allResizableWidthsInitial, measureAndSetColumnWidths, + isResizing, + isLocked, + tableWidth, + beginResize, + endResize, } as const; };