Skip to content

Commit a75f42a

Browse files
committed
⚡️ Refactor column resize logic to align the width and reset
1 parent d47bcc4 commit a75f42a

File tree

10 files changed

+611
-369
lines changed

10 files changed

+611
-369
lines changed

app/examples/column-resizing/ResizingComplexExample.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export default function ResizingComplexExample() {
3434
const { effectiveColumns, resetColumnsWidth, resetColumnsOrder, resetColumnsToggle } = useDataTableColumns<Company>({
3535
key,
3636
columns: [
37-
{ accessor: 'name', ...props },
38-
{ accessor: 'streetAddress', ...props },
37+
{ accessor: 'name', ellipsis: true, ...props },
38+
{ accessor: 'streetAddress', ellipsis: true, ...props },
3939
{ accessor: 'city', ellipsis: true, ...props },
4040
{ accessor: 'state', textAlign: 'right', ...props },
4141
],

package/DataTable.css

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
--mantine-datatable-header-height: 0;
4141
--mantine-datatable-footer-height: 0;
42-
--mantine-datatable-selection-column-width: 0;
42+
--mantine-datatable-selection-column-width: 44px;
4343
--mantine-datatable-top-shadow-opacity: 0;
4444
--mantine-datatable-left-shadow-opacity: 0;
4545
--mantine-datatable-bottom-shadow-opacity: 0;
@@ -70,6 +70,31 @@
7070
white-space: nowrap;
7171
}
7272

73+
/* Selection column should always have fixed width */
74+
.mantine-datatable th[data-accessor="__selection__"],
75+
.mantine-datatable td[data-accessor="__selection__"] {
76+
width: 44px !important;
77+
min-width: 44px !important;
78+
max-width: 44px !important;
79+
}
80+
81+
/* When not using fixed layout, allow natural table sizing */
82+
.mantine-datatable:not(.mantine-datatable-resizable-columns) th {
83+
white-space: nowrap;
84+
/* Allow natural width calculation */
85+
width: auto;
86+
min-width: auto;
87+
max-width: none;
88+
}
89+
90+
/* But selection column should still be fixed even in auto layout */
91+
.mantine-datatable:not(.mantine-datatable-resizable-columns) th[data-accessor="__selection__"],
92+
.mantine-datatable:not(.mantine-datatable-resizable-columns) td[data-accessor="__selection__"] {
93+
width: 44px !important;
94+
min-width: 44px !important;
95+
max-width: 44px !important;
96+
}
97+
7398
.mantine-datatable-table {
7499
border-collapse: separate;
75100
border-spacing: 0;

package/DataTable.tsx

Lines changed: 10 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Box, Table, type MantineSize } from '@mantine/core';
22
import { useMergedRef } from '@mantine/hooks';
33
import clsx from 'clsx';
4-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4+
import { useCallback, useMemo, useState } from 'react';
55
import { DataTableColumnsProvider } from './DataTableDragToggleProvider';
66
import { DataTableEmptyRow } from './DataTableEmptyRow';
77
import { DataTableEmptyState } from './DataTableEmptyState';
@@ -134,19 +134,9 @@ export function DataTable<T>({
134134
return groups?.flatMap((group) => group.columns) ?? columns!;
135135
}, [columns, groups]);
136136

137-
const hasResizableColumns = useMemo(() => {
138-
return effectiveColumns.some((col) => col.resizable);
139-
}, [effectiveColumns]);
140-
141137
// When columns are resizable, start with auto layout to let the browser
142138
// compute natural widths, then capture them and switch to fixed layout.
143139
const [fixedLayoutEnabled, setFixedLayoutEnabled] = useState(false);
144-
const prevHasResizableRef = useRef<boolean | null>(null);
145-
146-
const dragToggle = useDataTableColumns({
147-
key: storeColumnsKey,
148-
columns: effectiveColumns,
149-
});
150140

151141
const { refs, onScroll: handleScrollPositionChange } = useDataTableInjectCssVariables({
152142
scrollCallbacks: {
@@ -159,124 +149,19 @@ export function DataTable<T>({
159149
withRowBorders: otherProps.withRowBorders,
160150
});
161151

152+
const dragToggle = useDataTableColumns({
153+
key: storeColumnsKey,
154+
columns: effectiveColumns,
155+
headerRef: refs.header as any,
156+
scrollViewportRef: refs.scrollViewport as any,
157+
onFixedLayoutChange: setFixedLayoutEnabled,
158+
});
159+
162160
const mergedTableRef = useMergedRef(refs.table, tableRef);
163161
const mergedViewportRef = useMergedRef(refs.scrollViewport, scrollViewportRef);
164162

165163
const rowExpansionInfo = useRowExpansion<T>({ rowExpansion, records, idAccessor });
166164

167-
// Initialize content-based widths when resizable columns are present.
168-
useEffect(() => {
169-
// If resizable just became disabled, revert to auto layout
170-
if (!hasResizableColumns) {
171-
prevHasResizableRef.current = false;
172-
setFixedLayoutEnabled(false);
173-
return;
174-
}
175-
176-
// Only run when switching from non-resizable -> resizable
177-
if (prevHasResizableRef.current === true) return;
178-
prevHasResizableRef.current = true;
179-
180-
let raf = requestAnimationFrame(() => {
181-
const thead = refs.header.current;
182-
if (!thead) {
183-
setFixedLayoutEnabled(true);
184-
return;
185-
}
186-
187-
const headerCells = Array.from(thead.querySelectorAll<HTMLTableCellElement>('th[data-accessor]'));
188-
189-
if (headerCells.length === 0) {
190-
setFixedLayoutEnabled(true);
191-
return;
192-
}
193-
194-
let measured = headerCells
195-
.map((cell) => {
196-
const accessor = cell.getAttribute('data-accessor');
197-
if (!accessor || accessor === '__selection__') return null;
198-
const width = Math.round(cell.getBoundingClientRect().width);
199-
return { accessor, width } as const;
200-
})
201-
.filter(Boolean) as Array<{ accessor: string; width: number }>;
202-
203-
const viewport = refs.scrollViewport.current;
204-
const viewportWidth = viewport?.clientWidth ?? 0;
205-
if (viewportWidth && measured.length) {
206-
const total = measured.reduce((acc, u) => acc + u.width, 0);
207-
const overflow = total - viewportWidth;
208-
if (overflow > 0) {
209-
const last = measured[measured.length - 1];
210-
last.width = Math.max(50, last.width - overflow);
211-
}
212-
}
213-
214-
const updates = measured.map((m) => ({ accessor: m.accessor, width: `${m.width}px` }));
215-
216-
setTimeout(() => {
217-
if (updates.length) dragToggle.setMultipleColumnWidths(updates);
218-
setFixedLayoutEnabled(true);
219-
}, 0);
220-
});
221-
222-
return () => cancelAnimationFrame(raf);
223-
}, [hasResizableColumns]);
224-
225-
// If user resets widths to 'initial', recompute widths and re-enable fixed layout.
226-
const allResizableWidthsInitial = useMemo(() => {
227-
if (!hasResizableColumns) return false;
228-
return effectiveColumns
229-
.filter((c) => c.resizable && !c.hidden && c.accessor !== '__selection__')
230-
.every((c) => c.width === undefined || c.width === '' || c.width === 'initial');
231-
}, [effectiveColumns, hasResizableColumns]);
232-
233-
useEffect(() => {
234-
if (!hasResizableColumns) return;
235-
if (!allResizableWidthsInitial) return;
236-
237-
// Temporarily disable fixed layout so natural widths can be measured
238-
setFixedLayoutEnabled(false);
239-
240-
let raf = requestAnimationFrame(() => {
241-
const thead = refs.header.current;
242-
if (!thead) {
243-
setFixedLayoutEnabled(true);
244-
return;
245-
}
246-
247-
const headerCells = Array.from(thead.querySelectorAll<HTMLTableCellElement>('th[data-accessor]'));
248-
249-
let measured = headerCells
250-
.map((cell) => {
251-
const accessor = cell.getAttribute('data-accessor');
252-
if (!accessor || accessor === '__selection__') return null;
253-
const width = Math.round(cell.getBoundingClientRect().width);
254-
return { accessor, width } as const;
255-
})
256-
.filter(Boolean) as Array<{ accessor: string; width: number }>;
257-
258-
const viewport = refs.scrollViewport.current;
259-
const viewportWidth = viewport?.clientWidth ?? 0;
260-
if (viewportWidth && measured.length) {
261-
const total = measured.reduce((acc, u) => acc + u.width, 0);
262-
const overflow = total - viewportWidth;
263-
if (overflow > 0) {
264-
const last = measured[measured.length - 1];
265-
last.width = Math.max(50, last.width - overflow);
266-
}
267-
}
268-
269-
const updates = measured.map((m) => ({ accessor: m.accessor, width: `${m.width}px` }));
270-
271-
setTimeout(() => {
272-
if (updates.length) dragToggle.setMultipleColumnWidths(updates);
273-
setFixedLayoutEnabled(true);
274-
}, 0);
275-
});
276-
277-
return () => cancelAnimationFrame(raf);
278-
}, [hasResizableColumns, allResizableWidthsInitial, refs.header, dragToggle]);
279-
280165
const handlePageChange = useCallback(
281166
(page: number) => {
282167
refs.scrollViewport.current?.scrollTo({ top: 0, left: 0 });
@@ -385,7 +270,7 @@ export function DataTable<T>({
385270
'mantine-datatable-pin-last-column': pinLastColumn,
386271
'mantine-datatable-selection-column-visible': selectionColumnVisible,
387272
'mantine-datatable-pin-first-column': pinFirstColumn,
388-
'mantine-datatable-resizable-columns': fixedLayoutEnabled,
273+
'mantine-datatable-resizable-columns': dragToggle.hasResizableColumns && fixedLayoutEnabled,
389274
},
390275
classNames?.table
391276
)}

package/DataTableHeaderCell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function DataTableHeaderCell<T>({
137137
style={[
138138
{
139139
width,
140-
...(!resizable ? { minWidth: width, maxWidth: width } : { minWidth: '1px' }),
140+
...(!resizable ? { minWidth: width, maxWidth: width } : {}),
141141
},
142142
style,
143143
]}

package/DataTableResizableHeaderHandle.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa
8787
currentCol.style.width = `${finalCurrentWidth}px`;
8888
nextCol.style.width = `${finalNextWidth}px`;
8989

90-
// Force the table layout to recalculate
90+
// Ensure the table maintains fixed layout during resize
9191
currentCol.style.minWidth = `${finalCurrentWidth}px`;
9292
currentCol.style.maxWidth = `${finalCurrentWidth}px`;
9393
nextCol.style.minWidth = `${finalNextWidth}px`;
@@ -141,7 +141,7 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa
141141
document.addEventListener('mousemove', handleMouseMove);
142142
document.addEventListener('mouseup', handleMouseUp);
143143
},
144-
[accessor, setMultipleColumnWidths]
144+
[accessor, columnRef, setMultipleColumnWidths]
145145
);
146146

147147
const handleDoubleClick = useCallback(() => {
@@ -150,30 +150,29 @@ export const DataTableResizableHeaderHandle = (props: DataTableResizableHeaderHa
150150
const currentColumn = columnRef.current;
151151
const nextColumn = currentColumn.nextElementSibling as HTMLTableCellElement | null;
152152

153-
// Reset styles immediately
153+
// Clear any inline styles that might interfere with natural sizing
154154
currentColumn.style.width = '';
155155
currentColumn.style.minWidth = '';
156156
currentColumn.style.maxWidth = '';
157157

158-
const updates = [{ accessor, width: 'initial' }];
158+
// Reset current column to auto width
159+
const updates = [{ accessor, width: 'auto' }];
159160

160161
if (nextColumn) {
161162
nextColumn.style.width = '';
162163
nextColumn.style.minWidth = '';
163164
nextColumn.style.maxWidth = '';
164165

165166
const nextAccessor = nextColumn.getAttribute('data-accessor');
166-
// Only add to updates if it's not the selection column
167+
// Only reset next column if it's not the selection column
167168
if (nextAccessor && nextAccessor !== '__selection__') {
168-
updates.push({ accessor: nextAccessor, width: 'initial' });
169+
updates.push({ accessor: nextAccessor, width: 'auto' });
169170
}
170171
}
171172

172-
// Use setTimeout to ensure DOM changes are applied before context update
173-
setTimeout(() => {
174-
setMultipleColumnWidths(updates);
175-
}, 0);
176-
}, [accessor, setMultipleColumnWidths]);
173+
// Update context - this will trigger re-measurement of natural widths
174+
setMultipleColumnWidths(updates);
175+
}, [accessor, columnRef, setMultipleColumnWidths]);
177176

178177
return (
179178
<div

package/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export * from './useColumnResize';
2+
export * from './useDataTableColumnReorder';
3+
export * from './useDataTableColumnResize';
24
export * from './useDataTableColumns';
5+
export * from './useDataTableColumnToggle';
36
export * from './useDataTableInjectCssVariables';
47
export * from './useIsomorphicLayoutEffect';
58
export * from './useLastSelectionChangeIndex';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useLocalStorage } from '@mantine/hooks';
2+
import type { DataTableColumn } from '../types/DataTableColumn';
3+
4+
/**
5+
* Hook to handle column reordering with localStorage persistence.
6+
* @see https://icflorescu.github.io/mantine-datatable/examples/column-dragging-and-toggling/
7+
*/
8+
export function useDataTableColumnReorder<T>({
9+
key,
10+
columns = [],
11+
getInitialValueInEffect = true,
12+
}: {
13+
/**
14+
* The key to use in localStorage to store the columns order.
15+
*/
16+
key: string | undefined;
17+
/**
18+
* Columns definitions.
19+
*/
20+
columns: DataTableColumn<T>[];
21+
/**
22+
* If set to true, value will be updated in useEffect after mount.
23+
* @default true
24+
*/
25+
getInitialValueInEffect?: boolean;
26+
}) {
27+
// Align order with current columns definition
28+
function alignColumnsOrder<T>(columnsOrder: string[], columns: DataTableColumn<T>[]) {
29+
const updatedColumnsOrder: string[] = [];
30+
31+
// Keep existing order for columns that still exist
32+
columnsOrder.forEach((col) => {
33+
if (columns.find((c) => c.accessor === col)) {
34+
updatedColumnsOrder.push(col);
35+
}
36+
});
37+
38+
// Add new columns to the end
39+
columns.forEach((col) => {
40+
if (!updatedColumnsOrder.includes(col.accessor as string)) {
41+
updatedColumnsOrder.push(col.accessor as string);
42+
}
43+
});
44+
45+
return updatedColumnsOrder;
46+
}
47+
48+
// Default columns order is the order of the columns in the array
49+
const defaultColumnsOrder = (columns && columns.map((column) => column.accessor)) || [];
50+
51+
const [columnsOrder, _setColumnsOrder] = useLocalStorage<string[]>({
52+
key: key ? `${key}-columns-order` : '',
53+
defaultValue: key ? (defaultColumnsOrder as string[]) : undefined,
54+
getInitialValueInEffect,
55+
});
56+
57+
function setColumnsOrder(order: string[] | ((prev: string[]) => string[])) {
58+
if (key) {
59+
_setColumnsOrder(order);
60+
}
61+
}
62+
63+
const resetColumnsOrder = () => {
64+
setColumnsOrder(defaultColumnsOrder as string[]);
65+
};
66+
67+
// If no key is provided, return unmanaged state
68+
if (!key) {
69+
return {
70+
columnsOrder: columnsOrder as string[],
71+
setColumnsOrder,
72+
resetColumnsOrder,
73+
} as const;
74+
}
75+
76+
// Align order with current columns
77+
const alignedColumnsOrder = alignColumnsOrder(columnsOrder, columns);
78+
const prevColumnsOrder = JSON.stringify(columnsOrder);
79+
80+
if (JSON.stringify(alignedColumnsOrder) !== prevColumnsOrder) {
81+
setColumnsOrder(alignedColumnsOrder);
82+
}
83+
84+
return {
85+
columnsOrder: alignedColumnsOrder,
86+
setColumnsOrder,
87+
resetColumnsOrder,
88+
} as const;
89+
}

0 commit comments

Comments
 (0)