Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 6 additions & 23 deletions package/DataTable.css
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
Expand Down
21 changes: 12 additions & 9 deletions package/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -133,14 +133,10 @@ export function DataTable<T>({
tableWrapper,
...otherProps
}: DataTableProps<T>) {
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,
Expand All @@ -154,12 +150,15 @@ export function DataTable<T>({

const dragToggle = useDataTableColumns({
key: storeColumnsKey,
columns: effectiveColumns,
columns: flatColumns,
headerRef: refs.header as RefObject<HTMLTableSectionElement | null>,
scrollViewportRef: refs.scrollViewport as RefObject<HTMLElement | null>,
onFixedLayoutChange: setFixedLayoutEnabled,
});

// Use the columns enriched with order/visibility/width from the hook so
// resize widths actually reach the rendered <th>/<td> cells.
const effectiveColumns = dragToggle.effectiveColumns;

const mergedTableRef = useMergedRef(refs.table, tableRef);
const mergedViewportRef = useMergedRef(refs.scrollViewport, scrollViewportRef);
const rowExpansionInfo = useRowExpansion<T>({ rowExpansion, records, idAccessor });
Expand Down Expand Up @@ -305,12 +304,16 @@ export function DataTable<T>({
'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}
Expand Down
4 changes: 4 additions & 0 deletions package/DataTableColumns.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataTableColumnsContext>(
Expand Down
9 changes: 9 additions & 0 deletions package/DataTableDragToggleProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -32,6 +35,9 @@ export const DataTableColumnsProvider = (props: DataTableColumnsProviderProps) =
setColumnWidth,
setMultipleColumnWidths,
resetColumnsWidth,

beginResize,
endResize,
} = props;

const [sourceColumn, setSourceColumn] = useState('');
Expand Down Expand Up @@ -70,6 +76,9 @@ export const DataTableColumnsProvider = (props: DataTableColumnsProviderProps) =
setColumnWidth,
setMultipleColumnWidths,
resetColumnsWidth,

beginResize,
endResize,
}}
>
{children}
Expand Down
120 changes: 29 additions & 91 deletions package/DataTableResizableHeaderHandle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ type DataTableResizableHeaderHandleProps = {
columnRef: RefObject<HTMLTableCellElement | null>;
};

const MIN_COLUMN_WIDTH = 50;

export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHandleProps) => {
const { accessor, columnRef } = props;
const [isResizing, setIsResizing] = useState(false);
Expand All @@ -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<HTMLDivElement>) => {
Expand All @@ -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(() => {
Expand All @@ -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 (
Expand Down
Loading