diff --git a/src/Cell.tsx b/src/Cell.tsx index e5fc99cae0..6b8b2fc4eb 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -44,6 +44,7 @@ const cellDragHandleClassname = `rdg-cell-drag-handle ${cellDragHandle}`; function Cell({ className, column, + colSpan, isCellSelected, isCopied, isDraggedOver, @@ -105,9 +106,10 @@ function Cell({ role="gridcell" aria-colindex={column.idx + 1} // aria-colindex is 1-based aria-selected={isCellSelected} + aria-colspan={colSpan} ref={ref} className={className} - style={getCellStyle(column)} + style={getCellStyle(column, colSpan)} onClick={handleClick} onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 532ec3db4b..290df00600 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -10,7 +10,7 @@ import type { RefAttributes } from 'react'; import clsx from 'clsx'; import { rootClassname, viewportDraggingClassname, focusSinkClassname } from './style'; -import { useGridDimensions, useViewportColumns, useViewportRows, useLatestFunc } from './hooks'; +import { useGridDimensions, useCalculatedColumns, useViewportColumns, useViewportRows, useLatestFunc } from './hooks'; import HeaderRow from './HeaderRow'; import FilterRow from './FilterRow'; import Row from './Row'; @@ -258,7 +258,18 @@ function DataGrid({ const clientHeight = gridHeight - totalHeaderHeight - summaryRowsCount * summaryRowHeight; const isSelectable = selectedRows !== undefined && onSelectedRowsChange !== undefined; - const { columns, viewportColumns, layoutCssVars, columnMetrics, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth, groupBy } = useViewportColumns({ + const { + columns, + colSpanColumns, + colOverscanStartIdx, + colOverscanEndIdx, + layoutCssVars, + columnMetrics, + totalColumnWidth, + lastFrozenColumnIndex, + totalFrozenColumnWidth, + groupBy + } = useCalculatedColumns({ rawColumns, columnWidths, scrollLeft, @@ -279,6 +290,20 @@ function DataGrid({ enableVirtualization }); + const viewportColumns = useViewportColumns({ + columns, + colSpanColumns, + colOverscanStartIdx, + colOverscanEndIdx, + lastFrozenColumnIndex, + rowOverscanStartIdx, + rowOverscanEndIdx, + rows, + summaryRows, + enableFilterRow, + isGroupRow + }); + const hasGroups = groupBy.length > 0 && typeof rowGrouper === 'function'; const minColIdx = hasGroups ? -1 : 0; @@ -778,12 +803,16 @@ function DataGrid({ event.preventDefault(); const ctrlKey = isCtrlKeyHeldDown(event); - let nextPosition = getNextPosition(key, ctrlKey, shiftKey); - nextPosition = getNextSelectedCellPosition({ + const nextPosition = getNextSelectedCellPosition({ columns, - rowsCount: rows.length, + colSpanColumns, + rows, + lastFrozenColumnIndex, cellNavigationMode: mode, - nextPosition + currentPosition: selectedPosition, + nextPosition: getNextPosition(key, ctrlKey, shiftKey), + isCellWithinBounds, + isGroupRow }); selectCell(nextPosition); @@ -887,6 +916,7 @@ function DataGrid({ copiedCellIdx={copiedCell !== null && copiedCell.row === row ? columns.findIndex(c => c.key === copiedCell.columnKey) : undefined} draggedOverCellIdx={getDraggedOverCellIdx(rowIdx)} setDraggedOverRowIdx={isDragging ? setDraggedOverRowIdx : undefined} + lastFrozenColumnIndex={lastFrozenColumnIndex} selectedCellProps={getSelectedCellProps(rowIdx)} onRowChange={handleFormatterRowChangeWrapper} selectCell={selectCellWrapper} @@ -941,6 +971,7 @@ function DataGrid({ sortColumn={sortColumn} sortDirection={sortDirection} onSort={onSort} + lastFrozenColumnIndex={lastFrozenColumnIndex} /> {enableFilterRow && ( @@ -968,6 +999,7 @@ function DataGrid({ row={row} bottom={summaryRowHeight * (summaryRows.length - 1 - rowIdx)} viewportColumns={viewportColumns} + lastFrozenColumnIndex={lastFrozenColumnIndex} /> ))} diff --git a/src/EditCell.tsx b/src/EditCell.tsx index daa9d4ee56..ca15dcb988 100644 --- a/src/EditCell.tsx +++ b/src/EditCell.tsx @@ -16,6 +16,7 @@ type SharedCellRendererProps = Pick, | 'rowIdx' | 'row' | 'column' + | 'colSpan' >; interface EditCellProps extends SharedCellRendererProps, Omit, 'style' | 'children'> { @@ -25,6 +26,7 @@ interface EditCellProps extends SharedCellRendererProps, Omit({ className, column, + colSpan, row, rowIdx, editorProps, @@ -73,7 +75,7 @@ export default function EditCell({ aria-selected ref={cellRef} className={className} - style={getCellStyle(column)} + style={getCellStyle(column, colSpan)} {...props} > {getCellContent()} diff --git a/src/HeaderCell.tsx b/src/HeaderCell.tsx index 9475947ef9..31a6e19509 100644 --- a/src/HeaderCell.tsx +++ b/src/HeaderCell.tsx @@ -39,12 +39,14 @@ type SharedHeaderRowProps = Pick, export interface HeaderCellProps extends SharedHeaderRowProps { column: CalculatedColumn; + colSpan?: number; onResize: (column: CalculatedColumn, width: number) => void; onAllRowsSelectionChange: (checked: boolean) => void; } export default function HeaderCell({ column, + colSpan, onResize, allRowsSelected, onAllRowsSelectionChange, @@ -128,7 +130,7 @@ export default function HeaderCell({ aria-colindex={column.idx + 1} aria-sort={sortColumn === column.key ? getAriaSort(sortDirection) : undefined} className={className} - style={getCellStyle(column)} + style={getCellStyle(column, colSpan)} onPointerDown={column.resizable ? onPointerDown : undefined} > {getCell()} diff --git a/src/HeaderRow.tsx b/src/HeaderRow.tsx index 3fed84aa28..7fba390167 100644 --- a/src/HeaderRow.tsx +++ b/src/HeaderRow.tsx @@ -2,7 +2,7 @@ import { useCallback, memo } from 'react'; import HeaderCell from './HeaderCell'; import type { CalculatedColumn } from './types'; -import { assertIsValidKeyGetter } from './utils'; +import { assertIsValidKeyGetter, getColSpan } from './utils'; import type { DataGridProps } from './DataGrid'; import { headerRowClassname } from './style'; @@ -19,6 +19,7 @@ export interface HeaderRowProps extends SharedDataGridProps { columns: readonly CalculatedColumn[]; allRowsSelected: boolean; onColumnResize: (column: CalculatedColumn, width: number) => void; + lastFrozenColumnIndex: number; } function HeaderRow({ @@ -30,7 +31,8 @@ function HeaderRow({ onColumnResize, sortColumn, sortDirection, - onSort + onSort, + lastFrozenColumnIndex }: HeaderRowProps) { const handleAllRowsSelectionChange = useCallback((checked: boolean) => { if (!onSelectedRowsChange) return; @@ -41,26 +43,36 @@ function HeaderRow({ onSelectedRowsChange(newSelectedRows); }, [onSelectedRowsChange, rows, rowKeyGetter]); + const cells = []; + for (let index = 0; index < columns.length; index++) { + const column = columns[index]; + const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'HEADER' }); + if (colSpan !== undefined) { + index += colSpan - 1; + } + + cells.push( + + key={column.key} + column={column} + colSpan={colSpan} + onResize={onColumnResize} + allRowsSelected={allRowsSelected} + onAllRowsSelectionChange={handleAllRowsSelectionChange} + onSort={onSort} + sortColumn={sortColumn} + sortDirection={sortDirection} + /> + ); + } + return (
- {columns.map(column => { - return ( - - key={column.key} - column={column} - onResize={onColumnResize} - allRowsSelected={allRowsSelected} - onAllRowsSelectionChange={handleAllRowsSelectionChange} - onSort={onSort} - sortColumn={sortColumn} - sortDirection={sortDirection} - /> - ); - })} + {cells}
); } diff --git a/src/Row.tsx b/src/Row.tsx index 7e780a6402..6ee8aea794 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -3,6 +3,7 @@ import type { RefAttributes } from 'react'; import clsx from 'clsx'; import { groupRowSelectedClassname, rowClassname, rowSelectedClassname } from './style'; +import { getColSpan } from './utils'; import Cell from './Cell'; import EditCell from './EditCell'; import type { RowRendererProps, SelectedCellProps } from './types'; @@ -14,6 +15,7 @@ function Row({ isRowSelected, copiedCellIdx, draggedOverCellIdx, + lastFrozenColumnIndex, row, viewportColumns, selectedCellProps, @@ -44,6 +46,52 @@ function Row({ className ); + const cells = []; + for (let index = 0; index < viewportColumns.length; index++) { + const column = viewportColumns[index]; + const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); + if (colSpan !== undefined) { + index += colSpan - 1; + } + + const isCellSelected = selectedCellProps?.idx === column.idx; + if (selectedCellProps?.mode === 'EDIT' && isCellSelected) { + cells.push( + + key={column.key} + rowIdx={rowIdx} + column={column} + colSpan={colSpan} + row={row} + onKeyDown={selectedCellProps.onKeyDown} + editorProps={selectedCellProps.editorProps} + /> + ); + continue; + } + + cells.push( + + ); + } + return (
({ style={{ top }} {...props} > - {viewportColumns.map(column => { - const isCellSelected = selectedCellProps?.idx === column.idx; - if (selectedCellProps?.mode === 'EDIT' && isCellSelected) { - return ( - - key={column.key} - rowIdx={rowIdx} - column={column} - row={row} - onKeyDown={selectedCellProps.onKeyDown} - editorProps={selectedCellProps.editorProps} - /> - ); - } - - return ( - - ); - })} + {cells}
); } diff --git a/src/SummaryCell.tsx b/src/SummaryCell.tsx index 7fbb84bd17..4a9b8a56b1 100644 --- a/src/SummaryCell.tsx +++ b/src/SummaryCell.tsx @@ -3,7 +3,7 @@ import { memo } from 'react'; import { getCellStyle, getCellClassname } from './utils'; import type { CellRendererProps } from './types'; -type SharedCellRendererProps = Pick, 'column'>; +type SharedCellRendererProps = Pick, 'column' | 'colSpan'>; interface SummaryCellProps extends SharedCellRendererProps { row: SR; @@ -11,6 +11,7 @@ interface SummaryCellProps extends SharedCellRendererProps { function SummaryCell({ column, + colSpan, row }: SummaryCellProps) { const { summaryFormatter: SummaryFormatter, summaryCellClass } = column; @@ -23,7 +24,7 @@ function SummaryCell({ role="gridcell" aria-colindex={column.idx + 1} className={className} - style={getCellStyle(column)} + style={getCellStyle(column, colSpan)} > {SummaryFormatter && } diff --git a/src/SummaryRow.tsx b/src/SummaryRow.tsx index 6f783d8c63..c2b2b09193 100644 --- a/src/SummaryRow.tsx +++ b/src/SummaryRow.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { rowClassname, summaryRowClassname } from './style'; +import { getColSpan } from './utils'; import SummaryCell from './SummaryCell'; import type { RowRendererProps } from './types'; @@ -12,6 +13,7 @@ interface SummaryRowProps extends SharedRowRendererProps { 'aria-rowindex': number; row: SR; bottom: number; + lastFrozenColumnIndex: number; } function SummaryRow({ @@ -19,8 +21,27 @@ function SummaryRow({ row, viewportColumns, bottom, + lastFrozenColumnIndex, 'aria-rowindex': ariaRowIndex }: SummaryRowProps) { + const cells = []; + for (let index = 0; index < viewportColumns.length; index++) { + const column = viewportColumns[index]; + const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'HEADER' }); + if (colSpan !== undefined) { + index += colSpan - 1; + } + + cells.push( + + key={column.key} + column={column} + colSpan={colSpan} + row={row} + /> + ); + } + return (
({ className={`${rowClassname} rdg-row-${rowIdx % 2 === 0 ? 'even' : 'odd'} ${summaryRowClassname}`} style={{ bottom }} > - {viewportColumns.map(column => ( - - key={column.key} - column={column} - row={row} - /> - ))} + {cells}
); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1154de7050..75be89ed47 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useCalculatedColumns'; export * from './useClickOutside'; export * from './useGridDimensions'; export * from './useViewportColumns'; diff --git a/src/hooks/useCalculatedColumns.ts b/src/hooks/useCalculatedColumns.ts new file mode 100644 index 0000000000..c866a7b05a --- /dev/null +++ b/src/hooks/useCalculatedColumns.ts @@ -0,0 +1,259 @@ +import { useMemo } from 'react'; + +import type { CalculatedColumn, Column, ColumnMetric } from '../types'; +import type { DataGridProps } from '../DataGrid'; +import { ValueFormatter, ToggleGroupFormatter } from '../formatters'; +import { SELECT_COLUMN_KEY } from '../Columns'; + +interface CalculatedColumnsArgs extends Pick, 'defaultColumnOptions'> { + rawColumns: readonly Column[]; + rawGroupBy?: readonly string[]; + viewportWidth: number; + scrollLeft: number; + columnWidths: ReadonlyMap; + enableVirtualization: boolean; +} + +export function useCalculatedColumns({ + rawColumns, + columnWidths, + viewportWidth, + scrollLeft, + defaultColumnOptions, + rawGroupBy, + enableVirtualization +}: CalculatedColumnsArgs) { + const minColumnWidth = defaultColumnOptions?.minWidth ?? 80; + const defaultFormatter = defaultColumnOptions?.formatter ?? ValueFormatter; + const defaultSortable = defaultColumnOptions?.sortable ?? false; + const defaultResizable = defaultColumnOptions?.resizable ?? false; + + const { columns, colSpanColumns, lastFrozenColumnIndex, groupBy } = useMemo(() => { + // Filter rawGroupBy and ignore keys that do not match the columns prop + const groupBy: string[] = []; + let lastFrozenColumnIndex = -1; + + const columns = rawColumns.map(rawColumn => { + const rowGroup = rawGroupBy?.includes(rawColumn.key) ?? false; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const frozen = rowGroup || rawColumn.frozen || false; + + const column: CalculatedColumn = { + ...rawColumn, + idx: 0, + frozen, + isLastFrozenColumn: false, + rowGroup, + sortable: rawColumn.sortable ?? defaultSortable, + resizable: rawColumn.resizable ?? defaultResizable, + formatter: rawColumn.formatter ?? defaultFormatter + }; + + if (rowGroup) { + column.groupFormatter ??= ToggleGroupFormatter; + } + + if (frozen) { + lastFrozenColumnIndex++; + } + + return column; + }); + + columns.sort(({ key: aKey, frozen: frozenA }, { key: bKey, frozen: frozenB }) => { + // Sort select column first: + if (aKey === SELECT_COLUMN_KEY) return -1; + if (bKey === SELECT_COLUMN_KEY) return 1; + + // Sort grouped columns second, following the groupBy order: + if (rawGroupBy?.includes(aKey)) { + if (rawGroupBy.includes(bKey)) { + return rawGroupBy.indexOf(aKey) - rawGroupBy.indexOf(bKey); + } + return -1; + } + if (rawGroupBy?.includes(bKey)) return 1; + + // Sort frozen columns third: + if (frozenA) { + if (frozenB) return 0; + return -1; + } + if (frozenB) return 1; + + // Sort other columns last: + return 0; + }); + + const colSpanColumns: CalculatedColumn[] = []; + columns.forEach((column, idx) => { + column.idx = idx; + + if (column.rowGroup) { + groupBy.push(column.key); + } + + if (column.colSpan !== undefined) { + colSpanColumns.push(column); + } + }); + + if (lastFrozenColumnIndex !== -1) { + columns[lastFrozenColumnIndex].isLastFrozenColumn = true; + } + + return { + columns, + colSpanColumns, + lastFrozenColumnIndex, + groupBy + }; + }, [rawColumns, defaultFormatter, defaultResizable, defaultSortable, rawGroupBy]); + + const { layoutCssVars, totalColumnWidth, totalFrozenColumnWidth, columnMetrics } = useMemo(() => { + const columnMetrics = new Map, ColumnMetric>(); + let left = 0; + let totalColumnWidth = 0; + let totalFrozenColumnWidth = 0; + let templateColumns = ''; + let allocatedWidth = 0; + let unassignedColumnsCount = 0; + + for (const column of columns) { + let width = getSpecifiedWidth(column, columnWidths, viewportWidth); + + if (width === undefined) { + unassignedColumnsCount++; + } else { + width = clampColumnWidth(width, column, minColumnWidth); + allocatedWidth += width; + columnMetrics.set(column, { width, left: 0 }); + } + } + + const unallocatedWidth = viewportWidth - allocatedWidth; + const unallocatedColumnWidth = unallocatedWidth / unassignedColumnsCount; + + for (const column of columns) { + let width: number; + if (columnMetrics.has(column)) { + const columnMetric = columnMetrics.get(column)!; + columnMetric.left = left; + ({ width } = columnMetric); + } else { + width = clampColumnWidth(unallocatedColumnWidth, column, minColumnWidth); + columnMetrics.set(column, { width, left }); + } + totalColumnWidth += width; + left += width; + templateColumns += `${width}px `; + } + + if (lastFrozenColumnIndex !== -1) { + const columnMetric = columnMetrics.get(columns[lastFrozenColumnIndex])!; + totalFrozenColumnWidth = columnMetric.left + columnMetric.width; + } + + const layoutCssVars: Record = { + '--template-columns': templateColumns + }; + + for (let i = 0; i <= lastFrozenColumnIndex; i++) { + const column = columns[i]; + layoutCssVars[`--frozen-left-${column.key}`] = `${columnMetrics.get(column)!.left}px`; + } + + return { layoutCssVars, totalColumnWidth, totalFrozenColumnWidth, columnMetrics }; + }, [columnWidths, columns, viewportWidth, minColumnWidth, 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 = Math.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 = Math.max(firstUnfrozenColumnIdx, colVisibleStartIdx - 1); + const colOverscanEndIdx = Math.min(lastColIdx, colVisibleEndIdx + 1); + + return [colOverscanStartIdx, colOverscanEndIdx]; + }, [columnMetrics, columns, lastFrozenColumnIndex, scrollLeft, totalFrozenColumnWidth, viewportWidth, enableVirtualization]); + + return { + columns, + colSpanColumns, + colOverscanStartIdx, + colOverscanEndIdx, + layoutCssVars, + columnMetrics, + totalColumnWidth, + lastFrozenColumnIndex, + totalFrozenColumnWidth, + groupBy + }; +} + +function getSpecifiedWidth( + { key, width }: Column, + columnWidths: ReadonlyMap, + viewportWidth: number +): number | undefined { + if (columnWidths.has(key)) { + // Use the resized width if available + return columnWidths.get(key); + } + if (typeof width === 'number') { + return width; + } + if (typeof width === 'string' && /^\d+%$/.test(width)) { + return Math.floor(viewportWidth * parseInt(width, 10) / 100); + } + return undefined; +} + +function clampColumnWidth( + width: number, + { minWidth, maxWidth }: Column, + minColumnWidth: number +): number { + width = Math.max(width, minWidth ?? minColumnWidth); + + if (typeof maxWidth === 'number') { + return Math.min(width, maxWidth); + } + + return width; +} diff --git a/src/hooks/useViewportColumns.ts b/src/hooks/useViewportColumns.ts index 33f07326f1..2feee40b17 100644 --- a/src/hooks/useViewportColumns.ts +++ b/src/hooks/useViewportColumns.ts @@ -1,254 +1,85 @@ import { useMemo } from 'react'; -import type { CalculatedColumn, Column, ColumnMetric } from '../types'; -import type { DataGridProps } from '../DataGrid'; -import { ValueFormatter, ToggleGroupFormatter } from '../formatters'; -import { SELECT_COLUMN_KEY } from '../Columns'; - -interface ViewportColumnsArgs extends Pick, 'defaultColumnOptions'> { - rawColumns: readonly Column[]; - rawGroupBy?: readonly string[]; - viewportWidth: number; - scrollLeft: number; - columnWidths: ReadonlyMap; - enableVirtualization: boolean; +import { getColSpan } from '../utils'; +import type { CalculatedColumn, GroupRow } from '../types'; + +interface ViewportColumnsArgs { + columns: readonly CalculatedColumn[]; + colSpanColumns: readonly CalculatedColumn[]; + rows: readonly (R | GroupRow)[]; + summaryRows: readonly SR[] | undefined; + colOverscanStartIdx: number; + colOverscanEndIdx: number; + lastFrozenColumnIndex: number; + rowOverscanStartIdx: number; + rowOverscanEndIdx: number; + enableFilterRow: boolean; + isGroupRow: (row: R | GroupRow) => row is GroupRow; } export function useViewportColumns({ - rawColumns, - columnWidths, - viewportWidth, - scrollLeft, - defaultColumnOptions, - rawGroupBy, - enableVirtualization + columns, + colSpanColumns, + rows, + summaryRows, + colOverscanStartIdx, + colOverscanEndIdx, + lastFrozenColumnIndex, + rowOverscanStartIdx, + rowOverscanEndIdx, + enableFilterRow, + isGroupRow }: ViewportColumnsArgs) { - const minColumnWidth = defaultColumnOptions?.minWidth ?? 80; - const defaultFormatter = defaultColumnOptions?.formatter ?? ValueFormatter; - const defaultSortable = defaultColumnOptions?.sortable ?? false; - const defaultResizable = defaultColumnOptions?.resizable ?? false; - - const { columns, lastFrozenColumnIndex, groupBy } = useMemo(() => { - // Filter rawGroupBy and ignore keys that do not match the columns prop - const groupBy: string[] = []; - let lastFrozenColumnIndex = -1; - - const columns = rawColumns.map(rawColumn => { - const rowGroup = rawGroupBy?.includes(rawColumn.key) ?? false; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const frozen = rowGroup || rawColumn.frozen || false; - - const column: CalculatedColumn = { - ...rawColumn, - idx: 0, - frozen, - isLastFrozenColumn: false, - rowGroup, - sortable: rawColumn.sortable ?? defaultSortable, - resizable: rawColumn.resizable ?? defaultResizable, - formatter: rawColumn.formatter ?? defaultFormatter - }; - - if (rowGroup) { - column.groupFormatter ??= ToggleGroupFormatter; - } - - if (frozen) { - lastFrozenColumnIndex++; - } - - return column; - }); - - columns.sort(({ key: aKey, frozen: frozenA }, { key: bKey, frozen: frozenB }) => { - // Sort select column first: - if (aKey === SELECT_COLUMN_KEY) return -1; - if (bKey === SELECT_COLUMN_KEY) return 1; - - // Sort grouped columns second, following the groupBy order: - if (rawGroupBy?.includes(aKey)) { - if (rawGroupBy.includes(bKey)) { - return rawGroupBy.indexOf(aKey) - rawGroupBy.indexOf(bKey); - } - return -1; - } - if (rawGroupBy?.includes(bKey)) return 1; - - // Sort frozen columns third: - if (frozenA) { - if (frozenB) return 0; - return -1; - } - if (frozenB) return 1; + // find the column that spans over a column within the visible columns range and adjust colOverscanStartIdx + const startIdx = useMemo(() => { + if (colOverscanStartIdx === 0) return 0; - // Sort other columns last: - return 0; - }); + let startIdx = colOverscanStartIdx; - columns.forEach((column, idx) => { - column.idx = idx; - - if (column.rowGroup) { - groupBy.push(column.key); + const updateStartIdx = (colIdx: number, colSpan: number | undefined) => { + if (colSpan !== undefined && (colIdx + colSpan) > colOverscanStartIdx) { + startIdx = colIdx; + return true; } - }); - - if (lastFrozenColumnIndex !== -1) { - columns[lastFrozenColumnIndex].isLastFrozenColumn = true; - } - - return { - columns, - lastFrozenColumnIndex, - groupBy + return false; }; - }, [rawColumns, defaultFormatter, defaultResizable, defaultSortable, rawGroupBy]); - - const { layoutCssVars, totalColumnWidth, totalFrozenColumnWidth, columnMetrics } = useMemo(() => { - const columnMetrics = new Map, ColumnMetric>(); - let left = 0; - let totalColumnWidth = 0; - let totalFrozenColumnWidth = 0; - let templateColumns = ''; - let allocatedWidth = 0; - let unassignedColumnsCount = 0; - - for (const column of columns) { - let width = getSpecifiedWidth(column, columnWidths, viewportWidth); - if (width === undefined) { - unassignedColumnsCount++; - } else { - width = clampColumnWidth(width, column, minColumnWidth); - allocatedWidth += width; - columnMetrics.set(column, { width, left: 0 }); - } - } + for (const column of colSpanColumns) { + // check header row + const colIdx = column.idx; + if (colIdx >= startIdx) break; + if (updateStartIdx(colIdx, getColSpan(column, lastFrozenColumnIndex, { type: 'HEADER' }))) break; - const unallocatedWidth = viewportWidth - allocatedWidth; - const unallocatedColumnWidth = unallocatedWidth / unassignedColumnsCount; + // check filter row + if (enableFilterRow && updateStartIdx(colIdx, getColSpan(column, lastFrozenColumnIndex, { type: 'FILTER' }))) break; - for (const column of columns) { - let width; - if (columnMetrics.has(column)) { - const columnMetric = columnMetrics.get(column)!; - columnMetric.left = left; - ({ width } = columnMetric); - } else { - width = clampColumnWidth(unallocatedColumnWidth, column, minColumnWidth); - columnMetrics.set(column, { width, left }); + // check viewport rows + for (let rowIdx = rowOverscanStartIdx; rowIdx <= rowOverscanEndIdx; rowIdx++) { + const row = rows[rowIdx]; + if (isGroupRow(row)) continue; + if (updateStartIdx(colIdx, getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }))) break; } - totalColumnWidth += width; - left += width; - templateColumns += `${width}px `; - } - if (lastFrozenColumnIndex !== -1) { - const columnMetric = columnMetrics.get(columns[lastFrozenColumnIndex])!; - totalFrozenColumnWidth = columnMetric.left + columnMetric.width; - } - - const layoutCssVars: Record = { - '--template-columns': templateColumns - }; - - for (let i = 0; i <= lastFrozenColumnIndex; i++) { - const column = columns[i]; - layoutCssVars[`--frozen-left-${column.key}`] = `${columnMetrics.get(column)!.left}px`; - } - - return { layoutCssVars, totalColumnWidth, totalFrozenColumnWidth, columnMetrics }; - }, [columnWidths, columns, viewportWidth, minColumnWidth, 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 = Math.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; + // check summary rows + if (summaryRows !== undefined) { + for (const row of summaryRows) { + if (updateStartIdx(colIdx, getColSpan(column, lastFrozenColumnIndex, { type: 'SUMMARY', row }))) break; + } } - colVisibleEndIdx++; } - const colOverscanStartIdx = Math.max(firstUnfrozenColumnIdx, colVisibleStartIdx - 1); - const colOverscanEndIdx = Math.min(lastColIdx, colVisibleEndIdx + 1); + return startIdx; + }, [rowOverscanStartIdx, rowOverscanEndIdx, rows, summaryRows, colOverscanStartIdx, lastFrozenColumnIndex, colSpanColumns, isGroupRow, enableFilterRow]); - return [colOverscanStartIdx, colOverscanEndIdx]; - }, [columnMetrics, columns, lastFrozenColumnIndex, scrollLeft, totalFrozenColumnWidth, viewportWidth, enableVirtualization]); - - const viewportColumns = useMemo((): readonly CalculatedColumn[] => { + return useMemo((): readonly CalculatedColumn[] => { const viewportColumns: CalculatedColumn[] = []; for (let colIdx = 0; colIdx <= colOverscanEndIdx; colIdx++) { const column = columns[colIdx]; - if (colIdx < colOverscanStartIdx && !column.frozen) continue; + if (colIdx < startIdx && !column.frozen) continue; viewportColumns.push(column); } return viewportColumns; - }, [colOverscanEndIdx, colOverscanStartIdx, columns]); - - return { columns, viewportColumns, layoutCssVars, columnMetrics, totalColumnWidth, lastFrozenColumnIndex, totalFrozenColumnWidth, groupBy }; -} - -function getSpecifiedWidth( - { key, width }: Column, - columnWidths: ReadonlyMap, - viewportWidth: number -): number | undefined { - if (columnWidths.has(key)) { - // Use the resized width if available - return columnWidths.get(key); - } - if (typeof width === 'number') { - return width; - } - if (typeof width === 'string' && /^\d+%$/.test(width)) { - return Math.floor(viewportWidth * parseInt(width, 10) / 100); - } - return undefined; -} - -function clampColumnWidth( - width: number, - { minWidth, maxWidth }: Column, - minColumnWidth: number -): number { - width = Math.max(width, minWidth ?? minColumnWidth); - - if (typeof maxWidth === 'number') { - return Math.min(width, maxWidth); - } - - return width; + }, [startIdx, colOverscanEndIdx, columns]); } diff --git a/src/hooks/useViewportRows.ts b/src/hooks/useViewportRows.ts index 0481c8812f..567e32139c 100644 --- a/src/hooks/useViewportRows.ts +++ b/src/hooks/useViewportRows.ts @@ -50,9 +50,9 @@ export function useViewportRows({ return groupRows(rawRows, groupBy, 0); }, [groupBy, rowGrouper, rawRows]); - const [rows, allGroupRows] = useMemo(() => { + const [rows, isGroupRow] = useMemo(() => { const allGroupRows = new Set(); - if (!groupedRows) return [rawRows, allGroupRows]; + if (!groupedRows) return [rawRows, isGroupRow]; const flattenedRows: Array> = []; const expandGroup = (rows: GroupByDictionary | readonly R[], parentId: string | undefined, level: number): void => { @@ -87,10 +87,13 @@ export function useViewportRows({ }; expandGroup(groupedRows, undefined, 0); - return [flattenedRows, allGroupRows]; + return [flattenedRows, isGroupRow]; + + function isGroupRow(row: R | GroupRow): row is GroupRow { + return allGroupRows.has(row); + } }, [expandedGroupIds, groupedRows, rawRows]); - const isGroupRow = (row: unknown): row is GroupRow => allGroupRows.has(row); if (!enableVirtualization) { return { diff --git a/src/index.ts b/src/index.ts index 2afa6125a1..8a4cde58c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,5 +23,6 @@ export type { FillEvent, PasteEvent, CellNavigationMode, - SortDirection + SortDirection, + ColSpanArgs } from './types'; diff --git a/src/types.ts b/src/types.ts index b25b205c96..f718675d3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,7 @@ export interface Column { groupFormatter?: React.ComponentType>; /** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */ editable?: boolean | ((row: TRow) => boolean); + colSpan?: (args: ColSpanArgs) => number | undefined; /** Determines whether column is frozen or not */ frozen?: boolean; /** Enable resizing of a column */ @@ -144,6 +145,7 @@ export interface SelectedCellProps extends SelectedCellPropsBase { export interface CellRendererProps extends Omit, 'style' | 'children'> { rowIdx: number; column: CalculatedColumn; + colSpan?: number; row: TRow; isCopied: boolean; isDraggedOver: boolean; @@ -163,6 +165,7 @@ export interface RowRendererProps extends Omit | SelectedCellProps; @@ -226,3 +229,13 @@ export interface GroupRow { export type CellNavigationMode = 'NONE' | 'CHANGE_ROW' | 'LOOP_OVER_ROW'; export type SortDirection = 'ASC' | 'DESC' | 'NONE'; + +export type ColSpanArgs = { + type: 'HEADER' | 'FILTER'; +} | { + type: 'ROW'; + row: R; +} | { + type: 'SUMMARY'; + row: SR; +}; diff --git a/src/utils/colSpanUtils.ts b/src/utils/colSpanUtils.ts new file mode 100644 index 0000000000..67defefdb3 --- /dev/null +++ b/src/utils/colSpanUtils.ts @@ -0,0 +1,14 @@ +import type { CalculatedColumn, ColSpanArgs } from '../types'; + +export function getColSpan(column: CalculatedColumn, lastFrozenColumnIndex: number, args: ColSpanArgs) { + const colSpan = typeof column.colSpan === 'function' ? column.colSpan(args) : 1; + if ( + Number.isInteger(colSpan) + && colSpan! > 1 + // ignore colSpan if it spans over both frozen and regular columns + && (!column.frozen || (column.idx + colSpan! - 1) <= lastFrozenColumnIndex) + ) { + return colSpan; + } + return undefined; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 16662131de..fe1ae44dc0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,7 @@ import clsx from 'clsx'; import type { CalculatedColumn } from '../types'; import { cellClassname, cellFrozenClassname, cellFrozenLastClassname } from '../style'; +export * from './colSpanUtils'; export * from './domUtils'; export * from './keyboardUtils'; export * from './selectedCellUtils'; @@ -13,10 +14,12 @@ export function assertIsValidKeyGetter(keyGetter: unknown): asserts keyGetter } } -export function getCellStyle(column: CalculatedColumn): React.CSSProperties { - return column.frozen - ? { left: `var(--frozen-left-${column.key})` } - : { gridColumnStart: column.idx + 1 }; +export function getCellStyle(column: CalculatedColumn, colSpan?: number): React.CSSProperties { + return { + gridColumnStart: column.idx + 1, + gridColumnEnd: colSpan !== undefined ? `span ${colSpan}` : undefined, + left: column.frozen ? `var(--frozen-left-${column.key})` : undefined + }; } export function getCellClassname(column: CalculatedColumn, ...extraClasses: Parameters): string { diff --git a/src/utils/selectedCellUtils.ts b/src/utils/selectedCellUtils.ts index 1b4cfe2976..4b92d670df 100644 --- a/src/utils/selectedCellUtils.ts +++ b/src/utils/selectedCellUtils.ts @@ -1,4 +1,5 @@ import type { CalculatedColumn, Position, GroupRow, CellNavigationMode } from '../types'; +import { getColSpan } from './colSpanUtils'; interface IsSelectedCellEditableOpts { selectedPosition: Position; @@ -19,11 +20,51 @@ export function isSelectedCellEditable({ selectedPosition, columns, rows, interface GetNextSelectedCellPositionOpts { cellNavigationMode: CellNavigationMode; columns: readonly CalculatedColumn[]; - rowsCount: number; + colSpanColumns: readonly CalculatedColumn[]; + rows: readonly (R | GroupRow)[]; + currentPosition: Readonly; nextPosition: Position; + lastFrozenColumnIndex: number; + isCellWithinBounds: (position: Position) => boolean; + isGroupRow: (row: R | GroupRow) => row is GroupRow; } -export function getNextSelectedCellPosition({ cellNavigationMode, columns, rowsCount, nextPosition }: GetNextSelectedCellPositionOpts): Position { +export function getNextSelectedCellPosition({ + cellNavigationMode, + columns, + colSpanColumns, + rows, + currentPosition, + nextPosition, + lastFrozenColumnIndex, + isCellWithinBounds, + isGroupRow +}: GetNextSelectedCellPositionOpts): Position { + const rowsCount = rows.length; + let position = nextPosition; + + const setColSpan = (moveRight: boolean) => { + const row = rows[position.rowIdx]; + if (!isGroupRow(row)) { + // If a cell within the colspan range is selected then move to the + // previous or the next cell depending on the navigation direction + const posIdx = position.idx; + for (const column of colSpanColumns) { + const colIdx = column.idx; + if (colIdx > posIdx) break; + const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); + if (colSpan && posIdx > colIdx && posIdx < colSpan + colIdx) { + position.idx = colIdx + (moveRight ? colSpan : 0); + break; + } + } + } + }; + + if (isCellWithinBounds(position)) { + setColSpan(position.idx - currentPosition.idx > 0); + } + if (cellNavigationMode !== 'NONE') { const { idx, rowIdx } = nextPosition; const columnsCount = columns.length; @@ -34,36 +75,38 @@ export function getNextSelectedCellPosition({ cellNavigationMode, columns if (cellNavigationMode === 'CHANGE_ROW') { const isLastRow = rowIdx === rowsCount - 1; if (!isLastRow) { - return { + position = { idx: 0, rowIdx: rowIdx + 1 }; } } else { - return { + position = { rowIdx, idx: 0 }; } + setColSpan(true); } else if (isBeforeFirstColumn) { if (cellNavigationMode === 'CHANGE_ROW') { const isFirstRow = rowIdx === 0; if (!isFirstRow) { - return { + position = { rowIdx: rowIdx - 1, idx: columnsCount - 1 }; } } else { - return { + position = { rowIdx, idx: columnsCount - 1 }; } } + setColSpan(false); } - return nextPosition; + return position; } interface CanExitGridOpts { diff --git a/stories/demos/ColumnSpanning.tsx b/stories/demos/ColumnSpanning.tsx new file mode 100644 index 0000000000..36d1a35991 --- /dev/null +++ b/stories/demos/ColumnSpanning.tsx @@ -0,0 +1,72 @@ +import { useMemo } from 'react'; +import { css } from '@linaria/core'; + +import DataGrid from '../../src'; +import type { Column, FormatterProps } from '../../src'; + +type Row = number; +const rows: readonly Row[] = [...Array(100).keys()]; + +const colSpanClassname = css` + background-color: #ffb300; + color: black; + text-align: center; +`; + +function CellFormatter(props: FormatterProps) { + return <>{props.column.key}×{props.rowIdx}; +} + +export function ColumnSpanning() { + const columns = useMemo((): readonly Column[] => { + const columns: Column[] = []; + + for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + resizable: true, + formatter: CellFormatter, + colSpan(args) { + if (args.type === 'ROW') { + if (key === '2' && args.row === 2) return 3; + if (key === '4' && args.row === 4) return 6; // Will not work as colspan includes both frozen and regular columns + if (key === '0' && args.row === 5) return 5; + if (key === '27' && args.row === 8) return 3; + if (key === '6' && args.row < 8) return 2; + } + if (args.type === 'HEADER' && key === '8') { + return 3; + } + return undefined; + }, + cellClass(row) { + if ( + (key === '0' && row === 5) + || (key === '2' && row === 2) + || (key === '27' && row === 8) + || (key === '6' && row < 8) + ) { + return colSpanClassname; + } + return undefined; + } + }); + } + + return columns; + }, []); + + return ( + + ); +} + +ColumnSpanning.storyName = 'Column Spanning'; diff --git a/stories/index.story.ts b/stories/index.story.ts index 17176a3615..2d1fec5300 100644 --- a/stories/index.story.ts +++ b/stories/index.story.ts @@ -8,6 +8,7 @@ export * from './demos/ContextMenu'; export * from './demos/ScrollToRow'; export * from './demos/CellNavigation'; export * from './demos/HeaderFilters'; +export * from './demos/ColumnSpanning'; export * from './demos/ColumnsReordering'; export * from './demos/RowsReordering'; export * from './demos/Grouping'; diff --git a/test/column/colSpan.test.ts b/test/column/colSpan.test.ts new file mode 100644 index 0000000000..e99a43fdc6 --- /dev/null +++ b/test/column/colSpan.test.ts @@ -0,0 +1,118 @@ +import userEvent from '@testing-library/user-event'; + +import type { Column } from '../../src'; +import { setup, getCellsAtRowIndex, getHeaderCells, getSelectedCell } from '../utils'; + +describe('colSpan', () => { + function setupColSpanGrid() { + const columns: Column[] = []; + type Row = number; + const rows: readonly Row[] = [...Array(10).keys()]; + + for (let i = 0; i < 15; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + colSpan(args) { + if (args.type === 'ROW') { + if (key === '2' && args.row === 2) return 3; + if (key === '4' && args.row === 4) return 6; // Will not work as colspan includes both frozen and regular columns + if (key === '0' && args.row === 5) return 5; + if (key === '12' && args.row === 8) return 3; + if (key === '6' && args.row < 8) return 2; + } + if (args.type === 'HEADER' && key === '8') { + return 3; + } + return undefined; + } + }); + } + setup({ columns, rows }); + } + + it('should merges cells', () => { + setupColSpanGrid(); + // header + expect(getHeaderCells()).toHaveLength(13); + + // rows + const row1 = getCellsAtRowIndex(1); + expect(row1).toHaveLength(14); + // 7th-8th cells are merged + expect(row1[6]).toHaveAttribute('aria-colindex', '7'); + expect(row1[7]).toHaveAttribute('aria-colindex', '9'); + expect(row1[6]).toHaveStyle({ + 'grid-column-start': '7', + 'grid-column-end': 'span 2' + }); + + // 3rd-5th, 7th-8th cells are merged + const row2 = getCellsAtRowIndex(2); + expect(row2).toHaveLength(12); + expect(row2[2]).toHaveAttribute('aria-colindex', '3'); + expect(row2[2]).toHaveStyle({ + 'grid-column-start': '3', + 'grid-column-end': 'span 3' + }); + expect(row2[3]).toHaveAttribute('aria-colindex', '6'); + expect(row2[4]).toHaveAttribute('aria-colindex', '7'); + expect(row2[4]).toHaveStyle({ + 'grid-column-start': '7', + 'grid-column-end': 'span 2' + }); + expect(row2[5]).toHaveAttribute('aria-colindex', '9'); + + expect(getCellsAtRowIndex(4)).toHaveLength(14); // colSpan 6 won't work as there are 5 frozen columns + expect(getCellsAtRowIndex(5)).toHaveLength(10); + }); + + it('should navigate between merged cells', () => { + setupColSpanGrid(); + userEvent.click(getCellsAtRowIndex(1)[1]); + testSelectedCell(1, 1); + userEvent.type(document.activeElement!, '{arrowright}'); + testSelectedCell(1, 2); + userEvent.type(document.activeElement!, '{arrowright}'); + testSelectedCell(1, 3); + userEvent.type(document.activeElement!, '{arrowdown}'); + testSelectedCell(2, 2); + userEvent.type(document.activeElement!, '{arrowleft}'); + testSelectedCell(2, 1); + userEvent.type(document.activeElement!, '{arrowright}'); + testSelectedCell(2, 2); + userEvent.type(document.activeElement!, '{arrowright}'); + testSelectedCell(2, 5); + userEvent.type(document.activeElement!, '{arrowleft}'); + testSelectedCell(2, 2); + userEvent.type(document.activeElement!, '{arrowdown}'); + testSelectedCell(3, 2); + userEvent.type(document.activeElement!, '{arrowdown}{arrowdown}'); + testSelectedCell(5, 0); + userEvent.type(document.activeElement!, '{arrowLeft}'); + testSelectedCell(5, 0); + userEvent.type(document.activeElement!, '{arrowright}'); + testSelectedCell(5, 5); + userEvent.tab({ shift: true }); + userEvent.tab({ shift: true }); + testSelectedCell(4, 14); + userEvent.tab(); + testSelectedCell(5, 0); + userEvent.click(getCellsAtRowIndex(8)[11]); + testSelectedCell(8, 11); + userEvent.tab(); + testSelectedCell(8, 12); + userEvent.tab(); + testSelectedCell(9, 0); + userEvent.tab({ shift: true }); + testSelectedCell(8, 12); + + function testSelectedCell(expectedRowIdx: number, expectedColIdx: number) { + const selectedCell = getSelectedCell(); + expect(selectedCell).toHaveAttribute('aria-colindex', `${expectedColIdx + 1}`); + expect(selectedCell!.parentElement).toHaveAttribute('aria-rowindex', `${expectedRowIdx + 2}`); // +1 to account for the header row + } + }); +}); diff --git a/test/utils.tsx b/test/utils.tsx index 253fda940a..19ab14342c 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -1,5 +1,5 @@ import { StrictMode } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import DataGrid from '../src/'; import type { DataGridProps } from '../src/'; @@ -15,6 +15,10 @@ export function getRows() { return screen.getAllByRole('row').slice(1); } +export function getCellsAtRowIndex(rowIdx: number) { + return within(getRows()[rowIdx]).getAllByRole('gridcell'); +} + export function getCells() { return screen.getAllByRole('gridcell'); }