-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Fix column auto resize #3746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix column auto resize #3746
Changes from 11 commits
005c9f2
b66f3bd
51c2bdd
d4afa2b
ccaa437
69c46a2
f60e759
f736a3b
0e90906
523ec5a
8a6bb28
0bc54d9
58b38d3
8bae511
012a2b9
3f04905
21205c8
06d7e92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import { useLayoutEffect, useRef } from 'react'; | ||
| import { flushSync } from 'react-dom'; | ||
| import { useLayoutEffect, useRef, useState } from 'react'; | ||
|
|
||
| import type { CalculatedColumn, StateSetter } from '../types'; | ||
| import type { DataGridProps } from '../DataGrid'; | ||
|
|
@@ -16,6 +15,7 @@ export function useColumnWidths<R, SR>( | |
| setMeasuredColumnWidths: StateSetter<ReadonlyMap<string, number>>, | ||
| onColumnResize: DataGridProps<R, SR>['onColumnResize'] | ||
| ) { | ||
| const [columnToAutoResize, setColumnToAutoResize] = useState<string | null>(null); | ||
| const prevGridWidthRef = useRef(gridWidth); | ||
| const columnsCanFlex: boolean = columns.length === viewportColumns.length; | ||
| // Allow columns to flex again when... | ||
|
|
@@ -26,7 +26,10 @@ export function useColumnWidths<R, SR>( | |
| const columnsToMeasure: string[] = []; | ||
|
|
||
| for (const { key, idx, width } of viewportColumns) { | ||
| if ( | ||
| if (key === columnToAutoResize) { | ||
| newTemplateColumns[idx] = 'max-content'; | ||
| columnsToMeasure.push(key); | ||
| } else if ( | ||
| typeof width === 'string' && | ||
| (ignorePreviouslyMeasuredColumns || !measuredColumnWidths.has(key)) && | ||
| !resizedColumnWidths.has(key) | ||
|
|
@@ -40,62 +43,81 @@ export function useColumnWidths<R, SR>( | |
|
|
||
| useLayoutEffect(() => { | ||
| prevGridWidthRef.current = gridWidth; | ||
| updateMeasuredWidths(columnsToMeasure); | ||
| updateMeasuredAndResizedWidths(); | ||
| }); | ||
|
|
||
| function updateMeasuredWidths(columnsToMeasure: readonly string[]) { | ||
| if (columnsToMeasure.length === 0) return; | ||
|
nstepien marked this conversation as resolved.
|
||
| function updateMeasuredAndResizedWidths() { | ||
|
nstepien marked this conversation as resolved.
Outdated
|
||
| if (columnsToMeasure.length > 0) { | ||
| setMeasuredColumnWidths((measuredColumnWidths) => { | ||
| const newMeasuredColumnWidths = new Map(measuredColumnWidths); | ||
| let hasChanges = false; | ||
|
|
||
| for (const key of columnsToMeasure) { | ||
| const measuredWidth = measureColumnWidth(gridRef, key); | ||
| hasChanges ||= measuredWidth !== measuredColumnWidths.get(key); | ||
| if (measuredWidth === undefined) { | ||
| newMeasuredColumnWidths.delete(key); | ||
| } else { | ||
| newMeasuredColumnWidths.set(key, measuredWidth); | ||
| } | ||
| } | ||
|
|
||
| setMeasuredColumnWidths((measuredColumnWidths) => { | ||
| const newMeasuredColumnWidths = new Map(measuredColumnWidths); | ||
| let hasChanges = false; | ||
| return hasChanges ? newMeasuredColumnWidths : measuredColumnWidths; | ||
| }); | ||
| } | ||
|
|
||
| for (const key of columnsToMeasure) { | ||
| const measuredWidth = measureColumnWidth(gridRef, key); | ||
| hasChanges ||= measuredWidth !== measuredColumnWidths.get(key); | ||
| if (measuredWidth === undefined) { | ||
| newMeasuredColumnWidths.delete(key); | ||
| } else { | ||
| newMeasuredColumnWidths.set(key, measuredWidth); | ||
| if (columnToAutoResize !== null) { | ||
| setColumnToAutoResize(null); | ||
| setResizedColumnWidths((resizedColumnWidths) => { | ||
| const oldWidth = resizedColumnWidths.get(columnToAutoResize); | ||
| const newWidth = measureColumnWidth(gridRef, columnToAutoResize); | ||
| if (newWidth !== undefined && oldWidth !== newWidth) { | ||
| const newResizedColumnWidths = new Map(resizedColumnWidths); | ||
| newResizedColumnWidths.set(columnToAutoResize, newWidth); | ||
| onColumnResize?.(viewportColumns.find((c) => c.key === columnToAutoResize)!, newWidth); | ||
|
amanmahajan7 marked this conversation as resolved.
Outdated
|
||
| return newResizedColumnWidths; | ||
| } | ||
| } | ||
|
|
||
| return hasChanges ? newMeasuredColumnWidths : measuredColumnWidths; | ||
| }); | ||
| return resizedColumnWidths; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| function handleColumnResize(column: CalculatedColumn<R, SR>, nextWidth: number | 'max-content') { | ||
| const { key: resizingKey } = column; | ||
| const newTemplateColumns = [...templateColumns]; | ||
| const columnsToMeasure: string[] = []; | ||
|
|
||
| for (const { key, idx, width } of viewportColumns) { | ||
| if (resizingKey === key) { | ||
| const width = typeof nextWidth === 'number' ? `${nextWidth}px` : nextWidth; | ||
| newTemplateColumns[idx] = width; | ||
| } else if (columnsCanFlex && typeof width === 'string' && !resizedColumnWidths.has(key)) { | ||
| newTemplateColumns[idx] = width; | ||
| // remeasure all columns that can flex and are not resized by the user | ||
| for (const { key, width } of viewportColumns) { | ||
| if ( | ||
| columnsCanFlex && | ||
| resizingKey !== key && | ||
| typeof width === 'string' && | ||
| !resizedColumnWidths.has(key) | ||
| ) { | ||
| columnsToMeasure.push(key); | ||
| } | ||
| } | ||
|
|
||
| gridRef.current!.style.gridTemplateColumns = newTemplateColumns.join(' '); | ||
| const measuredWidth = | ||
| typeof nextWidth === 'number' ? nextWidth : measureColumnWidth(gridRef, resizingKey)!; | ||
| if (columnsToMeasure.length > 0) { | ||
|
nstepien marked this conversation as resolved.
Outdated
|
||
| setMeasuredColumnWidths((measuredColumnWidths) => { | ||
| const newMeasuredColumnWidths = new Map(measuredColumnWidths); | ||
| for (const columnKey of columnsToMeasure) { | ||
| newMeasuredColumnWidths.delete(columnKey); | ||
| } | ||
| return newMeasuredColumnWidths; | ||
| }); | ||
| } | ||
|
|
||
| // TODO: remove | ||
| // need flushSync to keep frozen column offsets in sync | ||
| // we may be able to use `startTransition` or even `requestIdleCallback` instead | ||
| flushSync(() => { | ||
| if (typeof nextWidth === 'number') { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure this will correctly handle min/max-widths
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like it does. I will double check
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It works. We should put the min/max logic in one place
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. min / max can't overflow in case styles are applied to the measurement cells correctly, and grid re-render is synchronized I also found it helpful to use styles instead of JS for this, especially when you can have different styles for cells while measuring (when we set its size to "max-content" for example, and will measure its size after): You want to have a grid with auto-sized columns but with some rules for this auto-size mechanism: But at the same time, you want to be able to resize columns to exceed these values manually. In this situation, you will have different rules for measured cells and while measuring: .measuringCellClassname {
min-width: 20px;
}
.measuringCellClassname[measuring] {
min-width: 50px;
max-width: 300px
}This is why I've added this code also:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We want the same rules for bot cases. If
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, but I'm talking about cases when you have not specified maxWidth for the column, and this |
||
| setResizedColumnWidths((resizedColumnWidths) => { | ||
| const newResizedColumnWidths = new Map(resizedColumnWidths); | ||
| newResizedColumnWidths.set(resizingKey, measuredWidth); | ||
| newResizedColumnWidths.set(resizingKey, nextWidth); | ||
| return newResizedColumnWidths; | ||
| }); | ||
| updateMeasuredWidths(columnsToMeasure); | ||
| }); | ||
|
|
||
| onColumnResize?.(column, measuredWidth); | ||
| onColumnResize?.(column, nextWidth); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will suggest moving the onColumnResize call to the setter function to have the same possible behavior for resizing |
||
| } else { | ||
| setColumnToAutoResize(resizingKey); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,48 +66,52 @@ test('cannot not resize or auto resize column when resizable is not specified', | |
| test('should resize column when dragging the handle', async () => { | ||
| const onColumnResize = vi.fn(); | ||
| setup<Row, unknown>({ columns, rows: [], onColumnResize }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| expect(onColumnResize).not.toHaveBeenCalled(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); | ||
| const [, col2] = getHeaderCells(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand, surely the cell element should still be in the document if we get it earlier, no?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It should be. I will investigate in a separate PR |
||
| await resize({ column: col2, resizeBy: -50 }); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 150px' }); | ||
| expect(onColumnResize).toHaveBeenCalledExactlyOnceWith(expect.objectContaining(columns[1]), 150); | ||
| }); | ||
|
|
||
| test('should use the maxWidth if specified', async () => { | ||
| setup<Row, unknown>({ columns, rows: [] }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px ' }); | ||
| const [, col2] = getHeaderCells(); | ||
| await resize({ column: col2, resizeBy: 1000 }); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 400px' }); | ||
| }); | ||
|
|
||
| test('should use the minWidth if specified', async () => { | ||
| setup<Row, unknown>({ columns, rows: [] }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); | ||
| const [, col2] = getHeaderCells(); | ||
| await resize({ column: col2, resizeBy: -150 }); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 100px' }); | ||
| }); | ||
|
|
||
| test('should auto resize column when resize handle is double clicked', async () => { | ||
| const onColumnResize = vi.fn(); | ||
| setup<Row, unknown>({ | ||
| columns, | ||
| rows: [ | ||
| { | ||
| col1: 1, | ||
| col2: 'a'.repeat(50) | ||
| } | ||
| ] | ||
| ], | ||
| onColumnResize | ||
| }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); | ||
| const [, col2] = getHeaderCells(); | ||
| await autoResize(col2); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 327.703px' }); | ||
| // This is called twice in strict mode | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should it? Do we not check to only call the function if the width has changed?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does call it twice. May be the state updates after the |
||
| expect(onColumnResize).toHaveBeenCalledWith(expect.objectContaining(columns[1]), 327.703125); | ||
| }); | ||
|
|
||
| test('should use the maxWidth if specified on auto resize', async () => { | ||
|
|
@@ -120,9 +124,9 @@ test('should use the maxWidth if specified on auto resize', async () => { | |
| } | ||
| ] | ||
| }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); | ||
| const [, col2] = getHeaderCells(); | ||
| await autoResize(col2); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 400px' }); | ||
| }); | ||
|
|
@@ -137,9 +141,58 @@ test('should use the minWidth if specified on auto resize', async () => { | |
| } | ||
| ] | ||
| }); | ||
| const [, col2] = getHeaderCells(); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); | ||
| const [, col2] = getHeaderCells(); | ||
| await autoResize(col2); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 100px' }); | ||
| }); | ||
|
|
||
| test('should remeasure flex columns when resizing a column', async () => { | ||
| const onColumnResize = vi.fn(); | ||
| setup< | ||
| { | ||
| readonly col1: string; | ||
| readonly col2: string; | ||
| readonly col3: string; | ||
| }, | ||
| unknown | ||
| >({ | ||
| columns: [ | ||
| { | ||
| key: 'col1', | ||
| name: 'col1', | ||
| resizable: true | ||
| }, | ||
| { | ||
| key: 'col2', | ||
| name: 'col2', | ||
| resizable: true | ||
| }, | ||
| { | ||
| key: 'col3', | ||
| name: 'col3', | ||
| resizable: true | ||
| } | ||
| ], | ||
| rows: [ | ||
| { | ||
| col1: 'a'.repeat(10), | ||
| col2: 'a'.repeat(10), | ||
| col3: 'a'.repeat(10) | ||
| } | ||
| ], | ||
| onColumnResize | ||
| }); | ||
| const grid = getGrid(); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '639.328px 639.328px 639.344px' }); | ||
| const [col1] = getHeaderCells(); | ||
| await autoResize(col1); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '79.1406px 919.422px 919.438px' }); | ||
| expect(onColumnResize).toHaveBeenCalled(); | ||
|
amanmahajan7 marked this conversation as resolved.
Outdated
|
||
| onColumnResize.mockClear(); | ||
| // onColumnResize is not called if width is not changed | ||
| await autoResize(col1); | ||
| await expect.element(grid).toHaveStyle({ gridTemplateColumns: '79.1406px 919.422px 919.438px' }); | ||
| expect(onColumnResize).not.toHaveBeenCalled(); | ||
|
amanmahajan7 marked this conversation as resolved.
Outdated
|
||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@amanmahajan7
I think you better not use ref for the previous grid width. There is a bug with the initial render when:
This issue is reflected in my video from this comment: #3723 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you check if this issues still exists in my PR? I think the issue you are describing was related to #3724 (comment)