Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ website/ # demo site (Vite + TanStack Router)
- **TypeScript strict** with `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `erasableSyntaxOnly`. Distinguish missing properties from `undefined` values.
- **`Maybe<T>`** (`T | undefined | null`) — used for all nullable column/render props. Do not use bare `T | undefined`.
- **`NoInfer<>`** — wrap callback parameters to prevent reverse type inference into component generics.
- **Stable object shapes** — objects read in hot rendering paths, like calculated columns, must share one hidden class so property reads stay monomorphic: create them with every property in the same order, `undefined` when unset, and do not spread user-provided objects into them.
- **CSS layers** — all styles live in nested `@layer rdg.<Name>` sub-layers (e.g., `rdg.Cell`, `rdg.Row`). When a sub-layer overrides another sub-layer's styles, declare both in `src/style/layers.css`, which sets their order; undeclared sub-layers come after the declared ones. Use `ecij` `css` tagged templates (build-time extraction, not runtime CSS-in-JS). Co-locate styles in component files; `src/style/` is for shared styles.
- **Dual class names** — components apply both a semantic class (`rdg-cell`) and a generated hash. Preserve both.
- **Light/dark mode** — handled via CSS `light-dark()` + `color-scheme`, not JS.
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1464,7 +1464,7 @@ Union type representing either a `Column` or a `ColumnGroup`.

#### `CalculatedColumn<TRow, TSummaryRow>`

Extends `Column` with additional computed properties used internally by the grid. This is the type passed to render functions. The `Column` properties it redeclares, like `width` and `renderCell`, are resolved to their default values when not set.
Extends `Column` with additional computed properties used internally by the grid. This is the type passed to render functions. Every `Column` property is set: the ones with a default value, like `width` and `renderCell`, are resolved to their default value when not set, and the others are `undefined` when not set.

```tsx
interface CalculatedColumn<TRow, TSummaryRow = unknown> extends Column<TRow, TSummaryRow> {
Expand All @@ -1483,6 +1483,17 @@ interface CalculatedColumn<TRow, TSummaryRow = unknown> extends Column<TRow, TSu
readonly frozen: ColumnFrozen;
readonly renderCell: (props: RenderCellContentProps<TRow, TSummaryRow>) => ReactNode;
readonly renderHeaderCell: (props: RenderHeaderCellContentProps<TRow, TSummaryRow>) => ReactNode;
// Always set, `undefined` when not set on the column
readonly cellClass: Column<TRow, TSummaryRow>['cellClass'];
readonly headerCellClass: Column<TRow, TSummaryRow>['headerCellClass'];
readonly summaryCellClass: Column<TRow, TSummaryRow>['summaryCellClass'];
readonly renderSummaryCell: Column<TRow, TSummaryRow>['renderSummaryCell'];
readonly renderGroupCell: Column<TRow, TSummaryRow>['renderGroupCell'];
readonly renderEditCell: Column<TRow, TSummaryRow>['renderEditCell'];
readonly editable: Column<TRow, TSummaryRow>['editable'];
readonly colSpan: Column<TRow, TSummaryRow>['colSpan'];
readonly sortDescendingFirst: Column<TRow, TSummaryRow>['sortDescendingFirst'];
readonly editorOptions: Column<TRow, TSummaryRow>['editorOptions'];
}
```

Expand Down
2 changes: 1 addition & 1 deletion src/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function Cell<R, SR>({
const isEditable = isCellEditableUtil(column, row);

function setActivePositionWrapper(enableEditor = false) {
setActivePosition({ rowIdx, idx: column.idx }, { enableEditor });
setActivePosition({ idx: column.idx, rowIdx }, { enableEditor });
}

function handleMouseEvent(
Expand Down
19 changes: 11 additions & 8 deletions src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
const updatedRows = [...rows];
const indexes: number[] = [];
for (let i = startRowIdx; i < endRowIdx; i++) {
if (isCellEditable({ rowIdx: i, idx })) {
if (isCellEditable({ idx, rowIdx: i })) {
const updatedRow = onFill!({ columnKey: column.key, sourceRow, targetRow: rows[i] });
if (updatedRow !== rows[i]) {
updatedRows[i] = updatedRow;
Expand All @@ -802,16 +802,19 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
if (!isPositionInActiveBounds) return;
commitEditorChanges();

// Copy the coordinates instead of spreading `position`,
// so the shape of the active position doesn't depend on the object passed in
const { idx, rowIdx } = position;
const samePosition = isSamePosition(activePosition, position);

if (options?.enableEditor && isCellEditable(position)) {
const row = rows[position.rowIdx];
setActivePosition({ ...position, mode: 'EDIT', row, originalRow: row });
const row = rows[rowIdx];
setActivePosition({ idx, rowIdx, mode: 'EDIT', row, originalRow: row });
} else if (samePosition) {
// Avoid re-renders if the active position is the same
scrollIntoView(getCellToScroll(gridRef.current!));
} else {
const newPosition: ActivePosition = { ...position, mode: 'ACTIVE' };
const newPosition: ActivePosition = { idx, rowIdx, mode: 'ACTIVE' };
setActivePosition(newPosition);
if (options?.shouldFocus) {
setPositionToFocus(newPosition);
Expand All @@ -820,15 +823,15 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr

if (onActivePositionChange && !samePosition) {
onActivePositionChange({
rowIdx: position.rowIdx,
row: rows[position.rowIdx],
column: columns[position.idx]
rowIdx,
row: rows[rowIdx],
column: columns[idx]
});
}
}

function selectHeaderCell({ idx, rowIdx }: Position): void {
setPosition({ rowIdx: minRowIdx + rowIdx - 1, idx });
setPosition({ idx, rowIdx: minRowIdx + rowIdx - 1 });
}

function getNextPosition(key: string, ctrlKey: boolean, shiftKey: boolean): Position {
Expand Down
2 changes: 1 addition & 1 deletion src/GroupedRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function GroupedRow<R, SR>({
let idx = row.level;

function handleSelectGroup() {
setActivePosition({ rowIdx, idx: -1 }, { shouldFocus: true });
setActivePosition({ idx: -1, rowIdx }, { shouldFocus: true });
}

const selectionValue = useMemo(
Expand Down
2 changes: 1 addition & 1 deletion src/SummaryCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function SummaryCell<R, SR>({
);

function onMouseDown() {
setActivePosition({ rowIdx, idx: column.idx });
setActivePosition({ idx: column.idx, rowIdx });
}

return (
Expand Down
24 changes: 19 additions & 5 deletions src/hooks/useCalculatedColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,34 @@ export function useCalculatedColumns<R, SR>({

const frozen: ColumnFrozen = rawColumn.frozen ?? false;

// Set every property in a fixed order instead of spreading `rawColumn`,
// so that all calculated columns share a single hidden class (shape) in JS engines,
// and the column property reads that run for every rendered cell stay monomorphic.
const column: MutableCalculatedColumn<R, SR> = {
...rawColumn,
name: rawColumn.name,
key: rawColumn.key,
parent,
idx: 0,
level: 0,
frozen,
width: rawColumn.width ?? defaultWidth,
minWidth: rawColumn.minWidth ?? defaultMinWidth,
maxWidth: rawColumn.maxWidth ?? defaultMaxWidth,
sortable: rawColumn.sortable ?? defaultSortable,
cellClass: rawColumn.cellClass,
headerCellClass: rawColumn.headerCellClass,
summaryCellClass: rawColumn.summaryCellClass,
renderCell: rawColumn.renderCell ?? defaultRenderCell,
renderHeaderCell: rawColumn.renderHeaderCell ?? defaultRenderHeaderCell,
renderSummaryCell: rawColumn.renderSummaryCell,
renderGroupCell: rawColumn.renderGroupCell,
renderEditCell: rawColumn.renderEditCell,
editable: rawColumn.editable,
colSpan: rawColumn.colSpan,
frozen,
resizable: rawColumn.resizable ?? defaultResizable,
sortable: rawColumn.sortable ?? defaultSortable,
draggable: rawColumn.draggable ?? defaultDraggable,
renderCell: rawColumn.renderCell ?? defaultRenderCell,
renderHeaderCell: rawColumn.renderHeaderCell ?? defaultRenderHeaderCell
sortDescendingFirst: rawColumn.sortDescendingFirst,
editorOptions: rawColumn.editorOptions
};

columns.push(column);
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ export interface CalculatedColumn<TRow, TSummaryRow = unknown> extends Column<TR
readonly frozen: ColumnFrozen;
readonly renderCell: (props: RenderCellContentProps<TRow, TSummaryRow>) => ReactNode;
readonly renderHeaderCell: (props: RenderHeaderCellContentProps<TRow, TSummaryRow>) => ReactNode;
// Always set, `undefined` when not set on the column
readonly cellClass: Column<TRow, TSummaryRow>['cellClass'];
readonly headerCellClass: Column<TRow, TSummaryRow>['headerCellClass'];
readonly summaryCellClass: Column<TRow, TSummaryRow>['summaryCellClass'];
readonly renderSummaryCell: Column<TRow, TSummaryRow>['renderSummaryCell'];
readonly renderGroupCell: Column<TRow, TSummaryRow>['renderGroupCell'];
readonly renderEditCell: Column<TRow, TSummaryRow>['renderEditCell'];
readonly editable: Column<TRow, TSummaryRow>['editable'];
readonly colSpan: Column<TRow, TSummaryRow>['colSpan'];
readonly sortDescendingFirst: Column<TRow, TSummaryRow>['sortDescendingFirst'];
readonly editorOptions: Column<TRow, TSummaryRow>['editorOptions'];
}

export interface ColumnGroup<R, SR = unknown> {
Expand Down
26 changes: 25 additions & 1 deletion test/browser/column/renderCell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react';
import { page, userEvent } from 'vite-plus/test/browser';

import { DataGrid, renderHeaderCell } from '../../../src';
import type { Column } from '../../../src';
import type { CalculatedColumn, Column, RenderCellContentProps } from '../../../src';
import { getCellsAtRowIndex, safeTab, setup } from '../utils';

const cells = page.getCell();
Expand Down Expand Up @@ -112,6 +112,30 @@ describe('Custom cell content renderer', () => {
indexes: [0]
});
});

it('should receive columns that all have the same properties in the same order', async () => {
const calculatedColumns = new Map<string, CalculatedColumn<Row>>();

function renderCell({ column }: RenderCellContentProps<Row>) {
calculatedColumns.set(column.key, column);
return null;
}

await setup({
columns: [
{ key: 'id', name: 'ID', renderCell },
{ name: 'Name', key: 'name', width: 100, cellClass: 'name', renderCell },
{ key: 'other', name: 'Other', frozen: true, editable: false, colSpan: () => 1, renderCell }
],
rows: [{ id: 1 }]
});

await expect.poll(() => calculatedColumns.size).toBe(3);
const [idKeys, ...otherKeys] = calculatedColumns.values().map(Object.keys).toArray();
for (const keys of otherKeys) {
expect(keys).toStrictEqual(idKeys);
}
});
});

test('Focus child if it sets tabIndex', async () => {
Expand Down
Loading