From 84885d5ceb9e3ec2f244d012983b3a164860aae1 Mon Sep 17 00:00:00 2001 From: Pavol Date: Fri, 24 Apr 2026 19:43:28 +0200 Subject: [PATCH 1/2] Add frozen: 'start' | 'end' for end-edge column pinning --- README.md | 16 +- src/DataGrid.tsx | 119 +++++---- src/GroupRow.tsx | 7 +- src/HeaderRow.tsx | 4 +- src/hooks/useCalculatedColumns.ts | 84 +++++-- src/hooks/useViewportColumns.ts | 53 ++-- src/style/cell.ts | 10 + src/style/core.ts | 27 ++ src/style/row.ts | 11 +- src/types.ts | 7 +- src/utils/activePositionUtils.ts | 26 +- src/utils/colSpanUtils.ts | 21 +- src/utils/index.ts | 5 + src/utils/styleUtils.ts | 15 +- test/browser/column/frozenEnd.test.ts | 346 ++++++++++++++++++++++++++ test/browser/direction.test.ts | 29 +++ test/browser/virtualization.test.ts | 66 +++++ website/routes/CommonFeatures.tsx | 3 +- 18 files changed, 743 insertions(+), 106 deletions(-) create mode 100644 test/browser/column/frozenEnd.test.ts diff --git a/README.md b/README.md index da0114b936..366ea733c0 100644 --- a/README.md +++ b/README.md @@ -1377,11 +1377,23 @@ const columns: readonly Column[] = [ ]; ``` -##### `frozen?: Maybe` +##### `frozen?: Maybe` **Default**: `false` -Determines whether column is frozen. Frozen columns are pinned to the start edge (left in LTR, right in RTL). Per-column pinning to the end edge is not supported at the moment. +Determines whether the column is frozen, and on which edge. Frozen columns stay in place when the grid is scrolled horizontally. + +- `'start'` (or `true` for backwards compatibility) — pins the column to the start edge (left in LTR, right in RTL). +- `'end'` — pins the column to the end edge (right in LTR, left in RTL). +- `false` (default) — the column scrolls with the rest of the grid. + +```tsx +const columns: readonly Column[] = [ + { key: 'id', name: 'ID', frozen: 'start' }, + { key: 'name', name: 'Name' }, + { key: 'actions', name: 'Actions', frozen: 'end' } +]; +``` ##### `resizable?: Maybe` diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index f9bc6427f8..a3f1d875f8 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -75,8 +75,10 @@ import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/ import { rootClassname, frozenColumnShadowClassname, + frozenColumnShadowEndClassname, viewportDraggingClassname, - frozenColumnShadowTopClassname + frozenColumnShadowTopClassname, + frozenColumnShadowEndTopClassname } from './style/core'; import SummaryRow from './SummaryRow'; @@ -344,12 +346,14 @@ export function DataGrid(props: DataGridPr columns, colSpanColumns, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, headerRowsCount, colOverscanStartIdx, colOverscanEndIdx, templateColumns, layoutCssVars, - totalFrozenColumnWidth + totalFrozenColumnWidth, + totalEndFrozenColumnWidth } = useCalculatedColumns({ rawColumns, defaultColumnOptions, @@ -382,6 +386,11 @@ export function DataGrid(props: DataGridPr gridColumnStart: lastFrozenColumnIndex + 2, insetInlineStart: totalFrozenColumnWidth }; + const frozenEndShadowStyles: React.CSSProperties = { + gridColumnStart: firstEndFrozenColumnIndex + 1, + gridColumnEnd: -1, + insetInlineEnd: totalEndFrozenColumnWidth + }; const { activePosition, @@ -464,6 +473,7 @@ export function DataGrid(props: DataGridPr colOverscanStartIdx, colOverscanEndIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, rowOverscanStartIdx, rowOverscanEndIdx, rows, @@ -903,6 +913,7 @@ export function DataGrid(props: DataGridPr mainHeaderRowIdx, maxRowIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, cellNavigationMode, activePosition, nextPosition, @@ -967,6 +978,53 @@ export function DataGrid(props: DataGridPr ); } + function renderFrozenShadow( + shadowStyles: React.CSSProperties, + bodyClassname: string, + topClassname: string + ) { + return ( + <> +
+ + {rows.length > 0 && ( +
+ )} + + {bottomSummaryRows != null && bottomSummaryRowsCount > 0 && ( +
totalRowHeight + ? gridHeight - summaryRowHeight * bottomSummaryRowsCount + : undefined, + insetBlockEnd: clientHeight > totalRowHeight ? undefined : 0 + }} + /> + )} + + ); + } + function getCellEditor(rowIdx: number) { if ( !activePositionIsCellInViewport || @@ -978,7 +1036,10 @@ export function DataGrid(props: DataGridPr const { row } = activePosition; const column = getActiveColumn(); - const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); + const colSpan = getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, { + type: 'ROW', + row + }); function closeEditor(shouldFocus: boolean) { const newPosition: ActivePosition = { idx: activePosition.idx, rowIdx, mode: 'ACTIVE' }; @@ -1114,6 +1175,7 @@ export function DataGrid(props: DataGridPr ...style, // set scrollPadding to correctly scroll to non-sticky cells/rows scrollPaddingInlineStart: totalFrozenColumnWidth, + scrollPaddingInlineEnd: totalEndFrozenColumnWidth, scrollPaddingBlockStart: headerRowsHeight + topSummaryRowsCount * summaryRowHeight, scrollPaddingBlockEnd: bottomSummaryRowsCount * summaryRowHeight, gridTemplateColumns, @@ -1227,46 +1289,19 @@ export function DataGrid(props: DataGridPr )} - {lastFrozenColumnIndex > -1 && ( - <> -
+ {lastFrozenColumnIndex > -1 && + renderFrozenShadow( + frozenShadowStyles, + frozenColumnShadowClassname, + frozenColumnShadowTopClassname + )} - {rows.length > 0 && ( -
- )} - - {bottomSummaryRows != null && bottomSummaryRowsCount > 0 && ( -
totalRowHeight - ? gridHeight - summaryRowHeight * bottomSummaryRowsCount - : undefined, - insetBlockEnd: clientHeight > totalRowHeight ? undefined : 0 - }} - /> - )} - - )} + {firstEndFrozenColumnIndex > -1 && + renderFrozenShadow( + frozenEndShadowStyles, + frozenColumnShadowEndClassname, + frozenColumnShadowEndTopClassname + )} {getDragHandle()} diff --git a/src/GroupRow.tsx b/src/GroupRow.tsx index 6c02310b47..f7738f3a64 100644 --- a/src/GroupRow.tsx +++ b/src/GroupRow.tsx @@ -6,7 +6,7 @@ import { classnames } from './utils'; import type { BaseRenderRowProps, GroupRow, Omit } from './types'; import { SELECT_COLUMN_KEY } from './Columns'; import GroupCell from './GroupCell'; -import { cell, cellFrozen } from './style/cell'; +import { cell, cellFrozen, cellFrozenEnd } from './style/cell'; import { rowClassname, rowActiveClassname } from './style/row'; const groupRow = css` @@ -15,8 +15,9 @@ const groupRow = css` background-color: var(--rdg-header-background-color); } - > .${cell}:not(:last-child, .${cellFrozen}), - > :nth-last-child(n + 2 of .${cellFrozen}) { + > .${cell}:not(:last-child, .${cellFrozen}, .${cellFrozenEnd}), + > :nth-last-child(n + 2 of .${cellFrozen}), + > :nth-child(n + 2 of .${cellFrozenEnd}) { border-inline-end: none; } } diff --git a/src/HeaderRow.tsx b/src/HeaderRow.tsx index 8c0f140706..3599efe640 100644 --- a/src/HeaderRow.tsx +++ b/src/HeaderRow.tsx @@ -12,7 +12,7 @@ import type { } from './types'; import type { DataGridProps } from './DataGrid'; import HeaderCell from './HeaderCell'; -import { cell, cellFrozen } from './style/cell'; +import { cell, cellFrozen, cellFrozenEnd } from './style/cell'; import { rowActiveClassname } from './style/row'; type SharedDataGridProps = Pick< @@ -44,7 +44,7 @@ const headerRow = css` position: sticky; } - & > .${cellFrozen} { + & > .${cellFrozen}, & > .${cellFrozenEnd} { z-index: 3; } } diff --git a/src/hooks/useCalculatedColumns.ts b/src/hooks/useCalculatedColumns.ts index 9929d91a17..adcc487313 100644 --- a/src/hooks/useCalculatedColumns.ts +++ b/src/hooks/useCalculatedColumns.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { clampColumnWidth, max, min } from '../utils'; +import { clampColumnWidth, isStartFrozen, max, min } from '../utils'; import type { CalculatedColumn, CalculatedColumnParent, ColumnOrColumnGroup, Omit } from '../types'; import { renderValue } from '../cellRenderers'; import { SELECT_COLUMN_KEY } from '../Columns'; @@ -54,13 +54,21 @@ export function useCalculatedColumns({ const defaultResizable = defaultColumnOptions?.resizable ?? false; const defaultDraggable = defaultColumnOptions?.draggable ?? false; - const { columns, colSpanColumns, lastFrozenColumnIndex, headerRowsCount } = useMemo((): { + const { + columns, + colSpanColumns, + lastFrozenColumnIndex, + firstEndFrozenColumnIndex, + headerRowsCount + } = useMemo((): { readonly columns: readonly CalculatedColumn[]; readonly colSpanColumns: readonly CalculatedColumn[]; readonly lastFrozenColumnIndex: number; + readonly firstEndFrozenColumnIndex: number; readonly headerRowsCount: number; } => { let lastFrozenColumnIndex = -1; + let firstEndFrozenColumnIndex = -1; let headerRowsCount = 1; const columns: MutableCalculatedColumn[] = []; @@ -86,7 +94,7 @@ export function useCalculatedColumns({ continue; } - const frozen = rawColumn.frozen ?? false; + const frozen: boolean | 'start' | 'end' = rawColumn.frozen ?? false; const column: MutableCalculatedColumn = { ...rawColumn, @@ -106,7 +114,7 @@ export function useCalculatedColumns({ columns.push(column); - if (frozen) { + if (isStartFrozen(frozen)) { lastFrozenColumnIndex++; } @@ -116,22 +124,16 @@ export function useCalculatedColumns({ } } - columns.sort(({ key: aKey, frozen: frozenA }, { key: bKey, frozen: frozenB }) => { + columns.sort((a, b) => { // Sort select column first: - if (aKey === SELECT_COLUMN_KEY) return -1; - if (bKey === SELECT_COLUMN_KEY) return 1; - - // Sort frozen columns second: - if (frozenA) { - if (frozenB) return 0; - return -1; - } - if (frozenB) return 1; - - // TODO: sort columns to keep them grouped if they have a parent - - // Sort other columns last: - return 0; + if (a.key === SELECT_COLUMN_KEY) return -1; + if (b.key === SELECT_COLUMN_KEY) return 1; + + // Sort by band: start-frozen → unfrozen → end-frozen. + // Stable sort preserves definition order within each band. + const ra = a.frozen === 'end' ? 2 : a.frozen === false ? 1 : 0; + const rb = b.frozen === 'end' ? 2 : b.frozen === false ? 1 : 0; + return ra - rb; }); const colSpanColumns: CalculatedColumn[] = []; @@ -142,12 +144,17 @@ export function useCalculatedColumns({ if (column.colSpan != null) { colSpanColumns.push(column); } + + if (column.frozen === 'end' && firstEndFrozenColumnIndex === -1) { + firstEndFrozenColumnIndex = idx; + } }); return { columns, colSpanColumns, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, headerRowsCount }; }, [ @@ -162,15 +169,23 @@ export function useCalculatedColumns({ defaultDraggable ]); - const { templateColumns, layoutCssVars, totalFrozenColumnWidth, columnMetrics } = useMemo((): { + const { + templateColumns, + layoutCssVars, + totalFrozenColumnWidth, + totalEndFrozenColumnWidth, + columnMetrics + } = useMemo((): { templateColumns: readonly string[]; layoutCssVars: Readonly>; totalFrozenColumnWidth: number; + totalEndFrozenColumnWidth: number; columnMetrics: ReadonlyMap, ColumnMetric>; } => { const columnMetrics = new Map, ColumnMetric>(); let left = 0; let totalFrozenColumnWidth = 0; + let totalEndFrozenColumnWidth = 0; const templateColumns: string[] = []; for (const column of columns) { @@ -200,8 +215,29 @@ export function useCalculatedColumns({ layoutCssVars[`--rdg-frozen-left-${column.idx}`] = `${columnMetrics.get(column)!.left}px`; } - return { templateColumns, layoutCssVars, totalFrozenColumnWidth, columnMetrics }; - }, [getColumnWidth, columns, lastFrozenColumnIndex]); + if (firstEndFrozenColumnIndex !== -1) { + const lastColumn = columns[columns.length - 1]; + const lastMetric = columnMetrics.get(lastColumn)!; + const gridEnd = lastMetric.left + lastMetric.width; + const firstEndMetric = columnMetrics.get(columns[firstEndFrozenColumnIndex])!; + totalEndFrozenColumnWidth = gridEnd - firstEndMetric.left; + + for (let i = firstEndFrozenColumnIndex; i < columns.length; i++) { + const column = columns[i]; + const metric = columnMetrics.get(column)!; + layoutCssVars[`--rdg-frozen-end-${column.idx}`] = + `${gridEnd - (metric.left + metric.width)}px`; + } + } + + return { + templateColumns, + layoutCssVars, + totalFrozenColumnWidth, + totalEndFrozenColumnWidth, + columnMetrics + }; + }, [getColumnWidth, columns, lastFrozenColumnIndex, firstEndFrozenColumnIndex]); const [colOverscanStartIdx, colOverscanEndIdx] = useMemo((): [number, number] => { if (!enableVirtualization) { @@ -266,7 +302,9 @@ export function useCalculatedColumns({ layoutCssVars, headerRowsCount, lastFrozenColumnIndex, - totalFrozenColumnWidth + firstEndFrozenColumnIndex, + totalFrozenColumnWidth, + totalEndFrozenColumnWidth }; } diff --git a/src/hooks/useViewportColumns.ts b/src/hooks/useViewportColumns.ts index a36ceac8a8..8516ed7121 100644 --- a/src/hooks/useViewportColumns.ts +++ b/src/hooks/useViewportColumns.ts @@ -19,6 +19,7 @@ interface ViewportColumnsArgs { colOverscanStartIdx: number; colOverscanEndIdx: number; lastFrozenColumnIndex: number; + firstEndFrozenColumnIndex: number; rowOverscanStartIdx: number; rowOverscanEndIdx: number; } @@ -32,6 +33,7 @@ export function useViewportColumns({ colOverscanStartIdx, colOverscanEndIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, rowOverscanStartIdx, rowOverscanEndIdx }: ViewportColumnsArgs) { @@ -69,7 +71,7 @@ export function useViewportColumns({ if (colIdx >= colOverscanStartIdx) break; for (const args of iterateOverRowsForColSpanArgs()) { - const colSpan = getColSpan(column, lastFrozenColumnIndex, args); + const colSpan = getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, args); if (colSpan !== undefined && colIdx + colSpan > colOverscanStartIdx) { return colIdx; @@ -86,30 +88,48 @@ export function useViewportColumns({ bottomSummaryRows, colOverscanStartIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, colSpanColumns ]); + // Effective inclusive upper bound for overscan in the unfrozen band. + // When end-frozen columns exist, unfrozen band ends just before them. + const effectiveOverscanEndIdx = + firstEndFrozenColumnIndex > -1 + ? Math.min(colOverscanEndIdx, firstEndFrozenColumnIndex - 1) + : colOverscanEndIdx; + const iterateOverViewportColumns = useCallback>( function* (activeColumnIdx): Generator> { for (let colIdx = 0; colIdx <= lastFrozenColumnIndex; colIdx++) { yield columns[colIdx]; } - if (columns.length === lastFrozenColumnIndex + 1) return; + const unfrozenLastIdx = + firstEndFrozenColumnIndex > -1 ? firstEndFrozenColumnIndex - 1 : columns.length - 1; - if (activeColumnIdx > lastFrozenColumnIndex && activeColumnIdx < startIdx) { - yield columns[activeColumnIdx]; - } + if (lastFrozenColumnIndex < unfrozenLastIdx) { + if (activeColumnIdx > lastFrozenColumnIndex && activeColumnIdx < startIdx) { + yield columns[activeColumnIdx]; + } - for (let colIdx = startIdx; colIdx <= colOverscanEndIdx; colIdx++) { - yield columns[colIdx]; + for (let colIdx = startIdx; colIdx <= effectiveOverscanEndIdx; colIdx++) { + yield columns[colIdx]; + } + + if (activeColumnIdx > effectiveOverscanEndIdx && activeColumnIdx <= unfrozenLastIdx) { + yield columns[activeColumnIdx]; + } } - if (activeColumnIdx > colOverscanEndIdx && activeColumnIdx < columns.length) { - yield columns[activeColumnIdx]; + // Always yield end-frozen tail (virtualization must keep these in the DOM) + if (firstEndFrozenColumnIndex > -1) { + for (let colIdx = firstEndFrozenColumnIndex; colIdx < columns.length; colIdx++) { + yield columns[colIdx]; + } } }, - [startIdx, colOverscanEndIdx, columns, lastFrozenColumnIndex] + [startIdx, effectiveOverscanEndIdx, columns, lastFrozenColumnIndex, firstEndFrozenColumnIndex] ); const iterateOverViewportColumnsForRow = useCallback>( @@ -117,7 +137,8 @@ export function useViewportColumns({ const iterator = iterateOverViewportColumns(activeColumnIdx); for (const column of iterator) { - let colSpan = args && getColSpan(column, lastFrozenColumnIndex, args); + let colSpan = + args && getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, args); yield [column, column.idx === activeColumnIdx, colSpan]; @@ -128,7 +149,7 @@ export function useViewportColumns({ } } }, - [iterateOverViewportColumns, lastFrozenColumnIndex] + [iterateOverViewportColumns, lastFrozenColumnIndex, firstEndFrozenColumnIndex] ); const iterateOverViewportColumnsForRowOutsideOfViewport = useCallback< @@ -137,10 +158,14 @@ export function useViewportColumns({ function* (activeColumnIdx = -1, args): Generator> { if (activeColumnIdx >= 0 && activeColumnIdx < columns.length) { const column = columns[activeColumnIdx]; - yield [column, true, args && getColSpan(column, lastFrozenColumnIndex, args)]; + yield [ + column, + true, + args && getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, args) + ]; } }, - [columns, lastFrozenColumnIndex] + [columns, lastFrozenColumnIndex, firstEndFrozenColumnIndex] ); const viewportColumns = useMemo((): readonly CalculatedColumn[] => { diff --git a/src/style/cell.ts b/src/style/cell.ts index f9b9500526..9976702b70 100644 --- a/src/style/cell.ts +++ b/src/style/cell.ts @@ -40,6 +40,16 @@ export const cellFrozen = css` export const cellFrozenClassname = `rdg-cell-frozen ${cellFrozen}`; +export const cellFrozenEnd = css` + @layer rdg.Cell { + position: sticky; + /* Should have a higher value than 0 to show up above unfrozen cells */ + z-index: 1; + } +`; + +export const cellFrozenEndClassname = `rdg-cell-frozen-end ${cellFrozenEnd}`; + const cellDragHandle = css` @layer rdg.DragHandle { --rdg-drag-handle-size: 8px; diff --git a/src/style/core.ts b/src/style/core.ts index 585b12e921..b9d46cf232 100644 --- a/src/style/core.ts +++ b/src/style/core.ts @@ -117,9 +117,36 @@ export const frozenColumnShadowClassname = css` } `; +// Add shadow before the first end-frozen cell (mirror of the start shadow) +export const frozenColumnShadowEndClassname = css` + position: sticky; + width: 10px; + background-image: linear-gradient( + to left, + light-dark(rgb(0 0 0 / 15%), rgb(0 0 0 / 40%)), + transparent + ); + pointer-events: none; + z-index: 1; + + opacity: 1; + transition: opacity 0.1s; + + /* TODO: reverse 'opacity' and remove 'not' */ + @container rdg-root not scroll-state(scrollable: inline-end) { + opacity: 0; + } + + &:dir(rtl) { + transform: scaleX(-1); + } +`; + const topShadowClassname = css` /* render above header and summary rows */ z-index: 2; `; export const frozenColumnShadowTopClassname = `${frozenColumnShadowClassname} ${topShadowClassname}`; + +export const frozenColumnShadowEndTopClassname = `${frozenColumnShadowEndClassname} ${topShadowClassname}`; diff --git a/src/style/row.ts b/src/style/row.ts index a7feeb7743..35695988e6 100644 --- a/src/style/row.ts +++ b/src/style/row.ts @@ -1,6 +1,6 @@ import { css } from 'ecij'; -import { cellFrozen } from './cell'; +import { cellFrozen, cellFrozenEnd } from './cell'; export const row = css` @layer rdg.Row { @@ -35,6 +35,15 @@ export const row = css` inset-inline-start: 0; border-inline-start: var(--rdg-selection-width) solid var(--rdg-selection-color); } + + & > .${cellFrozenEnd}:last-child::after { + content: ''; + display: inline-block; + position: absolute; + inset-block: 0; + inset-inline-end: 0; + border-inline-end: var(--rdg-selection-width) solid var(--rdg-selection-color); + } } &[aria-selected='true'] { diff --git a/src/types.ts b/src/types.ts index a981e4d915..2c4593de29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,8 +46,9 @@ export interface Column { /** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */ readonly editable?: Maybe boolean)>; readonly colSpan?: Maybe<(args: ColSpanArgs) => Maybe>; - /** Determines whether column is frozen */ - readonly frozen?: Maybe; + /** Determines whether column is frozen, and on which edge. + * `true` is an alias for `'start'` for backwards compatibility. */ + readonly frozen?: Maybe; /** Enable resizing of the column */ readonly resizable?: Maybe; /** Enable sorting of the column */ @@ -88,7 +89,7 @@ export interface CalculatedColumn extends Column) => ReactNode; readonly renderHeaderCell: (props: RenderHeaderCellProps) => ReactNode; } diff --git a/src/utils/activePositionUtils.ts b/src/utils/activePositionUtils.ts index 85f125e5d2..be375704b3 100644 --- a/src/utils/activePositionUtils.ts +++ b/src/utils/activePositionUtils.ts @@ -31,6 +31,7 @@ interface GetNextPositionOpts { nextPosition: Position; nextPositionIsCellInActiveBounds: boolean; lastFrozenColumnIndex: number; + firstEndFrozenColumnIndex: number; } function getCellColSpan({ @@ -40,17 +41,25 @@ function getCellColSpan({ rowIdx, mainHeaderRowIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, column }: Pick< GetNextPositionOpts, - 'rows' | 'topSummaryRows' | 'bottomSummaryRows' | 'lastFrozenColumnIndex' | 'mainHeaderRowIdx' + | 'rows' + | 'topSummaryRows' + | 'bottomSummaryRows' + | 'lastFrozenColumnIndex' + | 'firstEndFrozenColumnIndex' + | 'mainHeaderRowIdx' > & { rowIdx: number; column: CalculatedColumn; }) { const topSummaryRowsCount = topSummaryRows?.length ?? 0; if (rowIdx === mainHeaderRowIdx) { - return getColSpan(column, lastFrozenColumnIndex, { type: 'HEADER' }); + return getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, { + type: 'HEADER' + }); } if ( @@ -58,7 +67,7 @@ function getCellColSpan({ rowIdx > mainHeaderRowIdx && rowIdx <= topSummaryRowsCount + mainHeaderRowIdx ) { - return getColSpan(column, lastFrozenColumnIndex, { + return getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, { type: 'SUMMARY', row: topSummaryRows[rowIdx + topSummaryRowsCount] }); @@ -66,11 +75,14 @@ function getCellColSpan({ if (rowIdx >= 0 && rowIdx < rows.length) { const row = rows[rowIdx]; - return getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); + return getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, { + type: 'ROW', + row + }); } if (bottomSummaryRows) { - return getColSpan(column, lastFrozenColumnIndex, { + return getColSpan(column, lastFrozenColumnIndex, firstEndFrozenColumnIndex, { type: 'SUMMARY', row: bottomSummaryRows[rowIdx - rows.length] }); @@ -94,7 +106,8 @@ export function getNextActivePosition({ activePosition: { idx: activeIdx, rowIdx: activeRowIdx }, nextPosition, nextPositionIsCellInActiveBounds, - lastFrozenColumnIndex + lastFrozenColumnIndex, + firstEndFrozenColumnIndex }: GetNextPositionOpts): Position { let { idx: nextIdx, rowIdx: nextRowIdx } = nextPosition; const columnsCount = columns.length; @@ -112,6 +125,7 @@ export function getNextActivePosition({ rowIdx: nextRowIdx, mainHeaderRowIdx, lastFrozenColumnIndex, + firstEndFrozenColumnIndex, column }); diff --git a/src/utils/colSpanUtils.ts b/src/utils/colSpanUtils.ts index da83a5c6d3..f043aa0ed3 100644 --- a/src/utils/colSpanUtils.ts +++ b/src/utils/colSpanUtils.ts @@ -1,22 +1,31 @@ import type { CalculatedColumn, ColSpanArgs } from '../types'; +import { isStartFrozen } from './index'; export function getColSpan( column: CalculatedColumn, lastFrozenColumnIndex: number, + firstEndFrozenColumnIndex: number, args: ColSpanArgs ): number | undefined { if (typeof column.colSpan !== 'function') return undefined; const colSpan = column.colSpan(args); + if (!Number.isInteger(colSpan) || colSpan! <= 1) return undefined; + + const spanEnd = column.idx + colSpan! - 1; + + // start-frozen column: span must stay within the start-frozen band + if (isStartFrozen(column.frozen) && spanEnd > lastFrozenColumnIndex) return undefined; + // unfrozen column: span must not enter the end-frozen band if ( - Number.isInteger(colSpan) && - colSpan! > 1 && - // ignore colSpan if it spans over both frozen and regular columns - (!column.frozen || column.idx + colSpan! - 1 <= lastFrozenColumnIndex) + column.frozen === false && + firstEndFrozenColumnIndex !== -1 && + spanEnd >= firstEndFrozenColumnIndex ) { - return colSpan!; + return undefined; } + // end-frozen columns are the contiguous tail, so spans within the band are self-contained - return undefined; + return colSpan!; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 592b6daad0..9dee7455d0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -38,3 +38,8 @@ export function getHeaderCellRowSpan( ) { return column.parent === undefined ? rowIdx : column.level - column.parent.level; } + +// Shared predicate — `frozen: true` is the backwards-compatible alias for `frozen: 'start'`. +export function isStartFrozen(frozen: boolean | 'start' | 'end'): boolean { + return frozen === true || frozen === 'start'; +} diff --git a/src/utils/styleUtils.ts b/src/utils/styleUtils.ts index cfc720e69a..65147be223 100644 --- a/src/utils/styleUtils.ts +++ b/src/utils/styleUtils.ts @@ -1,5 +1,6 @@ import type { CalculatedColumn, CalculatedColumnOrColumnGroup, Maybe } from '../types'; -import { cellClassname, cellFrozenClassname } from '../style/cell'; +import { isStartFrozen } from './index'; +import { cellClassname, cellFrozenClassname, cellFrozenEndClassname } from '../style/cell'; export function getHeaderCellStyle( column: CalculatedColumnOrColumnGroup, @@ -34,7 +35,10 @@ export function getCellStyle( return { gridColumnStart: index, gridColumnEnd: index + colSpan, - insetInlineStart: column.frozen ? `var(--rdg-frozen-left-${column.idx})` : undefined + insetInlineStart: isStartFrozen(column.frozen) + ? `var(--rdg-frozen-left-${column.idx})` + : undefined, + insetInlineEnd: column.frozen === 'end' ? `var(--rdg-frozen-end-${column.idx})` : undefined }; } @@ -56,5 +60,10 @@ export function getCellClassname( column: CalculatedColumn, ...extraClasses: readonly ClassValue[] ): string { - return classnames(cellClassname, column.frozen && cellFrozenClassname, ...extraClasses); + return classnames( + cellClassname, + isStartFrozen(column.frozen) && cellFrozenClassname, + column.frozen === 'end' && cellFrozenEndClassname, + ...extraClasses + ); } diff --git a/test/browser/column/frozenEnd.test.ts b/test/browser/column/frozenEnd.test.ts new file mode 100644 index 0000000000..f27ad2c8d3 --- /dev/null +++ b/test/browser/column/frozenEnd.test.ts @@ -0,0 +1,346 @@ +import { page, userEvent } from 'vitest/browser'; + +import type { Column } from '../../../src'; +import { + cellClassname, + cellFrozenClassname, + cellFrozenEndClassname +} from '../../../src/style/cell'; +import { getCellsAtRowIndex, safeTab, setup } from '../utils'; + +const headerCells = page.getHeaderCell(); +const dragHandle = page.getDragHandle(); + +test('end-frozen columns have a specific class and are stable-sorted at the tail', async () => { + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1', + frozen: 'start' + }, + { + key: 'col2', + name: 'col2' + }, + { + key: 'col3', + name: 'col3', + frozen: 'end' + }, + { + key: 'col4', + name: 'col4' + }, + { + key: 'col5', + name: 'col5', + frozen: 'end' + } + ]; + + await setup({ columns, rows: [] }); + + // Expected DOM order: col1 (start), col2, col4 (unfrozen), col3 (end), col5 (end) + const [cell1, cell2, cell3, cell4, cell5] = headerCells.all(); + + await expect.element(cell1).toHaveTextContent('col1'); + await expect.element(cell2).toHaveTextContent('col2'); + await expect.element(cell3).toHaveTextContent('col4'); + await expect.element(cell4).toHaveTextContent('col3'); + await expect.element(cell5).toHaveTextContent('col5'); + + await expect.element(cell1).toHaveClass(cellClassname, cellFrozenClassname, { exact: true }); + await expect.element(cell2).toHaveClass(cellClassname, { exact: true }); + await expect.element(cell3).toHaveClass(cellClassname, { exact: true }); + await expect.element(cell4).toHaveClass(cellClassname, cellFrozenEndClassname, { exact: true }); + await expect.element(cell5).toHaveClass(cellClassname, cellFrozenEndClassname, { exact: true }); +}); + +test('frozen: true is normalized to start-frozen (backwards compat)', async () => { + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1', + frozen: true + }, + { + key: 'col2', + name: 'col2' + }, + { + key: 'col3', + name: 'col3', + frozen: 'end' + } + ]; + + await setup({ columns, rows: [] }); + + const [cell1, cell2, cell3] = headerCells.all(); + + await expect.element(cell1).toHaveTextContent('col1'); + await expect.element(cell2).toHaveTextContent('col2'); + await expect.element(cell3).toHaveTextContent('col3'); + + await expect.element(cell1).toHaveClass(cellClassname, cellFrozenClassname, { exact: true }); + await expect.element(cell2).toHaveClass(cellClassname, { exact: true }); + await expect.element(cell3).toHaveClass(cellClassname, cellFrozenEndClassname, { exact: true }); +}); + +test('end-frozen cells expose insetInlineEnd and --rdg-frozen-end CSS var', async () => { + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1' + }, + { + key: 'col2', + name: 'col2', + frozen: 'end', + width: 80 + } + ]; + + await setup({ columns, rows: [] }); + + const grid = page.getGrid().element(); + const rootStyle = getComputedStyle(grid); + // Last end-frozen column's var = 0 (flush with inline-end edge) + expect(rootStyle.getPropertyValue('--rdg-frozen-end-1').trim()).toBe('0px'); + + const endCell = headerCells.nth(1).element(); + const cellStyle = getComputedStyle(endCell); + expect(cellStyle.position).toBe('sticky'); + // insetInlineEnd should resolve to 0 (the value of --rdg-frozen-end-1) + expect(cellStyle.insetInlineEnd).toBe('0px'); +}); + +test('multiple end-frozen columns stack via decreasing --rdg-frozen-end CSS vars', async () => { + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1' + }, + { + key: 'col2', + name: 'col2', + frozen: 'end', + width: 80 + }, + { + key: 'col3', + name: 'col3', + frozen: 'end', + width: 60 + } + ]; + + await setup({ columns, rows: [] }); + + const grid = page.getGrid().element(); + const rootStyle = getComputedStyle(grid); + // First-of-end-frozen (col2 after sort, idx=1): width of col3 = 60px + expect(rootStyle.getPropertyValue('--rdg-frozen-end-1').trim()).toBe('60px'); + // Last-of-end-frozen (col3, idx=2): 0 (flush) + expect(rootStyle.getPropertyValue('--rdg-frozen-end-2').trim()).toBe('0px'); +}); + +test('end-frozen cells in top summary rows carry the end-frozen class', async () => { + interface SummaryRow { + label: string; + } + + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1' + }, + { + key: 'col2', + name: 'col2', + frozen: 'end', + renderSummaryCell({ row }) { + return row.label; + } + } + ]; + + await setup({ + columns, + rows: [], + topSummaryRows: [{ label: 'total' }] + }); + + // the summary cell in the end-frozen column must carry the end-frozen class + const summaryCell = page.getCell({ name: 'total' }); + await expect.element(summaryCell).toHaveClass(cellClassname, cellFrozenEndClassname, { + exact: true + }); +}); + +test('end-frozen cells in bottom summary rows carry the end-frozen class', async () => { + interface SummaryRow { + label: string; + } + + const columns: readonly Column[] = [ + { + key: 'col1', + name: 'col1' + }, + { + key: 'col2', + name: 'col2', + frozen: 'end', + renderSummaryCell({ row }) { + return row.label; + } + } + ]; + + await setup({ + columns, + rows: [], + bottomSummaryRows: [{ label: 'bottom-total' }] + }); + + const summaryCell = page.getCell({ name: 'bottom-total' }); + await expect.element(summaryCell).toHaveClass(cellClassname, cellFrozenEndClassname, { + exact: true + }); +}); + +test('reordering input columns past end-frozen preserves band integrity', async () => { + // User provides columns in an arbitrary order (e.g. end-frozen in the middle); + // the grid must still sort end-frozen columns to the tail. + const columns: readonly Column[] = [ + { key: 'end1', name: 'end1', frozen: 'end' }, + { key: 'mid', name: 'mid' }, + { key: 'start1', name: 'start1', frozen: 'start' } + ]; + + await setup({ columns, rows: [] }); + + const [cell1, cell2, cell3] = headerCells.all(); + await expect.element(cell1).toHaveTextContent('start1'); + await expect.element(cell2).toHaveTextContent('mid'); + await expect.element(cell3).toHaveTextContent('end1'); +}); + +test('keyboard navigation reaches end-frozen columns', async () => { + const columns: readonly Column[] = [ + { key: 's', name: 's', frozen: 'start' }, + { key: 'u', name: 'u' }, + { key: 'e', name: 'e', frozen: 'end' } + ]; + const rows: readonly unknown[] = Array.from({ length: 1 }); + + await setup({ columns, rows }); + + await safeTab(); + // Ctrl+End jumps to the last cell; the end-frozen column sits at aria-colindex=3 + await userEvent.keyboard('{Control>}{End}{/Control}'); + await expect.element(page.getActiveCell()).toHaveAttribute('aria-colindex', '3'); +}); + +test('colSpan from an unfrozen column into the end-frozen band is dropped', async () => { + const columns: readonly Column[] = [ + { key: 'a', name: 'a' }, + { + key: 'b', + name: 'b', + colSpan(args) { + // b is unfrozen — span 2 would cross into end-frozen column c + if (args.type === 'ROW') return 2; + return undefined; + } + }, + { key: 'c', name: 'c', frozen: 'end' } + ]; + + await setup({ columns, rows: [1] }); + + // colSpan is ignored (boundary crossed) → all three cells render independently + await expect.element(getCellsAtRowIndex(0)).toHaveLength(3); +}); + +test('colSpan contained within the end-frozen band is respected', async () => { + const columns: readonly Column[] = [ + { key: 'a', name: 'a' }, + { + key: 'e1', + name: 'e1', + frozen: 'end', + colSpan(args) { + // e1 and e2 are both end-frozen — span stays within the tail band + if (args.type === 'ROW') return 2; + return undefined; + } + }, + { key: 'e2', name: 'e2', frozen: 'end' } + ]; + + await setup({ columns, rows: [1] }); + + // colSpan=2 absorbs e2 → only 2 cells render in the body row (a + e1-spanning) + await expect.element(getCellsAtRowIndex(0)).toHaveLength(2); +}); + +test('drag handle on an editable end-frozen column is anchored via inset-inline-end', async () => { + interface Row { + col: string; + } + + const columns: readonly Column[] = [ + { key: 'pad', name: 'pad', width: 2000 }, + { + key: 'col', + name: 'col', + frozen: 'end', + editable: true, + renderEditCell: () => null + } + ]; + + await setup({ + columns, + rows: [{ col: 'a1' }, { col: 'a2' }] satisfies Row[], + onFill({ targetRow, sourceRow, columnKey }) { + return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; + }, + onRowsChange() {} + }); + + // click the end-frozen cell in row 0 to activate it; drag handle should render + await userEvent.click(getCellsAtRowIndex(0).nth(1)); + await expect.element(dragHandle).toBeInTheDocument(); + + // drag handle must use inset-inline-end so it stays anchored to the end-frozen cell on scroll + const computed = getComputedStyle(dragHandle.element()); + expect(computed.position).toBe('sticky'); + expect(computed.insetInlineEnd).not.toBe('auto'); +}); + +test('no unfrozen columns — one start + one end', async () => { + const columns: readonly Column[] = [ + { + key: 'a', + name: 'a', + frozen: 'start' + }, + { + key: 'b', + name: 'b', + frozen: 'end' + } + ]; + + await setup({ columns, rows: [] }); + + const [cellA, cellB] = headerCells.all(); + + await expect.element(cellA).toHaveTextContent('a'); + await expect.element(cellB).toHaveTextContent('b'); + await expect.element(cellA).toHaveClass(cellClassname, cellFrozenClassname, { exact: true }); + await expect.element(cellB).toHaveClass(cellClassname, cellFrozenEndClassname, { exact: true }); +}); diff --git a/test/browser/direction.test.ts b/test/browser/direction.test.ts index be9c61f55e..d3ced541d5 100644 --- a/test/browser/direction.test.ts +++ b/test/browser/direction.test.ts @@ -50,3 +50,32 @@ test('should use right to left direction if direction prop is set to rtl', async await userEvent.keyboard('{ArrowLeft}'); await expect.element(activeCell).toHaveTextContent('Name'); }); + +test('start and end frozen columns use logical insets under RTL', async () => { + interface RtlRow { + id: number; + name: string; + trailing: string; + } + + const rtlColumns: readonly Column[] = [ + { key: 'id', name: 'ID', frozen: 'start', width: 60 }, + { key: 'name', name: 'Name', width: 100 }, + { key: 'trailing', name: 'Trailing', frozen: 'end', width: 80 } + ]; + const rtlRows: readonly RtlRow[] = []; + + await setup({ rows: rtlRows, columns: rtlColumns, direction: 'rtl' }); + + await expect.element(grid).toHaveAttribute('dir', 'rtl'); + + const headerById = page.getHeaderCell({ name: 'ID' }).element(); + const headerByTrailing = page.getHeaderCell({ name: 'Trailing' }).element(); + + // Logical properties: both cells use logical insets which the browser flips physically in RTL + expect(getComputedStyle(headerById).position).toBe('sticky'); + expect(getComputedStyle(headerById).insetInlineStart).toBe('0px'); + + expect(getComputedStyle(headerByTrailing).position).toBe('sticky'); + expect(getComputedStyle(headerByTrailing).insetInlineEnd).toBe('0px'); +}); diff --git a/test/browser/virtualization.test.ts b/test/browser/virtualization.test.ts index 01fa277812..601497d3b0 100644 --- a/test/browser/virtualization.test.ts +++ b/test/browser/virtualization.test.ts @@ -189,6 +189,72 @@ test('virtualization is enabled with all columns frozen', async () => { await assertCellIndexes(0, indexes); }); +test('virtualization is enabled with end-frozen columns', async () => { + // 30 columns total, cols 28 and 29 are end-frozen — always in DOM regardless of scroll + const columns: Column[] = []; + const rows = Array.from({ length: 30 }); + + for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + width: 100 + ((i * 10) % 50), + frozen: i >= 28 ? 'end' : false + }); + } + + await setup({ columns, rows, rowHeight }); + + // At scroll 0: first visible cells + end-frozen tail rendered; middle cells virtualized away. + scrollGrid({ left: 0 }); + await expect.element(page.getHeaderCell({ name: '0' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '28' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '29' })).toBeInTheDocument(); + // a far-right middle column is NOT in DOM at scroll 0 + await expect.element(page.getHeaderCell({ name: '25' })).not.toBeInTheDocument(); + + // At max scroll: far-left unfrozen column virtualized away; end-frozen still rendered + scrollGrid({ left: 3600 - 1920 }); + await expect.element(page.getHeaderCell({ name: '0' })).not.toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '28' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '29' })).toBeInTheDocument(); +}); + +test('virtualization is enabled with start + end frozen columns', async () => { + // 30 columns total, cols 0-2 start-frozen, cols 28-29 end-frozen + const columns: Column[] = []; + const rows = Array.from({ length: 30 }); + + for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + width: 100 + ((i * 10) % 50), + frozen: i < 3 ? 'start' : i >= 28 ? 'end' : false + }); + } + + await setup({ columns, rows, rowHeight }); + + // At scroll 0: start (0,1,2) + early unfrozen + end (28,29) rendered; middle virtualized away. + scrollGrid({ left: 0 }); + await expect.element(page.getHeaderCell({ name: '0' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '2' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '28' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '29' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '25' })).not.toBeInTheDocument(); + + // At max scroll: BOTH start and end bands still rendered; an early unfrozen column (3) virtualized away. + scrollGrid({ left: 3600 - 1920 }); + await expect.element(page.getHeaderCell({ name: '0' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '2' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '3' })).not.toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '28' })).toBeInTheDocument(); + await expect.element(page.getHeaderCell({ name: '29' })).toBeInTheDocument(); +}); + test('virtualization is enabled with 2 summary rows', async () => { await setupGrid(true, 1, 100, 0, 2); diff --git a/website/routes/CommonFeatures.tsx b/website/routes/CommonFeatures.tsx index 071ae0a654..17013d0991 100644 --- a/website/routes/CommonFeatures.tsx +++ b/website/routes/CommonFeatures.tsx @@ -96,7 +96,7 @@ function getColumns( { key: 'title', name: 'Task', - frozen: true, + frozen: 'start', renderEditCell: renderTextEditor, renderSummaryCell({ row }) { return `${row.totalCount} records`; @@ -225,6 +225,7 @@ function getColumns( { key: 'available', name: 'Available', + frozen: 'end', renderCell({ row, onRowChange, tabIndex }) { return ( Date: Fri, 24 Apr 2026 22:48:02 +0200 Subject: [PATCH 2/2] Address review feedback: ColumnFrozen type + CSS dedup --- src/DataGrid.tsx | 8 ++--- src/GroupRow.tsx | 8 ++--- src/HeaderRow.tsx | 4 +-- src/hooks/useCalculatedColumns.ts | 10 ++++-- src/index.ts | 1 + src/style/cell.ts | 14 +++----- src/style/core.ts | 46 ++++++++++++--------------- src/style/row.ts | 14 ++++---- src/types.ts | 5 +-- src/utils/index.ts | 9 ++++-- test/browser/column/frozenEnd.test.ts | 37 +++++++++++++++++++++ 11 files changed, 97 insertions(+), 59 deletions(-) diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index a3f1d875f8..c499da0ace 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -74,11 +74,11 @@ import { default as defaultRenderSortStatus } from './sortStatus'; import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell'; import { rootClassname, - frozenColumnShadowClassname, frozenColumnShadowEndClassname, - viewportDraggingClassname, + frozenColumnShadowEndTopClassname, + frozenColumnShadowStartClassname, frozenColumnShadowTopClassname, - frozenColumnShadowEndTopClassname + viewportDraggingClassname } from './style/core'; import SummaryRow from './SummaryRow'; @@ -1292,7 +1292,7 @@ export function DataGrid(props: DataGridPr {lastFrozenColumnIndex > -1 && renderFrozenShadow( frozenShadowStyles, - frozenColumnShadowClassname, + frozenColumnShadowStartClassname, frozenColumnShadowTopClassname )} diff --git a/src/GroupRow.tsx b/src/GroupRow.tsx index f7738f3a64..ee0c6d99f9 100644 --- a/src/GroupRow.tsx +++ b/src/GroupRow.tsx @@ -6,7 +6,7 @@ import { classnames } from './utils'; import type { BaseRenderRowProps, GroupRow, Omit } from './types'; import { SELECT_COLUMN_KEY } from './Columns'; import GroupCell from './GroupCell'; -import { cell, cellFrozen, cellFrozenEnd } from './style/cell'; +import { cell, cellFrozen } from './style/cell'; import { rowClassname, rowActiveClassname } from './style/row'; const groupRow = css` @@ -15,9 +15,9 @@ const groupRow = css` background-color: var(--rdg-header-background-color); } - > .${cell}:not(:last-child, .${cellFrozen}, .${cellFrozenEnd}), - > :nth-last-child(n + 2 of .${cellFrozen}), - > :nth-child(n + 2 of .${cellFrozenEnd}) { + > .${cell}:not(:last-child, .${cellFrozen}), + > :nth-last-child(n + 2 of .rdg-cell-frozen), + > :nth-child(n + 2 of .rdg-cell-frozen-end) { border-inline-end: none; } } diff --git a/src/HeaderRow.tsx b/src/HeaderRow.tsx index 3599efe640..8c0f140706 100644 --- a/src/HeaderRow.tsx +++ b/src/HeaderRow.tsx @@ -12,7 +12,7 @@ import type { } from './types'; import type { DataGridProps } from './DataGrid'; import HeaderCell from './HeaderCell'; -import { cell, cellFrozen, cellFrozenEnd } from './style/cell'; +import { cell, cellFrozen } from './style/cell'; import { rowActiveClassname } from './style/row'; type SharedDataGridProps = Pick< @@ -44,7 +44,7 @@ const headerRow = css` position: sticky; } - & > .${cellFrozen}, & > .${cellFrozenEnd} { + & > .${cellFrozen} { z-index: 3; } } diff --git a/src/hooks/useCalculatedColumns.ts b/src/hooks/useCalculatedColumns.ts index adcc487313..b409548880 100644 --- a/src/hooks/useCalculatedColumns.ts +++ b/src/hooks/useCalculatedColumns.ts @@ -1,7 +1,13 @@ import { useMemo } from 'react'; import { clampColumnWidth, isStartFrozen, max, min } from '../utils'; -import type { CalculatedColumn, CalculatedColumnParent, ColumnOrColumnGroup, Omit } from '../types'; +import type { + CalculatedColumn, + CalculatedColumnParent, + ColumnFrozen, + ColumnOrColumnGroup, + Omit +} from '../types'; import { renderValue } from '../cellRenderers'; import { SELECT_COLUMN_KEY } from '../Columns'; import type { DataGridProps } from '../DataGrid'; @@ -94,7 +100,7 @@ export function useCalculatedColumns({ continue; } - const frozen: boolean | 'start' | 'end' = rawColumn.frozen ?? false; + const frozen: ColumnFrozen = rawColumn.frozen ?? false; const column: MutableCalculatedColumn = { ...rawColumn, diff --git a/src/index.ts b/src/index.ts index d965cdf85f..e6df9a49d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export type { CellRendererProps, ColSpanArgs, Column, + ColumnFrozen, ColumnGroup, ColumnOrColumnGroup, ColumnWidth, diff --git a/src/style/cell.ts b/src/style/cell.ts index 9976702b70..5d8b100c6c 100644 --- a/src/style/cell.ts +++ b/src/style/cell.ts @@ -30,6 +30,9 @@ export const cell = css` export const cellClassname = `rdg-cell ${cell}`; +// Single shared sticky/z-index rule reused by both edge-frozen variants. +// Selectors that need to distinguish start vs end use the marker classes +// (`.rdg-cell-frozen` / `.rdg-cell-frozen-end`) directly rather than this ecij identifier. export const cellFrozen = css` @layer rdg.Cell { position: sticky; @@ -39,16 +42,7 @@ export const cellFrozen = css` `; export const cellFrozenClassname = `rdg-cell-frozen ${cellFrozen}`; - -export const cellFrozenEnd = css` - @layer rdg.Cell { - position: sticky; - /* Should have a higher value than 0 to show up above unfrozen cells */ - z-index: 1; - } -`; - -export const cellFrozenEndClassname = `rdg-cell-frozen-end ${cellFrozenEnd}`; +export const cellFrozenEndClassname = `rdg-cell-frozen-end ${cellFrozen}`; const cellDragHandle = css` @layer rdg.DragHandle { diff --git a/src/style/core.ts b/src/style/core.ts index b9d46cf232..72164723ec 100644 --- a/src/style/core.ts +++ b/src/style/core.ts @@ -92,61 +92,57 @@ const viewportDragging = css` export const viewportDraggingClassname = `rdg-viewport-dragging ${viewportDragging}`; -// Add shadow after the last frozen cell +// Common properties shared by both start- and end-edge frozen-column shadows. +// Variants below add only the direction-dependent properties (gradient + scroll-state predicate). export const frozenColumnShadowClassname = css` position: sticky; width: 10px; + pointer-events: none; + z-index: 1; + opacity: 1; + transition: opacity 0.1s; + + &:dir(rtl) { + transform: scaleX(-1); + } +`; + +const frozenColumnShadowStartOverrides = css` background-image: linear-gradient( to right, light-dark(rgb(0 0 0 / 15%), rgb(0 0 0 / 40%)), transparent ); - pointer-events: none; - z-index: 1; - - opacity: 1; - transition: opacity 0.1s; /* TODO: reverse 'opacity' and remove 'not' */ @container rdg-root not scroll-state(scrollable: inline-start) { opacity: 0; } - - &:dir(rtl) { - transform: scaleX(-1); - } `; -// Add shadow before the first end-frozen cell (mirror of the start shadow) -export const frozenColumnShadowEndClassname = css` - position: sticky; - width: 10px; +const frozenColumnShadowEndOverrides = css` background-image: linear-gradient( to left, light-dark(rgb(0 0 0 / 15%), rgb(0 0 0 / 40%)), transparent ); - pointer-events: none; - z-index: 1; - - opacity: 1; - transition: opacity 0.1s; /* TODO: reverse 'opacity' and remove 'not' */ @container rdg-root not scroll-state(scrollable: inline-end) { opacity: 0; } - - &:dir(rtl) { - transform: scaleX(-1); - } `; +// Add shadow after the last start-frozen cell +export const frozenColumnShadowStartClassname = `${frozenColumnShadowClassname} ${frozenColumnShadowStartOverrides}`; + +// Add shadow before the first end-frozen cell (mirror of the start shadow) +export const frozenColumnShadowEndClassname = `${frozenColumnShadowClassname} ${frozenColumnShadowEndOverrides}`; + const topShadowClassname = css` /* render above header and summary rows */ z-index: 2; `; -export const frozenColumnShadowTopClassname = `${frozenColumnShadowClassname} ${topShadowClassname}`; - +export const frozenColumnShadowTopClassname = `${frozenColumnShadowStartClassname} ${topShadowClassname}`; export const frozenColumnShadowEndTopClassname = `${frozenColumnShadowEndClassname} ${topShadowClassname}`; diff --git a/src/style/row.ts b/src/style/row.ts index 35695988e6..dec9bb4616 100644 --- a/src/style/row.ts +++ b/src/style/row.ts @@ -1,7 +1,5 @@ import { css } from 'ecij'; -import { cellFrozen, cellFrozenEnd } from './cell'; - export const row = css` @layer rdg.Row { display: grid; @@ -27,20 +25,20 @@ export const row = css` border: var(--rdg-selection-width) solid var(--rdg-selection-color); } - & > .${cellFrozen}:first-child::before { + & > .rdg-cell-frozen:first-child::before, + & > .rdg-cell-frozen-end:last-child::after { content: ''; display: inline-block; position: absolute; inset-block: 0; + } + + & > .rdg-cell-frozen:first-child::before { inset-inline-start: 0; border-inline-start: var(--rdg-selection-width) solid var(--rdg-selection-color); } - & > .${cellFrozenEnd}:last-child::after { - content: ''; - display: inline-block; - position: absolute; - inset-block: 0; + & > .rdg-cell-frozen-end:last-child::after { inset-inline-end: 0; border-inline-end: var(--rdg-selection-width) solid var(--rdg-selection-color); } diff --git a/src/types.ts b/src/types.ts index 2c4593de29..fed440f9f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,7 @@ export interface Column { readonly colSpan?: Maybe<(args: ColSpanArgs) => Maybe>; /** Determines whether column is frozen, and on which edge. * `true` is an alias for `'start'` for backwards compatibility. */ - readonly frozen?: Maybe; + readonly frozen?: Maybe; /** Enable resizing of the column */ readonly resizable?: Maybe; /** Enable sorting of the column */ @@ -89,7 +89,7 @@ export interface CalculatedColumn extends Column) => ReactNode; readonly renderHeaderCell: (props: RenderHeaderCellProps) => ReactNode; } @@ -332,6 +332,7 @@ export interface SortColumn { } export type CellNavigationMode = 'NONE' | 'CHANGE_ROW'; +export type ColumnFrozen = boolean | 'start' | 'end'; export type SortDirection = 'ASC' | 'DESC'; export type ColSpanArgs = diff --git a/src/utils/index.ts b/src/utils/index.ts index 9dee7455d0..9451c383f8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,9 @@ -import type { CalculatedColumn, CalculatedColumnOrColumnGroup, Maybe } from '../types'; +import type { + CalculatedColumn, + CalculatedColumnOrColumnGroup, + ColumnFrozen, + Maybe +} from '../types'; export * from './activePositionUtils'; export * from './colSpanUtils'; @@ -40,6 +45,6 @@ export function getHeaderCellRowSpan( } // Shared predicate — `frozen: true` is the backwards-compatible alias for `frozen: 'start'`. -export function isStartFrozen(frozen: boolean | 'start' | 'end'): boolean { +export function isStartFrozen(frozen: ColumnFrozen): boolean { return frozen === true || frozen === 'start'; } diff --git a/test/browser/column/frozenEnd.test.ts b/test/browser/column/frozenEnd.test.ts index f27ad2c8d3..5c24491e08 100644 --- a/test/browser/column/frozenEnd.test.ts +++ b/test/browser/column/frozenEnd.test.ts @@ -344,3 +344,40 @@ test('no unfrozen columns — one start + one end', async () => { await expect.element(cellA).toHaveClass(cellClassname, cellFrozenClassname, { exact: true }); await expect.element(cellB).toHaveClass(cellClassname, cellFrozenEndClassname, { exact: true }); }); + +test('selection outline paints on edge-frozen cells when row is active', async () => { + interface R { + id: number; + name: string; + trailing: string; + } + const columns: readonly Column[] = [ + { key: 'id', name: 'ID', frozen: 'start', width: 60 }, + { key: 'name', name: 'Name', width: 100 }, + { key: 'trailing', name: 'Trail', frozen: 'end', width: 80 } + ]; + const rows: readonly R[] = [{ id: 1, name: 'one', trailing: 't1' }]; + + await setup({ columns, rows }); + + // Trigger the `&[tabindex='0']` rule by setting the attribute directly + const firstRow = page.getRow().nth(0).element() as HTMLElement; + firstRow.setAttribute('tabindex', '0'); + + const cells = firstRow.children; + const startCell = cells[0] as HTMLElement; + const endCell = cells[cells.length - 1] as HTMLElement; + + const beforeStyle = getComputedStyle(startCell, '::before'); + const afterStyle = getComputedStyle(endCell, '::after'); + + // Shared block (combined selector): both pseudo-elements get the same base properties. + expect(beforeStyle.content).toBe('""'); + expect(beforeStyle.position).toBe('absolute'); + expect(afterStyle.content).toBe('""'); + expect(afterStyle.position).toBe('absolute'); + + // Direction-specific (split selectors): start gets inline-start border, end gets inline-end. + expect(beforeStyle.borderInlineStartWidth).not.toBe('0px'); + expect(afterStyle.borderInlineEndWidth).not.toBe('0px'); +});