From 95fdb38efbaf8b8e02c4a74c968a3317463a23b1 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 16 Mar 2026 01:14:06 +0000 Subject: [PATCH] Refactor column width and resizing handling --- README.md | 16 +- src/DataGrid.tsx | 109 +++---- src/HeaderCell.tsx | 19 +- src/hooks/useCalculatedColumns.ts | 118 +------- src/hooks/useColumnWidths.ts | 145 --------- src/hooks/useColumnWidths.tsx | 314 ++++++++++++++++++++ src/hooks/useGridDimensions.ts | 15 +- src/types.ts | 18 +- src/utils/renderMeasuringCells.tsx | 10 +- src/utils/styleUtils.ts | 7 +- test/browser/column/renderEditCell.test.tsx | 64 ++-- test/browser/column/resizable.test.tsx | 47 +-- test/browser/scrollToCell.test.tsx | 42 +-- test/browser/virtualization.test.ts | 17 +- website/routes/CommonFeatures.tsx | 8 +- 15 files changed, 514 insertions(+), 435 deletions(-) delete mode 100644 src/hooks/useColumnWidths.ts create mode 100644 src/hooks/useColumnWidths.tsx diff --git a/README.md b/README.md index d8f15085a9..55b5d55268 100644 --- a/README.md +++ b/README.md @@ -1957,12 +1957,18 @@ interface GroupRow { A map of column widths. ```tsx -type ColumnWidths = ReadonlyMap; +type ColumnWidth = + | { + readonly type: 'measured' | 'resizing' | 'resized'; + readonly width: number; + } + | { + readonly type: 'autosizing'; + readonly width: 'max-content'; + readonly onMeasure: (width: number) => void; + }; -interface ColumnWidth { - readonly type: 'resized' | 'measured'; - readonly width: number; -} +type ColumnWidths = ReadonlyMap; ``` Used with `columnWidths` and `onColumnWidthsChange` props to control column widths externally. diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 536fdc43e1..da5f40a3d9 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react'; +import { useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react'; import type { Key, KeyboardEvent } from 'react'; import { flushSync } from 'react-dom'; @@ -308,53 +308,40 @@ export function DataGrid(props: DataGridPr */ const [scrollTop, setScrollTop] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); - const [columnWidthsInternal, setColumnWidthsInternal] = useState( - (): ColumnWidths => columnWidthsRaw ?? new Map() - ); - const [isColumnResizing, setIsColumnResizing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [draggedOverRowIdx, setDraggedOverRowIdx] = useState(undefined); const [scrollToPosition, setScrollToPosition] = useState(null); const [shouldFocusPosition, setShouldFocusPosition] = useState(false); const [previousRowIdx, setPreviousRowIdx] = useState(-1); - const isColumnWidthsControlled = - columnWidthsRaw != null && onColumnWidthsChangeRaw != null && !isColumnResizing; - const columnWidths = isColumnWidthsControlled ? columnWidthsRaw : columnWidthsInternal; - const onColumnWidthsChange = isColumnWidthsControlled - ? (columnWidths: ColumnWidths) => { - // we keep the internal state in sync with the prop but this prevents an extra render - setColumnWidthsInternal(columnWidths); - onColumnWidthsChangeRaw(columnWidths); - } - : setColumnWidthsInternal; + const [gridRef, gridWidth, gridHeight, isResizingWidth] = useGridDimensions(); - const getColumnWidth = useCallback( - (column: CalculatedColumn) => { - return columnWidths.get(column.key)?.width ?? column.width; - }, - [columnWidths] - ); + const { columns, colSpanColumns, lastFrozenColumnIndex, headerRowsCount } = useCalculatedColumns({ + rawColumns, + defaultColumnOptions + }); - const [gridRef, gridWidth, gridHeight] = useGridDimensions(); const { - columns, - colSpanColumns, - lastFrozenColumnIndex, - headerRowsCount, colOverscanStartIdx, colOverscanEndIdx, - templateColumns, + totalFrozenColumnWidth, layoutCssVars, - totalFrozenColumnWidth - } = useCalculatedColumns({ - rawColumns, - defaultColumnOptions, - getColumnWidth, + columnMetrics, + observeMeasuringCellRef, + handleColumnResizeLatest, + handleColumnResizeEndLatest + } = useColumnWidths( + gridRef, + columns, + lastFrozenColumnIndex, + gridWidth, scrollLeft, - viewportWidth: gridWidth, - enableVirtualization - }); + isResizingWidth, + enableVirtualization, + columnWidthsRaw, + onColumnResize, + onColumnWidthsChangeRaw + ); /** * computed values @@ -466,23 +453,9 @@ export function DataGrid(props: DataGridPr bottomSummaryRows }); - const { gridTemplateColumns, handleColumnResize } = useColumnWidths( - columns, - viewportColumns, - templateColumns, - gridRef, - gridWidth, - columnWidths, - onColumnWidthsChange, - onColumnResize, - setIsColumnResizing - ); - /** * The identity of the wrapper function is stable so it won't break memoization */ - const handleColumnResizeLatest = useLatestFunc(handleColumnResize); - const handleColumnResizeEndLatest = useLatestFunc(handleColumnResizeEnd); const onColumnsReorderLastest = useLatestFunc(onColumnsReorder); const onSortColumnsChangeLatest = useLatestFunc(onSortColumnsChange); const onCellMouseDownLatest = useLatestFunc(onCellMouseDown); @@ -695,21 +668,17 @@ export function DataGrid(props: DataGridPr } if (isCellEditable(activePosition) && isDefaultCellInput(event, onCellPaste != null)) { - setActivePosition(({ idx, rowIdx }) => ({ - idx, - rowIdx, - mode: 'EDIT', - row, - originalRow: row - })); - } - } - - function handleColumnResizeEnd() { - // This check is needed as double click on the resize handle triggers onPointerMove - if (isColumnResizing) { - onColumnWidthsChangeRaw?.(columnWidths); - setIsColumnResizing(false); + // ensure we render the editor quickly enough, + // otherwise the user input might be lost in Firefox + flushSync(() => { + setActivePosition(({ idx, rowIdx }) => ({ + idx, + rowIdx, + mode: 'EDIT', + row, + originalRow: row + })); + }); } } @@ -947,8 +916,9 @@ export function DataGrid(props: DataGridPr } const isLastRow = rowIdx === maxRowIdx; - const columnWidth = getColumnWidth(column); - const colSpan = column.colSpan?.({ type: 'ROW', row: getActiveRow() }) ?? 1; + const columnWidth = columnMetrics.get(column)?.width ?? 0; + const colSpan = + getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row: getActiveRow() }) ?? 1; const { insetInlineStart, ...style } = getCellStyle(column, colSpan); const marginEnd = 'calc(var(--rdg-drag-handle-size) * -0.5 + 1px)'; const isLastColumn = column.idx + colSpan - 1 === maxColIdx; @@ -1089,10 +1059,6 @@ export function DataGrid(props: DataGridPr } // Keep the state and prop in sync - if (isColumnWidthsControlled && columnWidthsInternal !== columnWidthsRaw) { - setColumnWidthsInternal(columnWidthsRaw); - } - let templateRows = `repeat(${headerRowsCount}, ${headerRowHeight}px)`; if (topSummaryRowsCount > 0) { templateRows += ` repeat(${topSummaryRowsCount}, ${summaryRowHeight}px)`; @@ -1124,7 +1090,6 @@ export function DataGrid(props: DataGridPr scrollPaddingInlineStart: totalFrozenColumnWidth, scrollPaddingBlockStart: headerRowsHeight + topSummaryRowsCount * summaryRowHeight, scrollPaddingBlockEnd: bottomSummaryRowsCount * summaryRowHeight, - gridTemplateColumns, gridTemplateRows: templateRows, '--rdg-header-row-height': `${headerRowHeight}px`, ...layoutCssVars @@ -1279,7 +1244,7 @@ export function DataGrid(props: DataGridPr {getDragHandle()} {/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */} - {renderMeasuringCells(viewportColumns)} + {renderMeasuringCells(viewportColumns, observeMeasuringCellRef)} {scrollToPosition !== null && ( ({ setDraggedColumnKey }: HeaderCellProps) { const [isOver, setIsOver] = useState(false); + const resizingRef = useRef(false); const dragImageRef = useRef(null); const isDragging = draggedColumnKey === column.key; const rowSpan = getHeaderCellRowSpan(column, rowIdx); @@ -195,6 +196,7 @@ export default function HeaderCell({ // prevent navigation // TODO: check if we can use `preventDefault` instead event.stopPropagation(); + resizingRef.current = true; const { width } = event.currentTarget.getBoundingClientRect(); const { leftKey } = getLeftRightKey(direction); const offset = key === leftKey ? -10 : 10; @@ -205,6 +207,13 @@ export default function HeaderCell({ } } + function onKeyUp() { + if (resizingRef.current) { + resizingRef.current = false; + onColumnResizeEnd(); + } + } + function onDragStart(event: React.DragEvent) { // need flushSync to make sure the drag image is rendered before the drag starts flushSync(() => { @@ -299,6 +308,7 @@ export default function HeaderCell({ onFocus={handleFocus} onClick={onClick} onKeyDown={onKeyDown} + onKeyUp={onKeyUp} {...dragTargetProps} {...dropTargetProps} > @@ -328,6 +338,7 @@ function ResizeHandle({ onColumnResize, onColumnResizeEnd }: ResizeHandleProps) { + const resizingRef = useRef(false); const resizingOffsetRef = useRef(undefined); const isRtl = direction === 'rtl'; @@ -349,6 +360,7 @@ function ResizeHandle({ function onPointerMove(event: React.PointerEvent) { const offset = resizingOffsetRef.current; if (offset === undefined) return; + resizingRef.current = true; const { width, right, left } = event.currentTarget.parentElement!.getBoundingClientRect(); let newWidth = isRtl ? right + offset - event.clientX : event.clientX + offset - left; newWidth = clampColumnWidth(newWidth, column); @@ -358,7 +370,12 @@ function ResizeHandle({ } function onLostPointerCapture() { - onColumnResizeEnd(); + // avoid calling onColumnResizeEnd if the pointer has not moved after pointed down, + // also to avoid conflicts with double-clicking + if (resizingRef.current) { + resizingRef.current = false; + onColumnResizeEnd(); + } resizingOffsetRef.current = undefined; } diff --git a/src/hooks/useCalculatedColumns.ts b/src/hooks/useCalculatedColumns.ts index 9929d91a17..0a23c80559 100644 --- a/src/hooks/useCalculatedColumns.ts +++ b/src/hooks/useCalculatedColumns.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; -import { clampColumnWidth, max, min } from '../utils'; import type { CalculatedColumn, CalculatedColumnParent, ColumnOrColumnGroup, Omit } from '../types'; import { renderValue } from '../cellRenderers'; import { SELECT_COLUMN_KEY } from '../Columns'; @@ -20,30 +19,17 @@ type MutableCalculatedColumnParent = Omit = Omit>, 'parent'> & WithParent; -interface ColumnMetric { - width: number; - left: number; -} - const DEFAULT_COLUMN_WIDTH = 'auto'; const DEFAULT_COLUMN_MIN_WIDTH = 50; interface CalculatedColumnsArgs { rawColumns: readonly ColumnOrColumnGroup[]; defaultColumnOptions: DataGridProps['defaultColumnOptions']; - viewportWidth: number; - scrollLeft: number; - getColumnWidth: (column: CalculatedColumn) => string | number; - enableVirtualization: boolean; } export function useCalculatedColumns({ rawColumns, - defaultColumnOptions, - getColumnWidth, - viewportWidth, - scrollLeft, - enableVirtualization + defaultColumnOptions }: CalculatedColumnsArgs) { const defaultWidth = defaultColumnOptions?.width ?? DEFAULT_COLUMN_WIDTH; const defaultMinWidth = defaultColumnOptions?.minWidth ?? DEFAULT_COLUMN_MIN_WIDTH; @@ -162,111 +148,11 @@ export function useCalculatedColumns({ defaultDraggable ]); - const { templateColumns, layoutCssVars, totalFrozenColumnWidth, columnMetrics } = useMemo((): { - templateColumns: readonly string[]; - layoutCssVars: Readonly>; - totalFrozenColumnWidth: number; - columnMetrics: ReadonlyMap, ColumnMetric>; - } => { - const columnMetrics = new Map, ColumnMetric>(); - let left = 0; - let totalFrozenColumnWidth = 0; - const templateColumns: string[] = []; - - for (const column of columns) { - let width = getColumnWidth(column); - - if (typeof width === 'number') { - width = clampColumnWidth(width, column); - } else { - // This is a placeholder width so we can continue to use virtualization. - // The actual value is set after the column is rendered - width = column.minWidth; - } - templateColumns.push(`${width}px`); - columnMetrics.set(column, { width, left }); - left += width; - } - - if (lastFrozenColumnIndex !== -1) { - const columnMetric = columnMetrics.get(columns[lastFrozenColumnIndex])!; - totalFrozenColumnWidth = columnMetric.left + columnMetric.width; - } - - const layoutCssVars: Record = {}; - - for (let i = 0; i <= lastFrozenColumnIndex; i++) { - const column = columns[i]; - layoutCssVars[`--rdg-frozen-left-${column.idx}`] = `${columnMetrics.get(column)!.left}px`; - } - - return { templateColumns, layoutCssVars, totalFrozenColumnWidth, columnMetrics }; - }, [getColumnWidth, columns, lastFrozenColumnIndex]); - - const [colOverscanStartIdx, colOverscanEndIdx] = useMemo((): [number, number] => { - if (!enableVirtualization) { - return [0, columns.length - 1]; - } - // get the viewport's left side and right side positions for non-frozen columns - const viewportLeft = scrollLeft + totalFrozenColumnWidth; - const viewportRight = scrollLeft + viewportWidth; - // get first and last non-frozen column indexes - const lastColIdx = columns.length - 1; - const firstUnfrozenColumnIdx = min(lastFrozenColumnIndex + 1, lastColIdx); - - // skip rendering non-frozen columns if the frozen columns cover the entire viewport - if (viewportLeft >= viewportRight) { - return [firstUnfrozenColumnIdx, firstUnfrozenColumnIdx]; - } - - // get the first visible non-frozen column index - let colVisibleStartIdx = firstUnfrozenColumnIdx; - while (colVisibleStartIdx < lastColIdx) { - const { left, width } = columnMetrics.get(columns[colVisibleStartIdx])!; - // if the right side of the columnn is beyond the left side of the available viewport, - // then it is the first column that's at least partially visible - if (left + width > viewportLeft) { - break; - } - colVisibleStartIdx++; - } - - // get the last visible non-frozen column index - let colVisibleEndIdx = colVisibleStartIdx; - while (colVisibleEndIdx < lastColIdx) { - const { left, width } = columnMetrics.get(columns[colVisibleEndIdx])!; - // if the right side of the column is beyond or equal to the right side of the available viewport, - // then it the last column that's at least partially visible, as the previous column's right side is not beyond the viewport. - if (left + width >= viewportRight) { - break; - } - colVisibleEndIdx++; - } - - const colOverscanStartIdx = max(firstUnfrozenColumnIdx, colVisibleStartIdx - 1); - const colOverscanEndIdx = min(lastColIdx, colVisibleEndIdx + 1); - - return [colOverscanStartIdx, colOverscanEndIdx]; - }, [ - columnMetrics, - columns, - lastFrozenColumnIndex, - scrollLeft, - totalFrozenColumnWidth, - viewportWidth, - enableVirtualization - ]); - return { columns, colSpanColumns, - colOverscanStartIdx, - colOverscanEndIdx, - templateColumns, - layoutCssVars, headerRowsCount, - lastFrozenColumnIndex, - totalFrozenColumnWidth + lastFrozenColumnIndex }; } diff --git a/src/hooks/useColumnWidths.ts b/src/hooks/useColumnWidths.ts deleted file mode 100644 index 5a2388504d..0000000000 --- a/src/hooks/useColumnWidths.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useLayoutEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; - -import type { CalculatedColumn, ColumnWidths, ResizedWidth } from '../types'; -import type { DataGridProps } from '../DataGrid'; - -export function useColumnWidths( - columns: readonly CalculatedColumn[], - viewportColumns: readonly CalculatedColumn[], - templateColumns: readonly string[], - gridRef: React.RefObject, - gridWidth: number, - columnWidths: ColumnWidths, - onColumnWidthsChange: (columnWidths: ColumnWidths) => void, - onColumnResize: DataGridProps['onColumnResize'], - setColumnResizing: (isColumnResizing: boolean) => void -) { - const [columnToAutoResize, setColumnToAutoResize] = useState<{ - readonly key: string; - readonly width: ResizedWidth; - } | null>(null); - const [columnsToMeasureOnResize, setColumnsToMeasureOnResize] = - useState | null>(null); - const [prevGridWidth, setPrevGridWidth] = useState(gridWidth); - const columnsCanFlex: boolean = columns.length === viewportColumns.length; - const ignorePreviouslyMeasuredColumnsOnGridWidthChange = - // Allow columns to flex again when... - columnsCanFlex && - // there is enough space for columns to flex and the grid was resized - gridWidth !== prevGridWidth; - const newTemplateColumns = [...templateColumns]; - const columnsToMeasure: string[] = []; - - for (const { key, idx, width } of viewportColumns) { - const columnWidth = columnWidths.get(key); - if (key === columnToAutoResize?.key) { - newTemplateColumns[idx] = - columnToAutoResize.width === 'max-content' - ? columnToAutoResize.width - : `${columnToAutoResize.width}px`; - columnsToMeasure.push(key); - } else if ( - typeof width === 'string' && - // If the column is resized by the user, we don't want to measure it again - columnWidth?.type !== 'resized' && - (ignorePreviouslyMeasuredColumnsOnGridWidthChange || - columnsToMeasureOnResize?.has(key) === true || - columnWidth === undefined) - ) { - newTemplateColumns[idx] = width; - columnsToMeasure.push(key); - } - } - - const gridTemplateColumns = newTemplateColumns.join(' '); - - useLayoutEffect(updateMeasuredAndResizedWidths); - - function updateMeasuredAndResizedWidths() { - setPrevGridWidth(gridWidth); - if (columnsToMeasure.length === 0) return; - - const newColumnWidths = new Map(columnWidths); - let hasChanges = false; - - for (const key of columnsToMeasure) { - const measuredWidth = measureColumnWidth(gridRef, key); - hasChanges ||= measuredWidth !== columnWidths.get(key)?.width; - if (measuredWidth === undefined) { - newColumnWidths.delete(key); - } else { - newColumnWidths.set(key, { type: 'measured', width: measuredWidth }); - } - } - - if (columnToAutoResize !== null) { - const resizingKey = columnToAutoResize.key; - const oldWidth = columnWidths.get(resizingKey)?.width; - const newWidth = measureColumnWidth(gridRef, resizingKey); - if (newWidth !== undefined && oldWidth !== newWidth) { - hasChanges = true; - newColumnWidths.set(resizingKey, { - type: 'resized', - width: newWidth - }); - } - setColumnToAutoResize(null); - } - - if (hasChanges) { - onColumnWidthsChange(newColumnWidths); - } - } - - function handleColumnResize(column: CalculatedColumn, nextWidth: ResizedWidth) { - const { key: resizingKey } = column; - - flushSync(() => { - if (columnsCanFlex) { - // remeasure all the columns that can flex and are not resized by the user - const columnsToRemeasure = new Set(); - for (const { key, width } of viewportColumns) { - if ( - resizingKey !== key && - typeof width === 'string' && - columnWidths.get(key)?.type !== 'resized' - ) { - columnsToRemeasure.add(key); - } - } - - setColumnsToMeasureOnResize(columnsToRemeasure); - } - - setColumnToAutoResize({ - key: resizingKey, - width: nextWidth - }); - - setColumnResizing(typeof nextWidth === 'number'); - }); - - setColumnsToMeasureOnResize(null); - - if (onColumnResize) { - const previousWidth = columnWidths.get(resizingKey)?.width; - const newWidth = - typeof nextWidth === 'number' ? nextWidth : measureColumnWidth(gridRef, resizingKey); - if (newWidth !== undefined && newWidth !== previousWidth) { - onColumnResize(column, newWidth); - } - } - } - - return { - gridTemplateColumns, - handleColumnResize - } as const; -} - -function measureColumnWidth(gridRef: React.RefObject, key: string) { - const selector = `[data-measuring-cell-key="${CSS.escape(key)}"]`; - const measuringCell = gridRef.current?.querySelector(selector); - return measuringCell?.getBoundingClientRect().width; -} diff --git a/src/hooks/useColumnWidths.tsx b/src/hooks/useColumnWidths.tsx new file mode 100644 index 0000000000..01a06a2359 --- /dev/null +++ b/src/hooks/useColumnWidths.tsx @@ -0,0 +1,314 @@ +import { useCallback, useMemo, useSyncExternalStore, type RefObject } from 'react'; + +import { clampColumnWidth, max, min } from '../utils'; +import type { CalculatedColumn, ColumnWidths, ResizedWidth } from '../types'; +import { useLatestFunc } from './useLatestFunc'; +import type { DataGridProps } from '../DataGrid'; + +interface ColumnMetric { + readonly width: number; + readonly right: number; +} + +const initialWidthsMap: ColumnWidths = new Map(); + +// use unmanaged WeakMaps so we preserve the cache even when +// the component partially unmounts via Suspense or Activity +const cellToGridRefMap = new WeakMap>(); +const gridRefToWidthsMap = new WeakMap, ColumnWidths>(); +const subscribers = new Map, () => void>(); + +// don't break in Node.js (SSR), jsdom, and environments that don't support ResizeObserver +const resizeObserver = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + globalThis.ResizeObserver == null ? null : new ResizeObserver(resizeObserverCallback); + +function resizeObserverCallback(entries: ResizeObserverEntry[]) { + const updatedGrids = new Set>(); + + for (const entry of entries) { + const cell = entry.target as HTMLDivElement; + + if (!cellToGridRefMap.has(cell)) continue; + + const gridRef = cellToGridRefMap.get(cell)!; + const key = cell.dataset.measuringCellKey!; + const width = entry.contentBoxSize[0].inlineSize; + + const widthsMap = new Map(gridRefToWidthsMap.get(gridRef)); + const columnWidth = widthsMap.get(key); + + // `autosizing` -> immediately `resized` + // `resizing` -> remains `resizing` until the end of the user action + // `resized` -> remains `resized`, may happen after external width changes + // `measured` otherwise + const type = columnWidth?.type === 'autosizing' ? 'resized' : (columnWidth?.type ?? 'measured'); + if (columnWidth?.type === 'autosizing') { + columnWidth.onMeasure(width); + } + + widthsMap.set(key, { type, width }); + gridRefToWidthsMap.set(gridRef, widthsMap); + updatedGrids.add(gridRef); + } + + for (const gridRef of updatedGrids) { + subscribers.get(gridRef)?.(); + } +} + +function getServerSnapshot(): ColumnWidths { + return initialWidthsMap; +} + +export function useColumnWidths( + gridRef: React.RefObject, + columns: readonly CalculatedColumn[], + lastFrozenColumnIndex: number, + gridWidth: number, + scrollLeft: number, + isResizingWidth: boolean, + enableVirtualization: boolean, + columnWidthsRaw: DataGridProps['columnWidths'], + onColumnResize: DataGridProps['onColumnResize'], + onColumnWidthsChangeRaw: DataGridProps['onColumnWidthsChange'] +) { + const subscribe = useCallback( + (onStoreChange: () => void) => { + subscribers.set(gridRef, onStoreChange); + + return () => { + subscribers.delete(gridRef); + }; + }, + [gridRef] + ); + + const getSnapshot = useCallback((): ColumnWidths => { + // ref.current is null during the initial render, when suspending, or in . + // We use ref as key instead to access stable values regardless of rendering state. + return gridRefToWidthsMap.get(gridRef) ?? initialWidthsMap; + }, [gridRef]); + + const widthsMap = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const { columnMetrics, totalColumnWidth, totalFrozenColumnWidth, layoutCssVars } = useMemo((): { + columnMetrics: ReadonlyMap, ColumnMetric>; + totalColumnWidth: number; + totalFrozenColumnWidth: number; + layoutCssVars: Readonly; + } => { + const gridTemplateColumns: string[] = []; + const columnMetrics = new Map, ColumnMetric>(); + let left = 0; + let totalColumnWidth = 0; + let totalFrozenColumnWidth = 0; + const layoutCssVars: React.CSSProperties = {}; + + const isRevalidatingWidths = + isResizingWidth || + widthsMap.values().some((item) => item.type === 'resizing' || item.type === 'autosizing'); + + for (const column of columns) { + const { key, idx, minWidth, maxWidth } = column; + const internalWidthItem = widthsMap.get(key); + const userWidthItem = columnWidthsRaw?.get(key); + const widthItem = + internalWidthItem?.type === 'resizing' || internalWidthItem?.type === 'autosizing' + ? internalWidthItem + : (userWidthItem ?? internalWidthItem); + // resize columns when resizing the grid, + // but preserve manually resized/resizing column widths + const width = + widthItem != null && (!isRevalidatingWidths || widthItem.type !== 'measured') + ? widthItem.width + : column.width; + + // This represents the width that will be used to compute virtualization. + // Use the previously measured width if available, otherwise width or minWidth. + const resolvedWidth: number = + typeof widthItem?.width === 'number' + ? widthItem.width + : typeof column.width === 'number' + ? clampColumnWidth(column.width, column) + : column.minWidth; + + if (typeof width === 'number') { + gridTemplateColumns.push(`${width}px`); + } else if (width === 'auto') { + gridTemplateColumns.push( + typeof maxWidth === 'number' + ? `minmax(auto, ${maxWidth}px)` + : `minmax(${minWidth}px, auto)` + ); + } else { + gridTemplateColumns.push(width); + } + + if (column.frozen) { + totalFrozenColumnWidth += resolvedWidth; + layoutCssVars[`--rdg-frozen-left-${idx}`] = `${left}px`; + } + + totalColumnWidth += resolvedWidth; + columnMetrics.set(column, { width: resolvedWidth, right: left + resolvedWidth }); + left += resolvedWidth; + } + + layoutCssVars.gridTemplateColumns = gridTemplateColumns.join(' '); + + return { + columnMetrics, + totalColumnWidth, + totalFrozenColumnWidth, + layoutCssVars + }; + }, [widthsMap, columnWidthsRaw, isResizingWidth, columns]); + + const renderAllColumns = !enableVirtualization || totalColumnWidth <= gridWidth; + + const [colOverscanStartIdx, colOverscanEndIdx] = useMemo((): [number, number] => { + const lastColumnIndex = columns.length - 1; + + // render frozen columns only when all columns are frozen, + // or when frozen columns cover the entire viewport + if (lastColumnIndex === lastFrozenColumnIndex || totalFrozenColumnWidth >= gridWidth) { + return [0, -1]; + } + + // get first and last non-frozen column indexes + const firstUnfrozenColumnIdx = lastFrozenColumnIndex + 1; + + // render all columns + if (renderAllColumns) { + return [firstUnfrozenColumnIdx, lastColumnIndex]; + } + + // get the viewport's left side and right side positions for non-frozen columns + const viewportLeft = scrollLeft + totalFrozenColumnWidth; + const viewportRight = scrollLeft + gridWidth; + + // get the first visible non-frozen column index + let colOverscanStartIdx = firstUnfrozenColumnIdx; + while (colOverscanStartIdx < lastColumnIndex) { + const { right } = columnMetrics.get(columns[colOverscanStartIdx])!; + // if the right side of the columnn is beyond the left side of the available viewport, + // then it is the first column that's at least partially visible + if (right > viewportLeft) { + break; + } + colOverscanStartIdx++; + } + + // get the last visible non-frozen column index + let colOverscanEndIdx = colOverscanStartIdx; + while (colOverscanEndIdx < lastColumnIndex) { + const { right } = columnMetrics.get(columns[colOverscanEndIdx])!; + // if the right side of the column is beyond or equal to the right side of the available viewport, + // then it the last column that's at least partially visible, as the previous column's right side is not beyond the viewport. + if (right >= viewportRight) { + break; + } + colOverscanEndIdx++; + } + + return [ + max(firstUnfrozenColumnIdx, colOverscanStartIdx - 1), + min(lastColumnIndex, colOverscanEndIdx + 1) + ]; + }, [ + columnMetrics, + columns, + gridWidth, + lastFrozenColumnIndex, + renderAllColumns, + scrollLeft, + totalFrozenColumnWidth + ]); + + const observeMeasuringCellRef = useCallback( + (cell: HTMLDivElement) => { + cellToGridRefMap.set(cell, gridRef); + resizeObserver?.observe(cell); + + return () => { + resizeObserver?.unobserve(cell); + }; + }, + [gridRef] + ); + + const handleColumnResizeLatest = useLatestFunc( + (column: CalculatedColumn, nextWidth: ResizedWidth) => { + const { key } = column; + + const widthsMap = new Map(gridRefToWidthsMap.get(gridRef)); + const previousWidth = (columnWidthsRaw?.get(key) ?? widthsMap.get(key))?.width; + const { promise, resolve } = Promise.withResolvers(); + + widthsMap.set( + key, + typeof nextWidth === 'number' + ? { type: 'resizing', width: nextWidth } + : { type: 'autosizing', width: nextWidth, onMeasure: resolve } + ); + + gridRefToWidthsMap.set(gridRef, widthsMap); + + subscribers.get(gridRef)?.(); + + if (typeof nextWidth === 'string') { + // force the observer to re-measure the cell + // this is necessary if the nextWidth is the same as the previous width + // ResizeObserver won't trigger if the size doesn't change + const cell = gridRef.current!.querySelector( + `:scope > [data-measuring-cell-key="${CSS.escape(key)}"]` + )!; + resizeObserver?.unobserve(cell); + resizeObserver?.observe(cell); + // alternatively, set up a new ResizeObserver just for this measurement + // and immediately disconnect it after the first callback + + promise.then((newWidth) => { + if (newWidth !== previousWidth) { + onColumnResize?.(column, newWidth); + onColumnWidthsChangeRaw?.(getSnapshot()); + } + }); + } else if (nextWidth !== previousWidth) { + onColumnResize?.(column, nextWidth); + } + } + ); + + const handleColumnResizeEndLatest = useLatestFunc(() => { + const widthsMap = new Map(gridRefToWidthsMap.get(gridRef)); + let hasUpdated = false; + + for (const [key, widthItem] of widthsMap) { + if (widthItem.type === 'resizing') { + widthsMap.set(key, { type: 'resized', width: widthItem.width }); + hasUpdated = true; + } + } + + if (!hasUpdated) return; + + gridRefToWidthsMap.set(gridRef, widthsMap); + + subscribers.get(gridRef)?.(); + + onColumnWidthsChangeRaw?.(widthsMap); + }); + + return { + colOverscanStartIdx, + colOverscanEndIdx, + totalFrozenColumnWidth, + layoutCssVars, + columnMetrics, + observeMeasuringCellRef, + handleColumnResizeLatest, + handleColumnResizeEndLatest + } as const; +} diff --git a/src/hooks/useGridDimensions.ts b/src/hooks/useGridDimensions.ts index 907d6f02e3..d4ec10836c 100644 --- a/src/hooks/useGridDimensions.ts +++ b/src/hooks/useGridDimensions.ts @@ -1,10 +1,12 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useDeferredValue, useLayoutEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; export function useGridDimensions() { const gridRef = useRef(null); const [inlineSize, setInlineSize] = useState(1); const [blockSize, setBlockSize] = useState(1); + const deferredInlineSize = useDeferredValue(inlineSize, -1); + const isResizingWidth = inlineSize !== deferredInlineSize; useLayoutEffect(() => { const { ResizeObserver } = window; @@ -13,10 +15,9 @@ export function useGridDimensions() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ResizeObserver == null) return; - const { clientWidth, clientHeight } = gridRef.current!; - - setInlineSize(clientWidth); - setBlockSize(clientHeight); + const grid = gridRef.current!; + setInlineSize(grid.clientWidth); + setBlockSize(grid.clientHeight); const resizeObserver = new ResizeObserver((entries) => { const size = entries[0].contentBoxSize[0]; @@ -27,12 +28,12 @@ export function useGridDimensions() { setBlockSize(size.blockSize); }); }); - resizeObserver.observe(gridRef.current!); + resizeObserver.observe(grid); return () => { resizeObserver.disconnect(); }; }, []); - return [gridRef, inlineSize, blockSize] as const; + return [gridRef, inlineSize, blockSize, isResizingWidth] as const; } diff --git a/src/types.ts b/src/types.ts index a981e4d915..50a9733513 100644 --- a/src/types.ts +++ b/src/types.ts @@ -373,13 +373,19 @@ export interface SetActivePositionOptions { shouldFocus?: Maybe; } -export interface ColumnWidth { - readonly type: 'resized' | 'measured'; - readonly width: number; -} +export type ColumnWidth = + | { + readonly type: 'measured' | 'resizing' | 'resized'; + readonly width: number; + } + | { + readonly type: 'autosizing'; + readonly width: 'max-content'; + readonly onMeasure: (width: number) => void; + }; export type ColumnWidths = ReadonlyMap; -export type Direction = 'ltr' | 'rtl'; +export type ResizedWidth = ColumnWidth['width']; -export type ResizedWidth = number | 'max-content'; +export type Direction = 'ltr' | 'rtl'; diff --git a/src/utils/renderMeasuringCells.tsx b/src/utils/renderMeasuringCells.tsx index e26a0807f5..ee28a7e3fc 100644 --- a/src/utils/renderMeasuringCells.tsx +++ b/src/utils/renderMeasuringCells.tsx @@ -10,12 +10,16 @@ const measuringCellClassname = css` } `; -export function renderMeasuringCells(viewportColumns: readonly CalculatedColumn[]) { - return viewportColumns.map(({ key, idx, minWidth, maxWidth }) => ( +export function renderMeasuringCells( + viewportColumns: readonly CalculatedColumn[], + observeMeasuringCellRef: (cell: HTMLDivElement) => () => void +) { + return viewportColumns.map(({ key, idx }) => (
)); diff --git a/src/utils/styleUtils.ts b/src/utils/styleUtils.ts index cfc720e69a..37d0e4bc4b 100644 --- a/src/utils/styleUtils.ts +++ b/src/utils/styleUtils.ts @@ -34,7 +34,12 @@ export function getCellStyle( return { gridColumnStart: index, gridColumnEnd: index + colSpan, - insetInlineStart: column.frozen ? `var(--rdg-frozen-left-${column.idx})` : undefined + insetInlineStart: column.frozen ? `var(--rdg-frozen-left-${column.idx})` : undefined, + // minWidth/maxWidth constraints must be set on all cells for auto/min-content/max-content to work correctly, + // otherwise when auto-sizing a column, its width may be greater than `max-width`, + // leaving less room for other columns to grow, which in turn will not be adjusted correctly. + minWidth: column.minWidth, + maxWidth: colSpan === 1 ? column.maxWidth : undefined }; } diff --git a/test/browser/column/renderEditCell.test.tsx b/test/browser/column/renderEditCell.test.tsx index 6cdc712ccc..647a0207cd 100644 --- a/test/browser/column/renderEditCell.test.tsx +++ b/test/browser/column/renderEditCell.test.tsx @@ -18,54 +18,67 @@ interface Row { describe('Editor', () => { it('should open editor on double click', async () => { await page.render(); - await userEvent.click(getCellsAtRowIndex(0).nth(0)); + const cell = getCellsAtRowIndex(0).nth(0); + await userEvent.click(cell); await expect.element(col1Editor).not.toBeInTheDocument(); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(0)); + await userEvent.dblClick(cell); await expect.element(col1Editor).toHaveValue(1); await userEvent.keyboard('2'); await safeTab(); await expect.element(col1Editor).not.toBeInTheDocument(); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^12$/); + await expect.element(cell).toHaveTextContent(/^12$/); }); it('should open and commit changes on enter', async () => { await page.render(); - await userEvent.click(getCellsAtRowIndex(0).nth(0)); + const cell = getCellsAtRowIndex(0).nth(0); + await userEvent.click(cell); await expect.element(col1Editor).not.toBeInTheDocument(); await userEvent.keyboard('{enter}'); await expect.element(col1Editor).toHaveValue(1); await userEvent.keyboard('3{enter}'); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^13$/); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveFocus(); + await expect.element(cell).toHaveTextContent(/^13$/); + await expect.element(cell).toHaveFocus(); await expect.element(col1Editor).not.toBeInTheDocument(); }); it('should open editor when user types', async () => { await page.render(); - await userEvent.click(getCellsAtRowIndex(0).nth(0)); - // TODO: await userEvent.keyboard('123{enter}'); fails in FF - await userEvent.keyboard('{enter}123{enter}'); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^1123$/); + const cell = getCellsAtRowIndex(0).nth(0); + await userEvent.click(cell); + await userEvent.keyboard('123{enter}'); + await expect.element(cell).toHaveTextContent(/^1123$/); + }); + + it.fails('should open editor when test runs userEvent.type() on unfocused cell', async () => { + await page.render(); + const cell = getCellsAtRowIndex(0).nth(0); + await expect.element(cell).not.toHaveFocus(); + await userEvent.type(cell, '123{enter}'); + await expect.element(cell).toHaveFocus(); + await expect.element(cell).toHaveTextContent(/^1123$/); }); it('should close editor and discard changes on escape', async () => { await page.render(); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(0)); + const cell = getCellsAtRowIndex(0).nth(0); + await userEvent.dblClick(cell); await expect.element(col1Editor).toHaveValue(1); await userEvent.keyboard('2222{escape}'); await expect.element(col1Editor).not.toBeInTheDocument(); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^1$/); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveFocus(); + await expect.element(cell).toHaveTextContent(/^1$/); + await expect.element(cell).toHaveFocus(); }); it('should commit changes and close editor when clicked outside', async () => { await page.render(); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(0)); + const cell = getCellsAtRowIndex(0).nth(0); + await userEvent.dblClick(cell); await expect.element(col1Editor).toHaveValue(1); await userEvent.keyboard('2222'); await userEvent.click(page.getByText('outside')); await expect.element(col1Editor).not.toBeInTheDocument(); - await expect.element(getCellsAtRowIndex(0).nth(0)).toHaveTextContent(/^12222$/); + await expect.element(cell).toHaveTextContent(/^12222$/); }); it('should commit quickly enough on outside clicks so click event handlers access the latest rows state', async () => { @@ -146,23 +159,24 @@ describe('Editor', () => { await page.render( ); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(1)); + const cell = getCellsAtRowIndex(0).nth(1); + await userEvent.dblClick(cell); await expect.element(col2Editor).toHaveValue('a1'); await userEvent.keyboard('23'); // The cell value should update as the editor value is changed - await expect.element(getCellsAtRowIndex(0).nth(1)).toHaveTextContent(/^a123$/); + await expect.element(cell).toHaveTextContent(/^a123$/); // clicking in a portal does not count as an outside click await userEvent.click(col2Editor); await expect.element(col2Editor).toBeInTheDocument(); // true outside clicks are still detected await userEvent.click(page.getByText('outside')); await expect.element(col2Editor).not.toBeInTheDocument(); - await expect.element(getCellsAtRowIndex(0).nth(1)).not.toHaveFocus(); + await expect.element(cell).not.toHaveFocus(); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(1)); + await userEvent.dblClick(cell); await userEvent.click(col2Editor); await userEvent.keyboard('{enter}'); - await expect.element(getCellsAtRowIndex(0).nth(1)).toHaveFocus(); + await expect.element(cell).toHaveFocus(); }); it('should not commit on outside click if commitOnOutsideClick is false', async () => { @@ -192,10 +206,11 @@ describe('Editor', () => { }} /> ); - await userEvent.click(getCellsAtRowIndex(0).nth(1)); + const cell = getCellsAtRowIndex(0).nth(1); + await userEvent.click(cell); // TODO: await userEvent.keyboard('yz{enter}'); fails in FF await userEvent.keyboard('{enter}yz{enter}'); - await expect.element(getCellsAtRowIndex(0).nth(1)).toHaveTextContent(/^a1yz$/); + await expect.element(cell).toHaveTextContent(/^a1yz$/); await userEvent.keyboard('x'); await expect.element(col2Editor).not.toBeInTheDocument(); }); @@ -211,9 +226,10 @@ describe('Editor', () => { }} /> ); - await userEvent.dblClick(getCellsAtRowIndex(0).nth(1)); + const cell = getCellsAtRowIndex(0).nth(1); + await userEvent.dblClick(cell); await userEvent.keyboard('a{arrowleft}b{arrowright}c{arrowdown}'); // should commit changes on arrowdown - await expect.element(getCellsAtRowIndex(0).nth(1)).toHaveTextContent(/^a1bac$/); + await expect.element(cell).toHaveTextContent(/^a1bac$/); }); it('should close the editor when closeOnExternalRowChange is true or undefined and row is changed from outside', async () => { diff --git a/test/browser/column/resizable.test.tsx b/test/browser/column/resizable.test.tsx index 6e9f6984a8..4089e4b7f8 100644 --- a/test/browser/column/resizable.test.tsx +++ b/test/browser/column/resizable.test.tsx @@ -127,17 +127,19 @@ test('should auto resize column when resize handle is double clicked', async () await expect.element(grid).toHaveStyle({ gridTemplateColumns: '100px 200px' }); await autoResize('col2'); await testGridTemplateColumns('100px 327.703px', '100px 327.833px', '100px 400px'); - expect(onColumnResize).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining(columns[1]), - // Due to differences in text rendering between browsers the measured width can vary - expect.toSatisfy( - (width) => - // Chrome and Firefox on windows - (width >= 327.7 && width <= 327.9) || - // Firefox on CI - width === 400 - ) - ); + await expect + .poll(() => onColumnResize) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining(columns[1]), + // Due to differences in text rendering between browsers the measured width can vary + expect.toSatisfy( + (width) => + // Chrome and Firefox on windows + (width >= 327.7 && width <= 327.9) || + // Firefox on CI + width === 400 + ) + ); }); test('should use the maxWidth if specified on auto resize', async () => { @@ -214,7 +216,7 @@ test('should remeasure flex columns when resizing a column', async () => { '79.1667px 919.417px 919.417px', '100.5px 908.75px 908.75px' ); - expect(onColumnResize).toHaveBeenCalledOnce(); + await expect.poll(() => onColumnResize).toHaveBeenCalledOnce(); // onColumnResize is not called if width is not changed await autoResize('col1'); await testGridTemplateColumns( @@ -272,12 +274,14 @@ test('should use columnWidths and onColumnWidthsChange props when provided', asy await expect.element(grid).toHaveStyle({ gridTemplateColumns: '101px 201px' }); await autoResize('col2'); - expect(onColumnWidthsChangeSpy).toHaveBeenCalledExactlyOnceWith( - new Map([ - ['col1', { width: 101, type: 'measured' }], - ['col2', { width: 100, type: 'resized' }] - ]) - ); + await expect + .poll(() => onColumnWidthsChangeSpy) + .toHaveBeenCalledExactlyOnceWith( + new Map([ + ['col1', { width: 100, type: 'measured' }], + ['col2', { width: 100, type: 'resized' }] + ]) + ); expect(onColumnResizeSpy).toHaveBeenCalledExactlyOnceWith( expect.objectContaining(columns[1]), 100 @@ -288,7 +292,7 @@ test('should use columnWidths and onColumnWidthsChange props when provided', asy await resize('col2', [5, 5, 5]); expect(onColumnWidthsChangeSpy).toHaveBeenCalledExactlyOnceWith( new Map([ - ['col1', { width: 101, type: 'measured' }], + ['col1', { width: 100, type: 'measured' }], ['col2', { width: 115, type: 'resized' }] ]) ); @@ -303,11 +307,12 @@ test('should use columnWidths and onColumnWidthsChange props when provided', asy expect(onColumnWidthsChangeSpy).not.toHaveBeenCalled(); expect(onColumnResizeSpy).not.toHaveBeenCalled(); await expect.element(grid).toHaveStyle({ gridTemplateColumns: '120px 120px' }); + await resize('col2', [5, 5]); expect(onColumnWidthsChangeSpy).toHaveBeenCalledExactlyOnceWith( new Map([ - ['col1', { width: 120, type: 'measured' }], - ['col2', { width: 130, type: 'resized' }] + ['col1', { width: 100, type: 'measured' }], + ['col2', { width: 150, type: 'resized' }] ]) ); }); diff --git a/test/browser/scrollToCell.test.tsx b/test/browser/scrollToCell.test.tsx index 1e03a020c6..7392da1c7c 100644 --- a/test/browser/scrollToCell.test.tsx +++ b/test/browser/scrollToCell.test.tsx @@ -29,56 +29,56 @@ test('scrollToCell', async () => { ref, columns, rows, - topSummaryRows: summaryRows, + bottomSummaryRows: summaryRows, rowHeight: 60 }); expect(ref.current).toBeDefined(); await validateCellVisibility('0×0', true); - await validateCellVisibility('40×30', false); + await validateCellVisibility('45×30', false); await validateCellVisibility('0×51', true); // should scroll to a cell when a valid position is specified - ref.current!.scrollToCell({ idx: 40, rowIdx: 30 }); + ref.current!.scrollToCell({ idx: 45, rowIdx: 30 }); await validateCellVisibility('0×0', false); - await validateCellVisibility('40×30', true); + await validateCellVisibility('45×30', true); // should scroll to a column when a valid idx is specified ref.current!.scrollToCell({ idx: 6 }); await validateCellVisibility('6×30', true); - await validateCellVisibility('40×30', false); - ref.current!.scrollToCell({ idx: 40 }); + await validateCellVisibility('45×30', false); + ref.current!.scrollToCell({ idx: 45 }); await validateCellVisibility('6×30', false); - await validateCellVisibility('40×30', true); + await validateCellVisibility('45×30', true); // should scroll to a row when a valid rowIdx is specified ref.current!.scrollToCell({ rowIdx: 1 }); - await validateCellVisibility('40×1', true); - await validateCellVisibility('40×30', false); + await validateCellVisibility('45×1', true); + await validateCellVisibility('45×30', false); ref.current!.scrollToCell({ rowIdx: 30 }); - await validateCellVisibility('40×1', false); - await validateCellVisibility('40×30', true); + await validateCellVisibility('45×1', false); + await validateCellVisibility('45×30', true); // should not scroll if scroll to column is frozen ref.current!.scrollToCell({ idx: 2 }); - await validateCellVisibility('40×30', true); + await validateCellVisibility('45×30', true); // should not scroll if rowIdx is header row - ref.current!.scrollToCell({ idx: -1 }); - await validateCellVisibility('40×30', true); + ref.current!.scrollToCell({ rowIdx: -1 }); + await validateCellVisibility('45×30', true); // should not scroll if rowIdx is summary row - ref.current!.scrollToCell({ idx: 50 }); - await validateCellVisibility('40×30', true); + ref.current!.scrollToCell({ rowIdx: 50 }); + await validateCellVisibility('45×30', true); + await validateCellVisibility('0×49', false); + await validateCellVisibility('45×49', false); // should not scroll if position is out of bound ref.current!.scrollToCell({ idx: 60, rowIdx: 60 }); - await validateCellVisibility('40×30', true); - - // should not scroll vertically when scrolling to summary row - ref.current!.scrollToCell({ idx: 49, rowIdx: 51 }); - await validateCellVisibility('49×30', true); + await validateCellVisibility('45×30', true); + await validateCellVisibility('0×49', false); + await validateCellVisibility('45×49', false); }); function validateCellVisibility(name: string, isVisible: boolean) { diff --git a/test/browser/virtualization.test.ts b/test/browser/virtualization.test.ts index 70529a94c4..f67324c911 100644 --- a/test/browser/virtualization.test.ts +++ b/test/browser/virtualization.test.ts @@ -123,9 +123,8 @@ test('virtualization is enabled', async () => { await scrollGrid({ top: 1000 }); await assertRows(39, 24, 62); - // scroll height = header height + row height * row count - // max top = scroll height - grid height - await scrollGrid({ top: rowHeight + rowHeight * 100 - 1080 }); + // scroll to the end + await scrollGrid({ top: 9999 }); await assertRows(34, 66, 99); await scrollGrid({ left: 92 }); @@ -144,8 +143,8 @@ test('virtualization is enabled', async () => { await assertHeaderCells(18, 1, 18); await assertCells(66, 18, 1, 18); - // max left = row width - grid width - await scrollGrid({ left: 3600 - 1920 }); + // scroll to the end + await scrollGrid({ left: 9999 }); await assertHeaderCells(17, 13, 29); await assertCells(66, 17, 13, 29); }); @@ -162,8 +161,8 @@ test('virtualization is enabled with 4 frozen columns', async () => { await assertHeaderCellIndexes(indexes); await assertCellIndexes(0, indexes); - // max left = row width - grid width - await scrollGrid({ left: 3600 - 1920 }); + // scroll to the end + await scrollGrid({ left: 9999 }); indexes = [0, 1, 2, 3, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]; await assertHeaderCellIndexes(indexes); await assertCellIndexes(0, indexes); @@ -183,8 +182,8 @@ test('virtualization is enabled with all columns frozen', async () => { await assertHeaderCellIndexes(indexes); await assertCellIndexes(0, indexes); - // max left = row width - grid width - await scrollGrid({ left: 3600 - 1920 }); + // scroll to the end + await scrollGrid({ left: 9999 }); await assertHeaderCellIndexes(indexes); await assertCellIndexes(0, indexes); }); diff --git a/website/routes/CommonFeatures.tsx b/website/routes/CommonFeatures.tsx index 071ae0a654..936083f883 100644 --- a/website/routes/CommonFeatures.tsx +++ b/website/routes/CommonFeatures.tsx @@ -62,7 +62,7 @@ interface SummaryRow { interface Row { id: number; - title: string; + task: string; client: string; area: string; country: string; @@ -94,7 +94,7 @@ function getColumns( } }, { - key: 'title', + key: 'task', name: 'Task', frozen: true, renderEditCell: renderTextEditor, @@ -260,7 +260,7 @@ function createRows(): readonly Row[] { rows.push({ id: i, - title: `Task #${i + 1}`, + task: `Task #${i + 1}`, client: faker.company.name(), area: faker.person.jobArea(), country, @@ -287,7 +287,7 @@ type Comparator = (a: Row, b: Row) => number; function getComparator(sortColumn: string): Comparator { switch (sortColumn) { case 'assignee': - case 'title': + case 'task': case 'client': case 'area': case 'country':