diff --git a/README.md b/README.md index da0114b936..194f86ae85 100644 --- a/README.md +++ b/README.md @@ -881,7 +881,7 @@ function MyGrid() { Function to generate unique IDs for group rows. If not provided, a default implementation is used that concatenates parent and group keys with `__`. -###### `rowHeight?: Maybe) => number)>` +###### `rowHeight?: Maybe) => number)>` **Note:** Unlike `DataGrid`, the `rowHeight` function receives [`RowHeightArgs`](#rowheightargstrow) which includes a `type` property to distinguish between regular rows and group rows: diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index f9bc6427f8..607de56216 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -1,10 +1,13 @@ -import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import type { Key, KeyboardEvent } from 'react'; +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { + type ActivePosition, HeaderRowSelectionChangeContext, HeaderRowSelectionContext, + type HeaderRowSelectionContextValue, + type PartialPosition, RowSelectionChangeContext, useActivePosition, useCalculatedColumns, @@ -14,10 +17,7 @@ import { useScrollState, useScrollToPosition, useViewportColumns, - useViewportRows, - type ActivePosition, - type HeaderRowSelectionContextValue, - type PartialPosition + useViewportRows } from './hooks'; import { assertIsValidKeyGetter, @@ -45,7 +45,6 @@ import type { CellMouseEventHandler, CellNavigationMode, CellPasteArgs, - PositionChangeArgs, Column, ColumnOrColumnGroup, ColumnWidths, @@ -53,11 +52,12 @@ import type { FillEvent, Maybe, Position, + PositionChangeArgs, Renderers, RowsChangeData, - SetActivePositionOptions, SelectHeaderRowEvent, SelectRowEvent, + SetActivePositionOptions, SortColumn } from './types'; import { defaultRenderCell } from './Cell'; @@ -73,10 +73,10 @@ import { defaultRenderRow } from './Row'; import { default as defaultRenderSortStatus } from './sortStatus'; import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell'; import { - rootClassname, frozenColumnShadowClassname, - viewportDraggingClassname, - frozenColumnShadowTopClassname + frozenColumnShadowTopClassname, + rootClassname, + viewportDraggingClassname } from './style/core'; import SummaryRow from './SummaryRow'; @@ -136,7 +136,7 @@ export interface DataGridProps extends Sha * Height of each row in pixels * @default 35 */ - rowHeight?: Maybe) => number)>; + rowHeight?: Maybe) => number)>; /** * Height of the header row in pixels * @default 35 @@ -301,9 +301,13 @@ export function DataGrid(props: DataGridPr const renderCheckbox = renderers?.renderCheckbox ?? defaultRenderers?.renderCheckbox ?? defaultRenderCheckbox; const noRowsFallback = renderers?.noRowsFallback ?? defaultRenderers?.noRowsFallback; - const enableVirtualization = rawEnableVirtualization ?? true; + const enableVirtualization = rawEnableVirtualization ?? typeof rawRowHeight !== 'string'; const direction = rawDirection ?? 'ltr'; + if (enableVirtualization && typeof rowHeight === 'string') { + throw new Error('`rowHeight` cannot be a string when `enableVirtualization` is true.'); + } + /** * ref */ @@ -404,7 +408,9 @@ export function DataGrid(props: DataGridPr maxRowIdx, setDraggedOverRowIdx }); - const { setScrollToPosition, scrollToPositionElement } = useScrollToPosition({ gridRef }); + const { setScrollToPosition, scrollToPositionElement } = useScrollToPosition({ + gridRef + }); const defaultGridComponents = useMemo( () => ({ @@ -448,10 +454,16 @@ export function DataGrid(props: DataGridPr findRowIdx } = useViewportRows({ rows, - rowHeight, clientHeight, scrollTop, - enableVirtualization + enableVirtualization, + ...(typeof rowHeight === 'string' + ? { + rowHeight, + gridRef, + gridHeight + } + : { rowHeight }) }); const { @@ -674,7 +686,11 @@ export function DataGrid(props: DataGridPr if (isSelectable && shiftKey && key === ' ') { assertIsValidKeyGetter(rowKeyGetter); const rowKey = rowKeyGetter(row); - selectRow({ row, checked: !selectedRows.has(rowKey), isShiftClick: false }); + selectRow({ + row, + checked: !selectedRows.has(rowKey), + isShiftClick: false + }); // prevent scrolling event.preventDefault(); return; @@ -760,7 +776,11 @@ export function DataGrid(props: DataGridPr const indexes: number[] = []; for (let i = startRowIdx; i < endRowIdx; i++) { if (isCellEditable({ rowIdx: i, idx })) { - const updatedRow = onFill!({ columnKey: column.key, sourceRow, targetRow: rows[i] }); + const updatedRow = onFill!({ + columnKey: column.key, + sourceRow, + targetRow: rows[i] + }); if (updatedRow !== rows[i]) { updatedRows[i] = updatedRow; indexes.push(i); @@ -978,10 +998,17 @@ export function DataGrid(props: DataGridPr const { row } = activePosition; const column = getActiveColumn(); - const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); + const colSpan = getColSpan(column, lastFrozenColumnIndex, { + type: 'ROW', + row + }); function closeEditor(shouldFocus: boolean) { - const newPosition: ActivePosition = { idx: activePosition.idx, rowIdx, mode: 'ACTIVE' }; + const newPosition: ActivePosition = { + idx: activePosition.idx, + rowIdx, + mode: 'ACTIVE' + }; setActivePosition(newPosition); if (shouldFocus) { setPositionToFocus(newPosition); diff --git a/src/hooks/useViewportRows.ts b/src/hooks/useViewportRows.ts index 13b8502302..2f4ecfe399 100644 --- a/src/hooks/useViewportRows.ts +++ b/src/hooks/useViewportRows.ts @@ -1,22 +1,37 @@ -import { useMemo } from 'react'; +import { useMemo, type RefObject } from 'react'; import { floor, max, min } from '../utils'; -interface ViewportRowsArgs { +interface ViewportRowsBaseArgs { rows: readonly R[]; - rowHeight: number | ((row: R) => number); clientHeight: number; scrollTop: number; enableVirtualization: boolean; + gridHeight?: number; +} + +interface ViewportRowsArgsStringHeight { + rowHeight: string; + gridRef: RefObject; + gridHeight: number; +} + +interface ViewportRowsArgsRegularHeight { + rowHeight: number | ((row: R) => number); } +type ViewportRowsArgs = ViewportRowsBaseArgs & + (ViewportRowsArgsStringHeight | ViewportRowsArgsRegularHeight); + export function useViewportRows({ rows, rowHeight, clientHeight, scrollTop, - enableVirtualization + enableVirtualization, + ...rest }: ViewportRowsArgs) { + const { gridRef, gridHeight } = rest as Partial; const { totalRowHeight, gridTemplateRows, getRowTop, getRowHeight, findRowIdx } = useMemo(() => { if (typeof rowHeight === 'number') { return { @@ -28,6 +43,68 @@ export function useViewportRows({ }; } + if (typeof rowHeight === 'string') { + if (!gridHeight) { + throw new Error( + 'props.gridHeight is required when rowHeight is a string. This is needed to calculate the total height of the rows.' + ); + } + + const getRowElementFirstCell = (element: Element, rowIdx: number): Element | null => { + const nth = element.querySelector('.rdg-header-row') ? rowIdx + 2 : rowIdx + 1; + return element.querySelector(`[role="row"][aria-rowindex="${nth}"] > [role="gridcell"]`); + }; + + const getRowYTop = (element: Element, rowIdx: number) => { + const cell = getRowElementFirstCell(element, rowIdx); + if (!cell) return -1; + return cell.getBoundingClientRect().top + element.scrollTop; + }; + + return { + totalRowHeight: gridHeight, + gridTemplateRows: ` repeat(${rows.length}, ${rowHeight})`, + getRowTop(rowIdx: number) { + const element = gridRef?.current; + if (!element) return -1; + const cell = getRowElementFirstCell(element, rowIdx); + if (!cell) return -1; + return cell.getBoundingClientRect().top + element.scrollTop; + }, + getRowHeight(rowIdx: number) { + const element = gridRef?.current; + if (!element) return -1; + const cell = getRowElementFirstCell(element, rowIdx); + if (!cell) return -1; + return cell.clientHeight; + }, + findRowIdx(offset: number) { + const element = gridRef?.current; + if (!element) return -1; + let start = 0; + let end = rows.length - 1; + + while (start <= end) { + const middle = start + floor((end - start) / 2); + const currentScrollTop = getRowYTop(element, middle); + const prevScrollTop = getRowYTop(element, middle - 1); + + if (currentScrollTop >= offset && prevScrollTop < offset) return middle; + + if (currentScrollTop < offset) { + start = middle + 1; + } else if (currentScrollTop > offset) { + end = middle - 1; + } + + if (start > end) return end; + } + + return -1; + } + }; + } + // Calcule the height of all the rows upfront. This can cause performance issues // and we can consider using a similar approach as react-window // https://github.com/bvaughn/react-window/blob/b0a470cc264e9100afcaa1b78ed59d88f7914ad4/src/VariableSizeList.js#L68 @@ -102,15 +179,21 @@ export function useViewportRows({ return 0; } }; - }, [rowHeight, rows]); + }, [rowHeight, rows, gridRef, gridHeight]); let rowOverscanStartIdx = 0; let rowOverscanEndIdx = rows.length - 1; if (enableVirtualization) { const overscanThreshold = 4; + // `findRowIdx` only reads `gridRef.current` in the string-rowHeight branch, + // which is unreachable here because `enableVirtualization` is forced off when + // `rowHeight` is a string (see DataGrid.tsx). + /* eslint-disable react-hooks/refs */ const rowVisibleStartIdx = findRowIdx(scrollTop); const rowVisibleEndIdx = findRowIdx(scrollTop + clientHeight); + /* eslint-enable react-hooks/refs */ + rowOverscanStartIdx = max(0, rowVisibleStartIdx - overscanThreshold); rowOverscanEndIdx = min(rows.length - 1, rowVisibleEndIdx + overscanThreshold); } diff --git a/test/browser/rowHeight.test.ts b/test/browser/rowHeight.test.tsx similarity index 62% rename from test/browser/rowHeight.test.ts rename to test/browser/rowHeight.test.tsx index 5ed9647509..142dd3cc72 100644 --- a/test/browser/rowHeight.test.ts +++ b/test/browser/rowHeight.test.tsx @@ -102,3 +102,66 @@ test('rowHeight with unique first height', async () => { return row === 0 ? 45 : 50; }, 'repeat(1, 35px) 45px repeat(49, 50px)'); }); + +test('rowHeight is "auto" sets gridTemplateRows to repeat(N, auto)', async () => { + await setupGrid('auto'); + + expect(grid.element().style.gridTemplateRows).toBe('repeat(1, 35px) repeat(50, auto)'); +}); + +test('rowHeight as a string auto-disables virtualization and renders all rows', async () => { + await setupGrid('auto'); + + // virtualization is off by default when `rowHeight` is a string, + // so every row is rendered regardless of viewport size + await testRowCount(50); +}); + +test('rowHeight accepts arbitrary CSS track values', async () => { + await setupGrid('min-content'); + + expect(grid.element().style.gridTemplateRows).toBe('repeat(1, 35px) repeat(50, min-content)'); + await testRowCount(50); +}); + +test('rowHeight is "auto" sizes rows to fit their content', async () => { + const columns: Column<{ id: number; content: string }>[] = [ + { key: 'id', name: 'ID', width: 80 }, + { + key: 'content', + name: 'Content', + width: 200, + renderCell: ({ row }) =>
{row.content}
+ } + ]; + const rows = [ + { id: 0, content: 'short' }, + { id: 1, content: 'line one\nline two\nline three\nline four' } + ]; + + await setup({ columns, rows, rowHeight: 'auto' }); + + const row0 = page.getRow().nth(0).element() as HTMLElement; + const row1 = page.getRow().nth(1).element() as HTMLElement; + + // multi-line cell must render taller than the single-line cell + expect(row1.clientHeight).toBeGreaterThan(row0.clientHeight); +}); + +test('rowHeight string + explicit enableVirtualization=true throws', async () => { + // Suppress React's error logging for this expected render error + // eslint-disable-next-line no-console + vi.mocked(console.error).mockImplementation(() => {}); + + await expect( + setup({ + columns: [{ key: 'id', name: 'ID' }], + rows: [{ id: 0 }], + rowHeight: 'auto', + enableVirtualization: true + }) + ).rejects.toThrow('`rowHeight` cannot be a string when `enableVirtualization` is true.'); + + // eslint-disable-next-line no-console + vi.mocked(console.error).mockClear(); +}); diff --git a/website/Nav.tsx b/website/Nav.tsx index 42731a3144..5ad31e64ff 100644 --- a/website/Nav.tsx +++ b/website/Nav.tsx @@ -91,6 +91,7 @@ export default function Nav({ direction, onDirectionChange }: Props) { Columns Reordering Context Menu Customizable Renderers + Dynamic Height Cells Row Grouping Header Filters Infinite Scrolling diff --git a/website/routeTree.gen.ts b/website/routeTree.gen.ts index b0b8edb0b3..34c275fe20 100644 --- a/website/routeTree.gen.ts +++ b/website/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as MillionCellsRouteImport } from './routes/MillionCells' import { Route as MasterDetailRouteImport } from './routes/MasterDetail' import { Route as InfiniteScrollingRouteImport } from './routes/InfiniteScrolling' import { Route as HeaderFiltersRouteImport } from './routes/HeaderFilters' +import { Route as DynamicHeightCellsRouteImport } from './routes/DynamicHeightCells' import { Route as CustomizableRenderersRouteImport } from './routes/CustomizableRenderers' import { Route as ContextMenuRouteImport } from './routes/ContextMenu' import { Route as CommonFeaturesRouteImport } from './routes/CommonFeatures' @@ -86,6 +87,11 @@ const HeaderFiltersRoute = HeaderFiltersRouteImport.update({ path: '/HeaderFilters', getParentRoute: () => rootRouteImport, } as any) +const DynamicHeightCellsRoute = DynamicHeightCellsRouteImport.update({ + id: '/DynamicHeightCells', + path: '/DynamicHeightCells', + getParentRoute: () => rootRouteImport, +} as any) const CustomizableRenderersRoute = CustomizableRenderersRouteImport.update({ id: '/CustomizableRenderers', path: '/CustomizableRenderers', @@ -148,6 +154,7 @@ export interface FileRoutesByFullPath { '/CommonFeatures': typeof CommonFeaturesRoute '/ContextMenu': typeof ContextMenuRoute '/CustomizableRenderers': typeof CustomizableRenderersRoute + '/DynamicHeightCells': typeof DynamicHeightCellsRoute '/HeaderFilters': typeof HeaderFiltersRoute '/InfiniteScrolling': typeof InfiniteScrollingRoute '/MasterDetail': typeof MasterDetailRoute @@ -171,6 +178,7 @@ export interface FileRoutesByTo { '/CommonFeatures': typeof CommonFeaturesRoute '/ContextMenu': typeof ContextMenuRoute '/CustomizableRenderers': typeof CustomizableRenderersRoute + '/DynamicHeightCells': typeof DynamicHeightCellsRoute '/HeaderFilters': typeof HeaderFiltersRoute '/InfiniteScrolling': typeof InfiniteScrollingRoute '/MasterDetail': typeof MasterDetailRoute @@ -195,6 +203,7 @@ export interface FileRoutesById { '/CommonFeatures': typeof CommonFeaturesRoute '/ContextMenu': typeof ContextMenuRoute '/CustomizableRenderers': typeof CustomizableRenderersRoute + '/DynamicHeightCells': typeof DynamicHeightCellsRoute '/HeaderFilters': typeof HeaderFiltersRoute '/InfiniteScrolling': typeof InfiniteScrollingRoute '/MasterDetail': typeof MasterDetailRoute @@ -220,6 +229,7 @@ export interface FileRouteTypes { | '/CommonFeatures' | '/ContextMenu' | '/CustomizableRenderers' + | '/DynamicHeightCells' | '/HeaderFilters' | '/InfiniteScrolling' | '/MasterDetail' @@ -243,6 +253,7 @@ export interface FileRouteTypes { | '/CommonFeatures' | '/ContextMenu' | '/CustomizableRenderers' + | '/DynamicHeightCells' | '/HeaderFilters' | '/InfiniteScrolling' | '/MasterDetail' @@ -266,6 +277,7 @@ export interface FileRouteTypes { | '/CommonFeatures' | '/ContextMenu' | '/CustomizableRenderers' + | '/DynamicHeightCells' | '/HeaderFilters' | '/InfiniteScrolling' | '/MasterDetail' @@ -290,6 +302,7 @@ export interface RootRouteChildren { CommonFeaturesRoute: typeof CommonFeaturesRoute ContextMenuRoute: typeof ContextMenuRoute CustomizableRenderersRoute: typeof CustomizableRenderersRoute + DynamicHeightCellsRoute: typeof DynamicHeightCellsRoute HeaderFiltersRoute: typeof HeaderFiltersRoute InfiniteScrollingRoute: typeof InfiniteScrollingRoute MasterDetailRoute: typeof MasterDetailRoute @@ -382,6 +395,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HeaderFiltersRouteImport parentRoute: typeof rootRouteImport } + '/DynamicHeightCells': { + id: '/DynamicHeightCells' + path: '/DynamicHeightCells' + fullPath: '/DynamicHeightCells' + preLoaderRoute: typeof DynamicHeightCellsRouteImport + parentRoute: typeof rootRouteImport + } '/CustomizableRenderers': { id: '/CustomizableRenderers' path: '/CustomizableRenderers' @@ -466,6 +486,7 @@ const rootRouteChildren: RootRouteChildren = { CommonFeaturesRoute: CommonFeaturesRoute, ContextMenuRoute: ContextMenuRoute, CustomizableRenderersRoute: CustomizableRenderersRoute, + DynamicHeightCellsRoute: DynamicHeightCellsRoute, HeaderFiltersRoute: HeaderFiltersRoute, InfiniteScrollingRoute: InfiniteScrollingRoute, MasterDetailRoute: MasterDetailRoute, diff --git a/website/routes/DynamicHeightCells.tsx b/website/routes/DynamicHeightCells.tsx new file mode 100644 index 0000000000..6fd5618fbd --- /dev/null +++ b/website/routes/DynamicHeightCells.tsx @@ -0,0 +1,121 @@ +import { useState, type JSX } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; +import { css } from 'ecij'; + +import { DataGrid, type Column } from '../../src'; +import { useDirection } from '../directionContext'; + +export const Route = createFileRoute('/DynamicHeightCells')({ + component: DynamicHeightCells +}); + +interface Row { + id: number; + task: string; + complete: number; + priority: string; + issueType: string; + startDate: string; + completeDate: string; + dynamicContent: JSX.Element; +} + +const columns: Column[] = [ + { + key: 'id', + name: 'ID', + width: 80 + }, + { + key: 'task', + name: 'Title' + }, + { + key: 'priority', + name: 'Priority' + }, + { + key: 'issueType', + name: 'Issue Type' + }, + { + key: 'complete', + name: '% Complete' + }, + { + key: 'startDate', + name: 'Start Date' + }, + { + key: 'completeDate', + name: 'Expected Complete', + width: 200 + }, + { + key: 'dynamicContent', + name: 'Dynamic HTML Content', + width: 200 + } +]; + +function getRandomDate(start: Date, end: Date) { + return new Date( + start.getTime() + Math.random() * (end.getTime() - start.getTime()) + ).toLocaleDateString(); +} + +function createRows(): Row[] { + const rows = []; + for (let i = 1; i < 500; i++) { + rows.push({ + id: i, + task: `Task ${i}`, + complete: Math.min(100, Math.round(Math.random() * 110)), + priority: ['Critical', 'High', 'Medium', 'Low'][Math.floor(Math.random() * 3 + 1)], + issueType: ['Bug', 'Improvement', 'Epic', 'Story'][Math.floor(Math.random() * 3 + 1)], + startDate: getRandomDate(new Date(2015, 3, 1), new Date()), + completeDate: getRandomDate(new Date(), new Date(2016, 0, 1)), + dynamicContent: (() => { + const arr = []; + for (let i = 0; i < Math.ceil(Math.random() * 6); i++) { + arr.push(`Dynamic content ${i + 1}`); + } + return ( +
    + {arr.map((item, idx) => ( +
  • {item}
  • + ))} +
+ ); + })() + }); + } + + return rows; +} + +const rootClassname = css` + display: flex; + flex-direction: column; + block-size: 100%; + gap: 10px; + + > .rdg { + flex: 1; + } + + .rdg-cell { + padding-block: 0.75em; + } +`; + +function DynamicHeightCells() { + const direction = useDirection(); + const [rows] = useState(createRows); + + return ( +
+ +
+ ); +}