From 286f14a0f68439c2cbfb425f0d6c2dce6a1451d3 Mon Sep 17 00:00:00 2001 From: Aman Mahajan Date: Wed, 9 Oct 2024 16:29:16 -0500 Subject: [PATCH 1/7] Implement download without `react-dom/server` (#3613) * Implement download without `react-dom/server'` * Use `flushSync` * Remove `ExportButton` component * Inline grid element * Address comments * Address comments --- website/exportUtils.tsx | 35 ++------- website/routes/CommonFeatures.lazy.tsx | 102 +++++++++++++------------ 2 files changed, 60 insertions(+), 77 deletions(-) diff --git a/website/exportUtils.tsx b/website/exportUtils.tsx index 9628c4f185..072eca749d 100644 --- a/website/exportUtils.tsx +++ b/website/exportUtils.tsx @@ -1,13 +1,5 @@ -import { cloneElement } from 'react'; -import type { ReactElement } from 'react'; - -import type { DataGridProps } from '../src'; - -export async function exportToCsv( - gridElement: ReactElement>, - fileName: string -) { - const { head, body, foot } = await getGridContent(gridElement); +export function exportToCsv(gridEl: HTMLDivElement, fileName: string) { + const { head, body, foot } = getGridContent(gridEl); const content = [...head, ...body, ...foot] .map((cells) => cells.map(serialiseCellValue).join(',')) .join('\n'); @@ -15,14 +7,11 @@ export async function exportToCsv( downloadFile(fileName, new Blob([content], { type: 'text/csv;charset=utf-8;' })); } -export async function exportToPdf( - gridElement: ReactElement>, - fileName: string -) { - const [{ jsPDF }, autoTable, { head, body, foot }] = await Promise.all([ +export async function exportToPdf(gridEl: HTMLDivElement, fileName: string) { + const { head, body, foot } = getGridContent(gridEl); + const [{ jsPDF }, { default: autoTable }] = await Promise.all([ import('jspdf'), - (await import('jspdf-autotable')).default, - await getGridContent(gridElement) + import('jspdf-autotable') ]); const doc = new jsPDF({ orientation: 'l', @@ -40,15 +29,7 @@ export async function exportToPdf( doc.save(fileName); } -async function getGridContent(gridElement: ReactElement>) { - const { renderToStaticMarkup } = await import('react-dom/server'); - const grid = document.createElement('div'); - grid.innerHTML = renderToStaticMarkup( - cloneElement(gridElement, { - enableVirtualization: false - }) - ); - +function getGridContent(gridEl: HTMLDivElement) { return { head: getRows('.rdg-header-row'), body: getRows('.rdg-row:not(.rdg-summary-row)'), @@ -56,7 +37,7 @@ async function getGridContent(gridElement: ReactElement(selector)).map((gridRow) => { + return Array.from(gridEl.querySelectorAll(selector)).map((gridRow) => { return Array.from(gridRow.querySelectorAll('.rdg-cell')).map( (gridCell) => gridCell.innerText ); diff --git a/website/routes/CommonFeatures.lazy.tsx b/website/routes/CommonFeatures.lazy.tsx index 53c9dbbd24..a019eeacf5 100644 --- a/website/routes/CommonFeatures.lazy.tsx +++ b/website/routes/CommonFeatures.lazy.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; +import { useMemo, useRef, useState } from 'react'; +import { createPortal, flushSync } from 'react-dom'; import { faker } from '@faker-js/faker'; import { createLazyFileRoute } from '@tanstack/react-router'; import { css } from '@linaria/core'; @@ -9,6 +9,7 @@ import DataGrid, { SelectColumn, textEditor, type Column, + type DataGridHandle, type SortColumn } from '../../src'; import { textEditorClassname } from '../../src/editors/textEditor'; @@ -310,6 +311,8 @@ function CommonFeatures() { const [rows, setRows] = useState(createRows); const [sortColumns, setSortColumns] = useState([]); const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); + const [isExporting, setIsExporting] = useState(false); + const gridRef = useRef(null); const countries = useMemo((): readonly string[] => { return [...new Set(rows.map((r) => r.country))].sort(new Intl.Collator().compare); @@ -342,61 +345,60 @@ function CommonFeatures() { }); }, [rows, sortColumns]); - const gridElement = ( - - ); + function handleExportToCsv() { + flushSync(() => { + setIsExporting(true); + }); + + exportToCsv(gridRef.current!.element!, 'CommonFeatures.csv'); + + flushSync(() => { + setIsExporting(false); + }); + } + + async function handleExportToPdf() { + flushSync(() => { + setIsExporting(true); + }); + + await exportToPdf(gridRef.current!.element!, 'CommonFeatures.pdf'); + + flushSync(() => { + setIsExporting(false); + }); + } return ( <>
- exportToCsv(gridElement, 'CommonFeatures.csv')}> + +
- {gridElement} + ); } - -function ExportButton({ - onExport, - children -}: { - onExport: () => Promise; - children: React.ReactNode; -}) { - const [exporting, setExporting] = useState(false); - return ( - - ); -} From 106b20c0b569c85d5d36016654b32d14dd03a962 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:12:20 +0100 Subject: [PATCH 2/7] Bump eslint-plugin-react-hooks from 4.6.2 to 5.0.0 (#3616) Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.2 to 5.0.0. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@5.0.0/packages/eslint-plugin-react-hooks) --- updated-dependencies: - dependency-name: eslint-plugin-react-hooks dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5534c7dd72..f747a54137 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "eslint": "^9.11.1", "eslint-plugin-jest-dom": "^5.0.1", "eslint-plugin-react": "^7.36.1", - "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-sonarjs": "^2.0.2", "eslint-plugin-testing-library": "^6.3.0", "jspdf": "^2.5.1", From 9c57299162f3ea2eceb48cb0a002b339a7ef34c9 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien <567105+nstepien@users.noreply.github.com> Date: Thu, 17 Oct 2024 13:01:52 +0100 Subject: [PATCH 3/7] Node 23 (#3618) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be5e64dab7..e87c7f77bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 23 check-latest: true - name: set up react 19 if: matrix.react == 19 From ca6de7607704a4543682ff2e570b2dd71b4d9fc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 12:22:02 +0100 Subject: [PATCH 4/7] Bump @biomejs/biome from 1.9.3 to 1.9.4 (#3622) Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 1.9.3 to 1.9.4. - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/cli/v1.9.4/packages/@biomejs/biome) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f747a54137..92666a98dd 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.21.5", - "@biomejs/biome": "1.9.3", + "@biomejs/biome": "1.9.4", "@eslint/compat": "^1.1.1", "@faker-js/faker": "^9.0.0", "@ianvs/prettier-plugin-sort-imports": "^4.0.2", From 06044030a72cdae49afc48af90cf110f78f4667b Mon Sep 17 00:00:00 2001 From: Aman Mahajan Date: Mon, 11 Nov 2024 12:02:26 -0600 Subject: [PATCH 5/7] Update eslint deps (#3615) * Update deps * Add new rule * Add `@tanstack/eslint-plugin-router` * Update router deps * update deps * Add `'@typescript-eslint/no-unnecessary-type-parameters': 1,` * Remove @tanstack/eslint-plugin-router * Add `eslint-plugin-react-hooks-extra` * 1 more * Add `eslint-plugin-react-compiler` * -1 array * Do we need these rules * disable 1 `skipLibCheck`, add back newline * update deps * Use state to focus cell * Remove a few refs * Disable warning --------- Co-authored-by: Nicolas Stepien --- eslint.config.js | 21 ++++++- package.json | 22 ++++--- src/DataGrid.tsx | 74 +++++++++++----------- src/ScrollToCell.tsx | 8 +-- src/hooks/useViewportColumns.ts | 1 + src/utils/index.ts | 4 +- tsconfig.website.json | 3 +- website/routes/ColumnSpanning.lazy.tsx | 77 +++++++++++------------ website/routes/CommonFeatures.lazy.tsx | 17 ++--- website/routes/MillionCells.lazy.tsx | 33 +++++----- website/routes/VariableRowHeight.lazy.tsx | 31 ++++----- 11 files changed, 150 insertions(+), 141 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index aa4f2cf287..9023668536 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,9 @@ import tsParser from '@typescript-eslint/parser'; import vitest from '@vitest/eslint-plugin'; import jestDom from 'eslint-plugin-jest-dom'; import react from 'eslint-plugin-react'; +import reactCompiler from 'eslint-plugin-react-compiler'; import reactHooks from 'eslint-plugin-react-hooks'; +import reactHooksExtra from 'eslint-plugin-react-hooks-extra'; import sonarjs from 'eslint-plugin-sonarjs'; import testingLibrary from 'eslint-plugin-testing-library'; @@ -18,7 +20,9 @@ export default [ plugins: { react, + 'react-compiler': reactCompiler, 'react-hooks': fixupPluginRules(reactHooks), + 'react-hooks-extra': reactHooksExtra, sonarjs, '@typescript-eslint': typescriptEslint }, @@ -371,11 +375,22 @@ export default [ 'react/style-prop-object': 0, 'react/void-dom-elements-no-children': 1, + // React Compiler + // https://react.dev/learn/react-compiler#installing-eslint-plugin-react-compiler + 'react-compiler/react-compiler': 1, + // React Hooks // https://www.npmjs.com/package/eslint-plugin-react-hooks 'react-hooks/rules-of-hooks': 1, 'react-hooks/exhaustive-deps': 1, + // React Hooks Extra + // https://eslint-react.xyz/ + 'react-hooks-extra/no-redundant-custom-hook': 1, + 'react-hooks-extra/no-unnecessary-use-callback': 1, + 'react-hooks-extra/no-unnecessary-use-memo': 1, + 'react-hooks-extra/prefer-use-state-lazy-initialization': 1, + // SonarJS rules // https://github.com/SonarSource/eslint-plugin-sonarjs#rules 'sonarjs/no-all-duplicated-branches': 1, @@ -467,13 +482,14 @@ export default [ '@typescript-eslint/no-this-alias': 0, '@typescript-eslint/no-type-alias': 0, '@typescript-eslint/no-unnecessary-boolean-literal-compare': 1, - '@typescript-eslint/no-unnecessary-condition': 1, + '@typescript-eslint/no-unnecessary-condition': [1, { checkTypePredicates: true }], '@typescript-eslint/no-unnecessary-parameter-property-assignment': 1, '@typescript-eslint/no-unnecessary-qualifier': 0, '@typescript-eslint/no-unnecessary-template-expression': 1, '@typescript-eslint/no-unnecessary-type-arguments': 1, '@typescript-eslint/no-unnecessary-type-assertion': 1, '@typescript-eslint/no-unnecessary-type-constraint': 1, + '@typescript-eslint/no-unnecessary-type-parameters': 1, '@typescript-eslint/no-unsafe-argument': 0, '@typescript-eslint/no-unsafe-assignment': 0, '@typescript-eslint/no-unsafe-call': 0, @@ -587,7 +603,7 @@ export default [ plugins: { vitest, 'jest-dom': jestDom, - 'testing-library': fixupPluginRules(testingLibrary) + 'testing-library': testingLibrary }, rules: { @@ -647,6 +663,7 @@ export default [ 'vitest/prefer-to-contain': 1, 'vitest/prefer-to-have-length': 1, 'vitest/prefer-todo': 1, + 'vitest/prefer-vi-mocked': 1, 'vitest/require-hook': 0, 'vitest/require-local-test-context-for-concurrent-snapshots': 0, 'vitest/require-to-throw-message': 0, diff --git a/package.json b/package.json index 92666a98dd..961778cae0 100644 --- a/package.json +++ b/package.json @@ -62,37 +62,39 @@ "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.21.5", "@biomejs/biome": "1.9.4", - "@eslint/compat": "^1.1.1", + "@eslint/compat": "^1.2.2", "@faker-js/faker": "^9.0.0", "@ianvs/prettier-plugin-sort-imports": "^4.0.2", "@linaria/core": "^6.0.0", "@microsoft/api-extractor": "^7.23.0", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-node-resolve": "^15.1.0", - "@tanstack/react-router": "^1.57.13", - "@tanstack/router-plugin": "^1.57.13", + "@tanstack/react-router": "^1.70.0", + "@tanstack/router-plugin": "^1.69.1", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^22.0.0", "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", "@vitejs/plugin-react": "^4.3.1", "@vitest/browser": "^2.1.1", "@vitest/coverage-v8": "^2.1.1", - "@vitest/eslint-plugin": "^1.1.4", + "@vitest/eslint-plugin": "^1.1.8", "@wyw-in-js/rollup": "^0.5.0", "@wyw-in-js/vite": "^0.5.0", "babel-plugin-optimize-clsx": "^2.6.2", "browserslist": "^4.24.0", - "eslint": "^9.11.1", + "eslint": "^9.14.0", "eslint-plugin-jest-dom": "^5.0.1", - "eslint-plugin-react": "^7.36.1", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-compiler": "^19.0.0-beta-a7bf2bd-20241110", "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-sonarjs": "^2.0.2", - "eslint-plugin-testing-library": "^6.3.0", + "eslint-plugin-react-hooks-extra": "^1.16.1", + "eslint-plugin-sonarjs": "^2.0.4", + "eslint-plugin-testing-library": "^6.4.0", "jspdf": "^2.5.1", "jspdf-autotable": "^3.5.23", "playwright": "^1.45.1", diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 314ec4a149..a16597e6fc 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -296,6 +296,8 @@ function DataGrid( const [isDragging, setDragging] = useState(false); const [draggedOverRowIdx, setOverRowIdx] = useState(undefined); const [scrollToPosition, setScrollToPosition] = useState(null); + const [shouldFocusCell, setShouldFocusCell] = useState(false); + const [previousRowIdx, setPreviousRowIdx] = useState(-1); const getColumnWidth = useCallback( (column: CalculatedColumn) => { @@ -338,15 +340,13 @@ function DataGrid( const [selectedPosition, setSelectedPosition] = useState( (): SelectCellState | EditCellState => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }) ); + const [prevSelectedPosition, setPrevSelectedPosition] = useState(selectedPosition); /** * refs */ - const prevSelectedPosition = useRef(selectedPosition); const latestDraggedOverRowIdx = useRef(draggedOverRowIdx); - const lastSelectedRowIdx = useRef(-1); const focusSinkRef = useRef(null); - const shouldFocusCellRef = useRef(false); /** * computed values @@ -458,31 +458,50 @@ function DataGrid( selectCell({ rowIdx: minRowIdx + rowIdx - 1, idx }); }); + /** + * callbacks + */ + const setDraggedOverRowIdx = useCallback((rowIdx?: number) => { + setOverRowIdx(rowIdx); + latestDraggedOverRowIdx.current = rowIdx; + }, []); + + const focusCellOrCellContent = useCallback(() => { + const cell = getCellToScroll(gridRef.current!); + if (cell === null) return; + + scrollIntoView(cell); + // Focus cell content when available instead of the cell itself + const elementToFocus = cell.querySelector('[tabindex="0"]') ?? cell; + elementToFocus.focus({ preventScroll: true }); + }, [gridRef]); + /** * effects */ useLayoutEffect(() => { if ( !selectedCellIsWithinSelectionBounds || - isSamePosition(selectedPosition, prevSelectedPosition.current) + isSamePosition(selectedPosition, prevSelectedPosition) ) { - prevSelectedPosition.current = selectedPosition; + setPrevSelectedPosition(selectedPosition); return; } - prevSelectedPosition.current = selectedPosition; + setPrevSelectedPosition(selectedPosition); - if (selectedPosition.idx === -1) { - focusSinkRef.current!.focus({ preventScroll: true }); + if (focusSinkRef.current !== null && selectedPosition.idx === -1) { + focusSinkRef.current.focus({ preventScroll: true }); scrollIntoView(focusSinkRef.current); } - }); + }, [selectedCellIsWithinSelectionBounds, selectedPosition, prevSelectedPosition]); useLayoutEffect(() => { - if (!shouldFocusCellRef.current) return; - shouldFocusCellRef.current = false; - focusCellOrCellContent(); - }); + if (shouldFocusCell) { + setShouldFocusCell(false); + focusCellOrCellContent(); + } + }, [shouldFocusCell, focusCellOrCellContent]); useImperativeHandle(ref, () => ({ element: gridRef.current, @@ -499,14 +518,6 @@ function DataGrid( selectCell })); - /** - * callbacks - */ - const setDraggedOverRowIdx = useCallback((rowIdx?: number) => { - setOverRowIdx(rowIdx); - latestDraggedOverRowIdx.current = rowIdx; - }, []); - /** * event handlers */ @@ -536,9 +547,8 @@ function DataGrid( if (isRowSelectionDisabled?.(row) === true) return; const newSelectedRows = new Set(selectedRows); const rowKey = rowKeyGetter(row); - const previousRowIdx = lastSelectedRowIdx.current; const rowIdx = rows.indexOf(row); - lastSelectedRowIdx.current = rowIdx; + setPreviousRowIdx(rowIdx); if (checked) { newSelectedRows.add(rowKey); @@ -758,7 +768,7 @@ function DataGrid( // Avoid re-renders if the selected cell state is the same scrollIntoView(getCellToScroll(gridRef.current!)); } else { - shouldFocusCellRef.current = true; + setShouldFocusCell(true); setSelectedPosition({ ...position, mode: 'SELECT' }); } @@ -870,16 +880,6 @@ function DataGrid( return isDraggedOver ? selectedPosition.idx : undefined; } - function focusCellOrCellContent() { - const cell = getCellToScroll(gridRef.current!); - if (cell === null) return; - - scrollIntoView(cell); - // Focus cell content when available instead of the cell itself - const elementToFocus = cell.querySelector('[tabindex="0"]') ?? cell; - elementToFocus.focus({ preventScroll: true }); - } - function renderDragHandle() { if ( onFill == null || @@ -925,7 +925,7 @@ function DataGrid( const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); const closeEditor = (shouldFocusCell: boolean) => { - shouldFocusCellRef.current = shouldFocusCell; + setShouldFocusCell(shouldFocusCell); setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'SELECT' })); }; @@ -1061,6 +1061,7 @@ function DataGrid( // Reset the positions if the current values are no longer valid. This can happen if a column or row is removed if (selectedPosition.idx > maxColIdx || selectedPosition.rowIdx > maxRowIdx) { setSelectedPosition({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }); + // eslint-disable-next-line react-compiler/react-compiler setDraggedOverRowIdx(undefined); } @@ -1182,6 +1183,7 @@ function DataGrid( ); })} + {/* eslint-disable-next-line react-compiler/react-compiler */} {getViewportRows()} {bottomSummaryRows?.map((row, rowIdx) => { @@ -1245,7 +1247,7 @@ function DataGrid( )} diff --git a/src/ScrollToCell.tsx b/src/ScrollToCell.tsx index 7fca2ce353..5c8a228eec 100644 --- a/src/ScrollToCell.tsx +++ b/src/ScrollToCell.tsx @@ -10,11 +10,11 @@ export interface PartialPosition { export default function ScrollToCell({ scrollToPosition: { idx, rowIdx }, - gridElement, + gridRef, setScrollToCellPosition }: { scrollToPosition: PartialPosition; - gridElement: HTMLDivElement; + gridRef: React.RefObject; setScrollToCellPosition: (cell: null) => void; }) { const ref = useRef(null); @@ -31,7 +31,7 @@ export default function ScrollToCell({ } const observer = new IntersectionObserver(removeScrollToCell, { - root: gridElement, + root: gridRef.current!, threshold: 1.0 }); @@ -40,7 +40,7 @@ export default function ScrollToCell({ return () => { observer.disconnect(); }; - }, [gridElement, setScrollToCellPosition]); + }, [gridRef, setScrollToCellPosition]); return (
({ const updateStartIdx = (colIdx: number, colSpan: number | undefined) => { if (colSpan !== undefined && colIdx + colSpan > colOverscanStartIdx) { + // eslint-disable-next-line react-compiler/react-compiler startIdx = colIdx; return true; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 35e428b772..f6bd990888 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import type { CalculatedColumn, CalculatedColumnOrColumnGroup } from '../types'; +import type { CalculatedColumn, CalculatedColumnOrColumnGroup, Maybe } from '../types'; export * from './colSpanUtils'; export * from './domUtils'; @@ -11,7 +11,7 @@ export * from './styleUtils'; export const { min, max, floor, sign, abs } = Math; export function assertIsValidKeyGetter( - keyGetter: unknown + keyGetter: Maybe<(row: NoInfer) => K> ): asserts keyGetter is (row: R) => K { if (typeof keyGetter !== 'function') { throw new Error('Please specify the rowKeyGetter prop to use selection'); diff --git a/tsconfig.website.json b/tsconfig.website.json index 8c78a220cf..8094a617b1 100644 --- a/tsconfig.website.json +++ b/tsconfig.website.json @@ -1,8 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], - "skipLibCheck": true + "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"] }, "include": ["website/**/*"], "references": [{ "path": "tsconfig.src.json" }] diff --git a/website/routes/ColumnSpanning.lazy.tsx b/website/routes/ColumnSpanning.lazy.tsx index e393e2e26d..1862c94cad 100644 --- a/website/routes/ColumnSpanning.lazy.tsx +++ b/website/routes/ColumnSpanning.lazy.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { createLazyFileRoute } from '@tanstack/react-router'; import { css } from '@linaria/core'; @@ -20,49 +19,45 @@ const colSpanClassname = css` text-align: center; `; -function ColumnSpanning() { - const direction = useDirection(); - - const columns = useMemo((): readonly Column[] => { - const columns: 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, - renderCell: renderCoordinates, - 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; - } - }); +for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + resizable: true, + renderCell: renderCoordinates, + 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; - }, []); +function ColumnSpanning() { + const direction = useDirection(); return ( (); for (let i = 0; i < 1000; i++) { + const country = faker.location.country(); + countrySet.add(country); + rows.push({ id: i, title: `Task #${i + 1}`, client: faker.company.name(), area: faker.person.jobArea(), - country: faker.location.country(), + country, contact: faker.internet.exampleEmail(), assignee: faker.person.fullName(), progress: Math.random() * 100, @@ -270,6 +276,8 @@ function createRows(): readonly Row[] { }); } + countries = [...countrySet].sort(new Intl.Collator().compare); + return rows; } @@ -313,12 +321,7 @@ function CommonFeatures() { const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); const [isExporting, setIsExporting] = useState(false); const gridRef = useRef(null); - - const countries = useMemo((): readonly string[] => { - return [...new Set(rows.map((r) => r.country))].sort(new Intl.Collator().compare); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const columns = useMemo(() => getColumns(countries, direction), [countries, direction]); + const columns = useMemo(() => getColumns(countries, direction), [direction]); const summaryRows = useMemo((): readonly SummaryRow[] => { return [ diff --git a/website/routes/MillionCells.lazy.tsx b/website/routes/MillionCells.lazy.tsx index fb3b2975bc..eafaeb467f 100644 --- a/website/routes/MillionCells.lazy.tsx +++ b/website/routes/MillionCells.lazy.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { createLazyFileRoute } from '@tanstack/react-router'; import DataGrid from '../../src'; @@ -13,27 +12,23 @@ export const Route = createLazyFileRoute('/MillionCells')({ type Row = number; const rows: readonly Row[] = Array.from({ length: 1000 }, (_, i) => i); +const columns: Column[] = []; + +for (let i = 0; i < 1000; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + width: 80, + resizable: true, + renderCell: renderCoordinates + }); +} + function MillionCells() { const direction = useDirection(); - const columns = useMemo((): readonly Column[] => { - const columns: Column[] = []; - - for (let i = 0; i < 1000; i++) { - const key = String(i); - columns.push({ - key, - name: key, - frozen: i < 5, - width: 80, - resizable: true, - renderCell: renderCoordinates - }); - } - - return columns; - }, []); - return ( i); +const columns: Column[] = []; + +for (let i = 0; i < 30; i++) { + const key = String(i); + columns.push({ + key, + name: key, + frozen: i < 5, + resizable: true, + renderCell: renderCoordinates + }); +} + function VariableRowHeight() { const direction = useDirection(); - 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, - renderCell: renderCoordinates - }); - } - - return columns; - }, []); - return ( Date: Tue, 12 Nov 2024 08:00:46 +0900 Subject: [PATCH 6/7] Feat add cellRenderer (successor of #2942) (#3621) * Feat cell renderer * chore: rename cellRenderer -> renderCell * chore: codestyle * chore: add renderCell tests and readme * chore: pr review fixes * fix typo * Refactor setupProvider function signature in renderers.test.tsx * fix typing * allow passing style to cellRenderer * use object spread operator --------- Co-authored-by: Aleksey Potsetsuev Co-authored-by: sergeyteleshev --- README.md | 1 + src/Cell.tsx | 58 +++++++++++++++++++---------- src/DataGrid.tsx | 7 +++- src/Row.tsx | 33 +++++++++-------- src/index.ts | 1 + src/types.ts | 3 +- test/browser/renderers.test.tsx | 66 ++++++++++++++++++++++++++++++--- 7 files changed, 124 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 2e20401578..4a80556fa8 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ interface Renderers { renderCheckbox?: Maybe<(props: RenderCheckboxProps) => ReactNode>; renderRow?: Maybe<(key: Key, props: RenderRowProps) => ReactNode>; renderSortStatus?: Maybe<(props: RenderSortStatusProps) => ReactNode>; + renderCell?: Maybe<(key: Key, props: CellRendererProps) => ReactNode>; noRowsFallback?: Maybe; } ``` diff --git a/src/Cell.tsx b/src/Cell.tsx index e90e308617..67a27d00c6 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { forwardRef, memo, type RefAttributes } from 'react'; import { css } from '@linaria/core'; import { useRovingTabIndex } from './hooks'; @@ -25,31 +25,37 @@ const cellDraggedOver = css` const cellDraggedOverClassname = `rdg-cell-dragged-over ${cellDraggedOver}`; -function Cell({ - column, - colSpan, - isCellSelected, - isCopied, - isDraggedOver, - row, - rowIdx, - onClick, - onDoubleClick, - onContextMenu, - onRowChange, - selectCell, - ...props -}: CellRendererProps) { +function Cell( + { + column, + colSpan, + isCellSelected, + isCopied, + isDraggedOver, + row, + rowIdx, + className, + onClick, + onDoubleClick, + onContextMenu, + onRowChange, + selectCell, + style, + ...props + }: CellRendererProps, + ref: React.Ref +) { const { tabIndex, childTabIndex, onFocus } = useRovingTabIndex(isCellSelected); const { cellClass } = column; - const className = getCellClassname( + className = getCellClassname( column, { [cellCopiedClassname]: isCopied, [cellDraggedOverClassname]: isDraggedOver }, - typeof cellClass === 'function' ? cellClass(row) : cellClass + typeof cellClass === 'function' ? cellClass(row) : cellClass, + className ); const isEditable = isCellEditableUtil(column, row); @@ -95,9 +101,13 @@ function Cell({ aria-colspan={colSpan} aria-selected={isCellSelected} aria-readonly={!isEditable || undefined} + ref={ref} tabIndex={tabIndex} className={className} - style={getCellStyle(column, colSpan)} + style={{ + ...getCellStyle(column, colSpan), + ...style + }} onClick={handleClick} onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} @@ -116,4 +126,12 @@ function Cell({ ); } -export default memo(Cell) as (props: CellRendererProps) => React.JSX.Element; +const CellComponent = memo(forwardRef(Cell)) as ( + props: CellRendererProps & RefAttributes +) => React.JSX.Element; + +export default CellComponent; + +export function defaultRenderCell(key: React.Key, props: CellRendererProps) { + return ; +} diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index a16597e6fc..c5c002345d 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -52,6 +52,7 @@ import type { SelectRowEvent, SortColumn } from './types'; +import { defaultRenderCell } from './Cell'; import { renderCheckbox as defaultRenderCheckbox } from './cellRenderers'; import { DataGridDefaultRenderersProvider, @@ -273,6 +274,7 @@ function DataGrid( const headerRowHeight = rawHeaderRowHeight ?? (typeof rowHeight === 'number' ? rowHeight : 35); const summaryRowHeight = rawSummaryRowHeight ?? (typeof rowHeight === 'number' ? rowHeight : 35); const renderRow = renderers?.renderRow ?? defaultRenderers?.renderRow ?? defaultRenderRow; + const renderCell = renderers?.renderCell ?? defaultRenderers?.renderCell ?? defaultRenderCell; const renderSortStatus = renderers?.renderSortStatus ?? defaultRenderers?.renderSortStatus ?? defaultRenderSortStatus; const renderCheckbox = @@ -364,9 +366,10 @@ function DataGrid( const defaultGridComponents = useMemo( () => ({ renderCheckbox, - renderSortStatus + renderSortStatus, + renderCell }), - [renderCheckbox, renderSortStatus] + [renderCheckbox, renderSortStatus, renderCell] ); const headerSelectionValue = useMemo((): HeaderRowSelectionContextValue => { diff --git a/src/Row.tsx b/src/Row.tsx index 1e2c795001..9b811a3397 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import { RowSelectionProvider, useLatestFunc, type RowSelectionContextValue } from './hooks'; import { getColSpan, getRowStyle } from './utils'; import type { CalculatedColumn, RenderRowProps } from './types'; -import Cell from './Cell'; +import { useDefaultRenderers } from './DataGridDefaultRenderersProvider'; import { rowClassname, rowSelectedClassname } from './style/row'; function Row( @@ -33,6 +33,8 @@ function Row( }: RenderRowProps, ref: React.Ref ) { + const renderCell = useDefaultRenderers()!.renderCell!; + const handleRowChange = useLatestFunc((column: CalculatedColumn, newRow: R) => { onRowChange(column, rowIdx, newRow); }); @@ -68,21 +70,20 @@ function Row( cells.push(selectedCellEditor); } else { cells.push( - + renderCell(column.key, { + column, + colSpan, + row, + rowIdx, + isCopied: copiedCellIdx === idx, + isDraggedOver: draggedOverCellIdx === idx, + isCellSelected, + onClick: onCellClick, + onDoubleClick: onCellDoubleClick, + onContextMenu: onCellContextMenu, + onRowChange: handleRowChange, + selectCell + }) ); } } diff --git a/src/index.ts b/src/index.ts index 49a6bc9511..d76283ea28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { default, type DataGridProps, type DataGridHandle } from './DataGrid'; export { default as TreeDataGrid, type TreeDataGridProps } from './TreeDataGrid'; export { DataGridDefaultRenderersProvider } from './DataGridDefaultRenderersProvider'; export { default as Row } from './Row'; +export { default as Cell } from './Cell'; export * from './Columns'; export * from './cellRenderers'; export { default as textEditor } from './editors/textEditor'; diff --git a/src/types.ts b/src/types.ts index 38281c6075..8eff5abe2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,7 +145,7 @@ export interface CellRendererProps extends Pick, 'row' | 'rowIdx' | 'selectCell'>, Omit< React.HTMLAttributes, - 'style' | 'children' | 'onClick' | 'onDoubleClick' | 'onContextMenu' + 'children' | 'onClick' | 'onDoubleClick' | 'onContextMenu' > { column: CalculatedColumn; colSpan: number | undefined; @@ -312,6 +312,7 @@ export interface Renderers { renderCheckbox?: Maybe<(props: RenderCheckboxProps) => ReactNode>; renderRow?: Maybe<(key: Key, props: RenderRowProps) => ReactNode>; renderSortStatus?: Maybe<(props: RenderSortStatusProps) => ReactNode>; + renderCell?: Maybe<(key: Key, props: CellRendererProps) => ReactNode>; noRowsFallback?: Maybe; } diff --git a/test/browser/renderers.test.tsx b/test/browser/renderers.test.tsx index ae520bf128..071cd5aac4 100644 --- a/test/browser/renderers.test.tsx +++ b/test/browser/renderers.test.tsx @@ -7,11 +7,19 @@ import DataGrid, { renderSortIcon, SelectColumn } from '../../src'; -import type { Column, DataGridProps, RenderSortStatusProps, SortColumn } from '../../src'; -import { getHeaderCells, getRows, setup } from './utils'; +import type { + CellRendererProps, + Column, + DataGridProps, + RenderSortStatusProps, + SortColumn +} from '../../src'; +import { getCells, getHeaderCells, getRows, setup } from './utils'; interface Row { id: number; + col1: string; + col2: string; } const noRows: readonly Row[] = []; @@ -30,6 +38,22 @@ const columns: readonly Column[] = [ } ]; +function globalCellRenderer(key: React.Key, props: CellRendererProps) { + return ( +
+ {props.row[props.column.key as keyof Row]} +
+ ); +} + +function localCellRenderer(key: React.Key) { + return ( +
+ local +
+ ); +} + function NoRowsFallback() { return
Local no rows fallback
; } @@ -76,7 +100,8 @@ function setupProvider(props: DataGridProps, renderCheckbox: globalRenderCheckbox, - renderSortStatus: globalSortStatus + renderSortStatus: globalSortStatus, + renderCell: globalCellRenderer }} > @@ -106,21 +131,29 @@ test('fallback defined using both provider and renderers with no rows', () => { }); test('fallback defined using renderers prop with a row', () => { - setup({ columns, rows: [{ id: 1 }], renderers: { noRowsFallback: } }); + setup({ + columns, + rows: [{ id: 1, col1: 'col 1 value', col2: 'col 2 value' }], + renderers: { noRowsFallback: } + }); expect(getRows()).toHaveLength(1); expect(screen.queryByText('Local no rows fallback')).not.toBeInTheDocument(); }); test('fallback defined using provider with a row', () => { - setupProvider({ columns, rows: [{ id: 1 }] }); + setupProvider({ columns, rows: [{ id: 1, col1: 'col 1 value', col2: 'col 2 value' }] }); expect(getRows()).toHaveLength(1); expect(screen.queryByText('Global no rows fallback')).not.toBeInTheDocument(); }); test('fallback defined using both provider and renderers with a row', () => { - setupProvider({ columns, rows: [{ id: 1 }], renderers: { noRowsFallback: } }); + setupProvider({ + columns, + rows: [{ id: 1, col1: 'col 1 value', col2: 'col 2 value' }], + renderers: { noRowsFallback: } + }); expect(getRows()).toHaveLength(1); expect(screen.queryByText('Global no rows fallback')).not.toBeInTheDocument(); @@ -180,3 +213,24 @@ test('sortPriority defined using both providers and renderers', async () => { expect(screen.queryByTestId('global-sort-priority')).not.toBeInTheDocument(); }); + +test('renderCell defined using provider', () => { + setupProvider({ columns, rows: [{ id: 1, col1: 'col 1 value', col2: 'col 2 value' }] }); + + const [, cell1, cell2] = getCells(); + expect(cell1).toHaveTextContent('col 1 value'); + expect(cell2).toHaveTextContent('col 2 value'); +}); + +test('renderCell defined using both providers and renderers', () => { + setupProvider({ + columns, + rows: [{ id: 1, col1: 'col 1 value', col2: 'col 2 value' }], + renderers: { renderCell: localCellRenderer } + }); + + const [selectCell, cell1, cell2] = getCells(); + expect(selectCell).toHaveTextContent('local'); + expect(cell1).toHaveTextContent('local'); + expect(cell2).toHaveTextContent('local'); +}); From dd76e81273a289f8f22065c47df714c24756f078 Mon Sep 17 00:00:00 2001 From: Aman Mahajan Date: Tue, 12 Nov 2024 08:15:05 -0600 Subject: [PATCH 7/7] Remove `prevSelectedPosition` state (#3627) * Remove `prevSelectedPosition` state and one `useLayoutEffect` * `lodash` is not used * Add back `useLayoutEffect` --- eslint.config.js | 4 ---- src/DataGrid.tsx | 15 ++++----------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 9023668536..25f3c6db9e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -574,10 +574,6 @@ export default [ { name: '@testing-library/dom', message: 'Import @testing-library/react instead.' - }, - { - name: 'lodash', - message: 'Import lodash-es instead.' } ], '@typescript-eslint/no-shadow': 0, diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index c5c002345d..593ed97154 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -342,7 +342,6 @@ function DataGrid( const [selectedPosition, setSelectedPosition] = useState( (): SelectCellState | EditCellState => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }) ); - const [prevSelectedPosition, setPrevSelectedPosition] = useState(selectedPosition); /** * refs @@ -484,20 +483,14 @@ function DataGrid( */ useLayoutEffect(() => { if ( - !selectedCellIsWithinSelectionBounds || - isSamePosition(selectedPosition, prevSelectedPosition) + focusSinkRef.current !== null && + selectedCellIsWithinSelectionBounds && + selectedPosition.idx === -1 ) { - setPrevSelectedPosition(selectedPosition); - return; - } - - setPrevSelectedPosition(selectedPosition); - - if (focusSinkRef.current !== null && selectedPosition.idx === -1) { focusSinkRef.current.focus({ preventScroll: true }); scrollIntoView(focusSinkRef.current); } - }, [selectedCellIsWithinSelectionBounds, selectedPosition, prevSelectedPosition]); + }, [selectedCellIsWithinSelectionBounds, selectedPosition]); useLayoutEffect(() => { if (shouldFocusCell) {