From 25d9f4954beb31d5cccbf0dffdc3b96cbd165f18 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sun, 26 Jul 2026 11:38:51 -0500 Subject: [PATCH 1/2] docs: add experimental spreadsheet example --- docs/config.json | 1 + examples/react/spreadsheet/index.html | 12 + examples/react/spreadsheet/package.json | 31 + .../react/spreadsheet/src/CellContextMenu.tsx | 111 ++ examples/react/spreadsheet/src/ColumnMenu.tsx | 104 ++ .../react/spreadsheet/src/Spreadsheet.tsx | 679 ++++++++++++ .../react/spreadsheet/src/SpreadsheetGrid.tsx | 983 ++++++++++++++++++ examples/react/spreadsheet/src/index.css | 867 +++++++++++++++ examples/react/spreadsheet/src/main.tsx | 23 + .../react/spreadsheet/src/spreadsheetModel.ts | 521 ++++++++++ .../react/spreadsheet/src/spreadsheetTable.ts | 120 +++ .../spreadsheet/src/useGridInteractions.ts | 620 +++++++++++ .../spreadsheet/src/useSpreadsheetHistory.ts | 141 +++ examples/react/spreadsheet/src/vite-env.d.ts | 1 + .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 386 +++++++ examples/react/spreadsheet/tsconfig.json | 21 + examples/react/spreadsheet/vite.config.js | 20 + pnpm-lock.yaml | 46 + 18 files changed, 4687 insertions(+) create mode 100644 examples/react/spreadsheet/index.html create mode 100644 examples/react/spreadsheet/package.json create mode 100644 examples/react/spreadsheet/src/CellContextMenu.tsx create mode 100644 examples/react/spreadsheet/src/ColumnMenu.tsx create mode 100644 examples/react/spreadsheet/src/Spreadsheet.tsx create mode 100644 examples/react/spreadsheet/src/SpreadsheetGrid.tsx create mode 100644 examples/react/spreadsheet/src/index.css create mode 100644 examples/react/spreadsheet/src/main.tsx create mode 100644 examples/react/spreadsheet/src/spreadsheetModel.ts create mode 100644 examples/react/spreadsheet/src/spreadsheetTable.ts create mode 100644 examples/react/spreadsheet/src/useGridInteractions.ts create mode 100644 examples/react/spreadsheet/src/useSpreadsheetHistory.ts create mode 100644 examples/react/spreadsheet/src/vite-env.d.ts create mode 100644 examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts create mode 100644 examples/react/spreadsheet/tsconfig.json create mode 100644 examples/react/spreadsheet/vite.config.js diff --git a/docs/config.json b/docs/config.json index cfcd521771..c4db3fdcc0 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1381,6 +1381,7 @@ { "label": "Composable Tables (createTableHook)", "to": "framework/react/examples/composable-tables" }, { "label": "Custom Plugin", "to": "framework/react/examples/custom-plugin" }, { "label": "Experimental Web Workers Plugin", "to": "framework/react/examples/web-worker-row-models" }, + { "label": "Experimental Spreadsheet", "to": "framework/react/examples/spreadsheet" }, { "label": "With TanStack Virtual - Columns", "to": "framework/react/examples/virtualized-columns" }, { "label": "With TanStack Virtual - Columns (Exp)", "to": "framework/react/examples/virtualized-columns-experimental" }, { "label": "With TanStack Virtual - Rows", "to": "framework/react/examples/virtualized-rows" }, diff --git a/examples/react/spreadsheet/index.html b/examples/react/spreadsheet/index.html new file mode 100644 index 0000000000..4a54453c14 --- /dev/null +++ b/examples/react/spreadsheet/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Table Spreadsheet + + +
+ + + diff --git a/examples/react/spreadsheet/package.json b/examples/react/spreadsheet/package.json new file mode 100644 index 0000000000..8ac0da1f75 --- /dev/null +++ b/examples/react/spreadsheet/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-react-table-example-spreadsheet", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "start": "vite", + "lint": "eslint ./src", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts", + "test:types": "tsc" + }, + "dependencies": { + "@faker-js/faker": "^10.5.0", + "@tanstack/react-store": "^0.11.0", + "@tanstack/react-table": "^9.0.0-beta.58", + "@tanstack/react-virtual": "^3.14.5", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@rolldown/plugin-babel": "^0.2.3", + "@rollup/plugin-replace": "^6.0.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "6.0.3", + "vite": "^8.1.0" + } +} diff --git a/examples/react/spreadsheet/src/CellContextMenu.tsx b/examples/react/spreadsheet/src/CellContextMenu.tsx new file mode 100644 index 0000000000..0d2323b5da --- /dev/null +++ b/examples/react/spreadsheet/src/CellContextMenu.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import type { GridInteractions } from './useGridInteractions' +import type { + SpreadsheetTable, + SpreadsheetTableColumn, +} from './spreadsheetTable' + +interface CellContextMenuProps { + x: number + y: number + column: SpreadsheetTableColumn + table: SpreadsheetTable + interactions: GridInteractions + onClose: () => void +} + +export function CellContextMenu({ + x, + y, + column, + table, + interactions, + onClose, +}: CellContextMenuProps) { + const menuRef = React.useRef(null) + + React.useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) onClose() + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + const run = (action: () => void | Promise) => { + onClose() + void action() + } + + return createPortal( +
+ + + +
+ +
+ + +
, + document.body, + ) +} diff --git a/examples/react/spreadsheet/src/ColumnMenu.tsx b/examples/react/spreadsheet/src/ColumnMenu.tsx new file mode 100644 index 0000000000..0fe1cf2403 --- /dev/null +++ b/examples/react/spreadsheet/src/ColumnMenu.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import type { + SpreadsheetTable, + SpreadsheetTableColumn, +} from './spreadsheetTable' + +interface ColumnMenuProps { + anchorRect: DOMRect + column: SpreadsheetTableColumn + table: SpreadsheetTable + onClose: () => void +} + +export function ColumnMenu({ + anchorRect, + column, + table, + onClose, +}: ColumnMenuProps) { + const menuRef = React.useRef(null) + const filterValue = String(column.getFilterValue() ?? '') + const left = Math.min(anchorRect.left, window.innerWidth - 286) + + React.useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) onClose() + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + return createPortal( +
+
+ {column.columnDef.meta?.letter} + {column.columnDef.meta?.label} +
+ + + +
+ + +
, + document.body, + ) +} diff --git a/examples/react/spreadsheet/src/Spreadsheet.tsx b/examples/react/spreadsheet/src/Spreadsheet.tsx new file mode 100644 index 0000000000..c9223c6893 --- /dev/null +++ b/examples/react/spreadsheet/src/Spreadsheet.tsx @@ -0,0 +1,679 @@ +import React from 'react' +import { useCreateAtom } from '@tanstack/react-store' +import { + constructFilterFn, + createColumnHelper, + filterFn_includesString, + useTable, +} from '@tanstack/react-table' +import { SpreadsheetGrid } from './SpreadsheetGrid' +import { + DEFAULT_COLUMN_COUNT, + DEFAULT_ROW_COUNT, + STRESS_COLUMN_COUNT, + STRESS_ROW_COUNT, + formatCellValue, + makeBlankSpreadsheetData, + makeSpreadsheetData, +} from './spreadsheetModel' +import { spreadsheetFeatures } from './spreadsheetTable' +import { useGridInteractions } from './useGridInteractions' +import { useSpreadsheetHistory } from './useSpreadsheetHistory' +import type { CellSelectionState } from '@tanstack/react-table' +import type { SpreadsheetGridHandle } from './SpreadsheetGrid' +import type { + SpreadsheetColumnMeta, + SpreadsheetData, + SpreadsheetRow, +} from './spreadsheetModel' + +interface WorkbookSheet { + id: string + name: string + data: SpreadsheetData +} + +const columnHelper = createColumnHelper< + typeof spreadsheetFeatures, + SpreadsheetRow +>() + +const fieldAwareIncludesStringFilter = constructFilterFn({ + ...filterFn_includesString, + filter: (dataValue, filterValue, row, columnId, addMeta) => + row.original.kind === 'field-header' || + filterFn_includesString.filter( + dataValue, + filterValue, + row, + columnId, + addMeta, + ), +}) + +export function Spreadsheet() { + const seedRef = React.useRef(7) + const [spreadsheetData, setSpreadsheetData] = React.useState( + () => + makeSpreadsheetData( + DEFAULT_ROW_COUNT, + DEFAULT_COLUMN_COUNT, + seedRef.current, + ), + ) + const [sheets, setSheets] = React.useState>(() => [ + { id: 'sheet-1', name: 'Sheet1', data: spreadsheetData }, + ]) + const [activeSheetId, setActiveSheetId] = React.useState('sheet-1') + const [frozenRowCount, setFrozenRowCount] = React.useState(1) + const [frozenColumnCount, setFrozenColumnCount] = React.useState(1) + const [zoom, setZoom] = React.useState(100) + const [ribbonTab, setRibbonTab] = React.useState<'home' | 'data' | 'view'>( + 'home', + ) + + const columnIndexById = React.useMemo( + () => + new Map( + spreadsheetData.columns.map((column) => [column.id, column.index]), + ), + [spreadsheetData.columns], + ) + const history = useSpreadsheetHistory(spreadsheetData.rows, columnIndexById) + + const columns = React.useMemo( + () => + columnHelper.columns( + spreadsheetData.columns.map((column) => + columnHelper.accessor((row) => row.cells[column.index] as unknown, { + id: column.id, + header: column.label, + size: getInitialColumnSize(column), + minSize: 72, + maxSize: 420, + filterFn: fieldAwareIncludesStringFilter, + sortFn: + column.initialType === 'number' || + column.initialType === 'boolean' + ? 'basic' + : column.initialType === 'date' + ? 'alphanumeric' + : 'text', + meta: column, + }), + ), + ), + [spreadsheetData.columns], + ) + + const cellSelectionAtom = useCreateAtom([]) + const table = useTable( + { + key: 'spreadsheet', + features: spreadsheetFeatures, + columns, + data: history.rows, + atoms: { + cellSelection: cellSelectionAtom, + }, + getRowId: (row) => row.id, + enableCellSelection: true, + autoResetCellSelection: false, + columnResizeMode: 'onChange', + keepPinnedRows: false, + }, + (state) => ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnSizing: state.columnSizing, + columnResizing: state.columnResizing, + columnPinning: state.columnPinning, + rowPinning: state.rowPinning, + }), + ) + + React.useEffect(() => { + const desiredTop = table + .getRowModel() + .rows.slice(0, frozenRowCount) + .map((row) => row.id) + const current = table.state.rowPinning + + if (!arraysEqual(current.top, desiredTop) || current.bottom.length > 0) { + table.setRowPinning({ top: desiredTop, bottom: [] }) + } + }, [ + frozenRowCount, + history.rows, + table, + table.state.columnFilters, + table.state.sorting, + ]) + + React.useEffect(() => { + const desiredStart = table + .getAllLeafColumns() + .slice(0, frozenColumnCount) + .map((column) => column.id) + const current = table.state.columnPinning + + if (!arraysEqual(current.start, desiredStart) || current.end.length > 0) { + table.setColumnPinning({ start: desiredStart, end: [] }) + } + }, [ + frozenColumnCount, + spreadsheetData.columns, + table, + table.state.columnPinning, + ]) + + const gridRef = React.useRef(null) + const scrollToCell = React.useCallback( + (rowId: string, columnId: string) => + gridRef.current?.scrollToCell(rowId, columnId), + [], + ) + const interactions = useGridInteractions({ + table, + rows: history.rows, + columns: spreadsheetData.columns, + execute: history.execute, + undo: history.undo, + redo: history.redo, + scrollToCell, + }) + + const resetTableView = React.useCallback(() => { + table.resetSorting(true) + table.resetColumnFilters(true) + table.resetColumnSizing(true) + table.resetCellSelection(true) + }, [table]) + + const loadDataset = React.useCallback( + (rowCount: number, columnCount: number) => { + seedRef.current++ + const next = makeSpreadsheetData(rowCount, columnCount, seedRef.current) + setSpreadsheetData(next) + history.reset(next.rows) + resetTableView() + setFrozenRowCount(1) + setFrozenColumnCount(1) + }, + [history, resetTableView], + ) + + const persistActiveSheet = React.useCallback( + (currentSheets: Array) => + currentSheets.map((sheet) => + sheet.id === activeSheetId + ? { + ...sheet, + data: { ...spreadsheetData, rows: history.rows }, + } + : sheet, + ), + [activeSheetId, history.rows, spreadsheetData], + ) + + const switchSheet = React.useCallback( + (sheetId: string) => { + if (sheetId === activeSheetId) return + const target = sheets.find((sheet) => sheet.id === sheetId) + if (!target) return + + setSheets(persistActiveSheet) + setActiveSheetId(target.id) + setSpreadsheetData(target.data) + history.reset(target.data.rows) + resetTableView() + }, + [activeSheetId, history, persistActiveSheet, resetTableView, sheets], + ) + + const addSheet = React.useCallback(() => { + seedRef.current++ + const number = sheets.length + 1 + const data = makeBlankSpreadsheetData( + DEFAULT_ROW_COUNT, + DEFAULT_COLUMN_COUNT, + seedRef.current, + ) + const sheet = { + id: `sheet-${number}`, + name: `Sheet${number}`, + data, + } + + setSheets((current) => [...persistActiveSheet(current), sheet]) + setActiveSheetId(sheet.id) + setSpreadsheetData(data) + history.reset(data.rows) + resetTableView() + setFrozenRowCount(1) + setFrozenColumnCount(1) + }, [history, persistActiveSheet, resetTableView, sheets.length]) + + const activeSheetIndex = sheets.findIndex( + (sheet) => sheet.id === activeSheetId, + ) + + return ( +
+
+
+
+ + +
+
+ +
+

TanStack Sheet

+

Spreadsheet example

+
+
+ +
+ +
+ File + {(['home', 'data', 'view'] as const).map((tab) => ( + + ))} +
+ +
+ {ribbonTab === 'home' ? ( + <> +
+
+ +
+ + +
+
+ Clipboard +
+
+
+ + +
+ Editing +
+
+
+ + + + {history.rows.length.toLocaleString()} ×{' '} + {spreadsheetData.columns.length.toLocaleString()} + +
+ Workbook +
+ + ) : ribbonTab === 'data' ? ( + <> +
+
+ + + +
+ Sort & Filter · use column menus +
+
+ Right-click a cell or open a column menu to sort and filter. +
+ + ) : ( + <> +
+
+ + +
+ Window +
+
+ Frozen rows and columns stay anchored while the grid + virtualizes. +
+ + )} +
+
+ + + {() => { + const active = interactions.getActiveRange() + const activeValue = active + ? formatCellValue( + interactions.getValue( + active.anchorRowId, + active.anchorColumnId, + ), + ) + : '' + return ( + + ) + }} + + + + +
+
+
+ + +
+ +
+ {sheets.map((sheet) => ( + + ))} +
+ Ready +
+ + {() => { + const summary = interactions.getSelectionSummary() + return ( +
+ {summary.count.toLocaleString()} selected + {summary.numericCount ? ( + <> + Count: {summary.numericCount.toLocaleString()} + Sum: {formatNumber(summary.sum)} + Average: {formatNumber(summary.average)} + + ) : null} +
+ ) + }} +
+
+ + setZoom(Number(event.target.value))} + aria-label="Zoom" + /> + + {zoom}% +
+
+
+ ) +} + +function SpreadsheetFormulaBar({ + active, + initialValue, + interactions, +}: { + active: CellSelectionState[number] | undefined + initialValue: string + interactions: ReturnType +}) { + const [draft, setDraft] = React.useState(initialValue) + + const commit = React.useCallback( + (move?: 'up' | 'down' | 'left' | 'right') => { + if (!active) return + interactions.commitCellValue( + active.anchorRowId, + active.anchorColumnId, + draft, + move, + ) + }, + [active, draft, interactions], + ) + + return ( +
+ + {interactions.getRangeLabel() || '—'} + + + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + setDraft(initialValue) + interactions.cancelEditing() + } else if (event.key === 'Enter') { + event.preventDefault() + commit(event.shiftKey ? 'up' : 'down') + } else if (event.key === 'Tab') { + event.preventDefault() + commit(event.shiftKey ? 'left' : 'right') + } + }} + onBlur={() => commit()} + /> +
+ ) +} + +function getInitialColumnSize(column: SpreadsheetColumnMeta) { + if (column.initialType === 'boolean') return 92 + if (column.initialType === 'number') return 112 + if (column.initialType === 'date') return 124 + if (column.label === 'Notes') return 190 + return 144 +} + +function arraysEqual( + left: ReadonlyArray, + right: ReadonlyArray, +) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function formatNumber(value: number) { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(value) +} diff --git a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx new file mode 100644 index 0000000000..4f354bb76f --- /dev/null +++ b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx @@ -0,0 +1,983 @@ +import React from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { CellContextMenu } from './CellContextMenu' +import { ColumnMenu } from './ColumnMenu' +import { getFillPreview } from './spreadsheetModel' +import type { + CellSelectionBounds, + CellSelectionState, +} from '@tanstack/react-table' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { + FillPreview, + GridBounds, + GridCoordinate, +} from './spreadsheetModel' +import type { + SpreadsheetTable, + SpreadsheetTableCell, + SpreadsheetTableColumn, + SpreadsheetTableHeader, + SpreadsheetTableRow, +} from './spreadsheetTable' +import type { GridInteractions } from './useGridInteractions' + +export const ROW_HEIGHT = 24 +export const HEADER_HEIGHT = 26 +export const ROW_HEADER_WIDTH = 42 +const EDGE_SCROLL_ZONE = 32 +const MAX_EDGE_SCROLL_SPEED = 22 + +interface SpreadsheetGridProps { + table: SpreadsheetTable + interactions: GridInteractions + zoom: number +} + +export interface SpreadsheetGridHandle { + scrollToCell: (rowId: string, columnId: string) => void +} + +interface OpenColumnMenu { + column: SpreadsheetTableColumn + rect: DOMRect +} + +interface OpenCellMenu { + x: number + y: number + column: SpreadsheetTableColumn +} + +interface FillDrag { + source: GridBounds + preview: FillPreview | null +} + +export const SpreadsheetGrid = React.forwardRef< + SpreadsheetGridHandle, + SpreadsheetGridProps +>(function SpreadsheetGrid({ table, interactions, zoom }, forwardedRef) { + const scrollRef = React.useRef(null) + const startColumns = table.getStartVisibleLeafColumns() + const centerColumns = table.getCenterVisibleLeafColumns() + const endColumns = table.getEndVisibleLeafColumns() + const topRows = table.getTopRows() + const centerRows = table.getCenterRows() + const startWidth = startColumns.reduce( + (total, column) => total + column.getSize(), + 0, + ) + const endWidth = endColumns.reduce( + (total, column) => total + column.getSize(), + 0, + ) + const frozenRowsHeight = topRows.length * ROW_HEIGHT + + const rowVirtualizer = useVirtualizer({ + count: centerRows.length, + getScrollElement: () => scrollRef.current, + getItemKey: (index) => centerRows[index]?.id ?? index, + estimateSize: () => ROW_HEIGHT, + paddingStart: HEADER_HEIGHT + frozenRowsHeight, + scrollPaddingStart: HEADER_HEIGHT + frozenRowsHeight, + overscan: 8, + }) + + const columnVirtualizer = useVirtualizer({ + count: centerColumns.length, + getScrollElement: () => scrollRef.current, + getItemKey: (index) => centerColumns[index]?.id ?? index, + estimateSize: (index) => centerColumns[index]?.getSize() ?? 120, + horizontal: true, + paddingStart: ROW_HEADER_WIDTH + startWidth, + paddingEnd: endWidth, + scrollPaddingStart: ROW_HEADER_WIDTH + startWidth, + scrollPaddingEnd: endWidth, + overscan: 3, + }) + + React.useEffect(() => { + columnVirtualizer.measure() + }, [columnVirtualizer, table.state.columnSizing]) + + React.useImperativeHandle( + forwardedRef, + () => ({ + scrollToCell(rowId, columnId) { + const topRowIds = new Set(topRows.map((row) => row.id)) + if (!topRowIds.has(rowId)) { + const rowIndex = centerRows.findIndex((row) => row.id === rowId) + if (rowIndex >= 0) rowVirtualizer.scrollToIndex(rowIndex) + } + + const startColumnIds = new Set(startColumns.map((column) => column.id)) + const endColumnIds = new Set(endColumns.map((column) => column.id)) + if (!startColumnIds.has(columnId) && !endColumnIds.has(columnId)) { + const columnIndex = centerColumns.findIndex( + (column) => column.id === columnId, + ) + if (columnIndex >= 0) columnVirtualizer.scrollToIndex(columnIndex) + } + }, + }), + [ + centerColumns, + centerRows, + columnVirtualizer, + endColumns, + rowVirtualizer, + startColumns, + topRows, + ], + ) + + const [openMenu, setOpenMenu] = React.useState(null) + const [openCellMenu, setOpenCellMenu] = React.useState( + null, + ) + const [fillPreview, setFillPreview] = React.useState(null) + const fillDragRef = React.useRef(null) + const pointerRef = React.useRef({ clientX: 0, clientY: 0 }) + const scrollFrameRef = React.useRef(null) + + const getDisplayColumns = React.useCallback( + () => [...startColumns, ...centerColumns, ...endColumns], + [centerColumns, endColumns, startColumns], + ) + + const resolveCoordinate = React.useCallback( + (clientX: number, clientY: number): GridCoordinate | null => { + const element = scrollRef.current + if (!element) return null + const rect = element.getBoundingClientRect() + const localX = Math.min( + Math.max(clientX - rect.left, ROW_HEADER_WIDTH + 1), + rect.width - 1, + ) + const localY = Math.min( + Math.max(clientY - rect.top, HEADER_HEIGHT + 1), + rect.height - 1, + ) + + let row: SpreadsheetTableRow | undefined + if (topRows.length && localY < HEADER_HEIGHT + frozenRowsHeight) { + const topIndex = Math.min( + topRows.length - 1, + Math.max(0, Math.floor((localY - HEADER_HEIGHT) / ROW_HEIGHT)), + ) + row = topRows[topIndex] + } else { + const item = rowVirtualizer.getVirtualItemForOffset( + element.scrollTop + localY, + ) + row = item ? centerRows[item.index] : undefined + } + + let column: SpreadsheetTableColumn | undefined + if (startColumns.length && localX < ROW_HEADER_WIDTH + startWidth) { + let offset = ROW_HEADER_WIDTH + column = startColumns.find((candidate) => { + const nextOffset = offset + candidate.getSize() + const match = localX >= offset && localX < nextOffset + offset = nextOffset + return match + }) + } else if (endColumns.length && localX > rect.width - endWidth) { + let offset = rect.width - endWidth + column = endColumns.find((candidate) => { + const nextOffset = offset + candidate.getSize() + const match = localX >= offset && localX < nextOffset + offset = nextOffset + return match + }) + } else { + const item = columnVirtualizer.getVirtualItemForOffset( + element.scrollLeft + localX, + ) + column = item ? centerColumns[item.index] : undefined + } + + if (!row || !column) return null + const columnIndex = table.getCellSelectionColumnIndexes()[column.id] ?? -1 + const rowIndex = row.getDisplayIndex() + if (rowIndex < 0 || columnIndex < 0) return null + return { rowIndex, columnIndex } + }, + [ + centerColumns, + centerRows, + columnVirtualizer, + endColumns, + endWidth, + frozenRowsHeight, + rowVirtualizer, + startColumns, + startWidth, + table, + topRows, + ], + ) + + const updateDragTarget = React.useCallback( + (event: MouseEvent) => { + const coordinate = resolveCoordinate(event.clientX, event.clientY) + if (!coordinate) return + + const fillDrag = fillDragRef.current + if (fillDrag) { + const preview = getFillPreview(fillDrag.source, coordinate) + fillDrag.preview = preview + setFillPreview(preview) + return + } + + if (!table._isSelectingCells) return + const row = table.getRowsInDisplayOrder()[coordinate.rowIndex] + const column = getDisplayColumns()[coordinate.columnIndex] + row.getAllCellsByColumnId()[column.id].getSelectionExtendHandler()(event) + }, + [getDisplayColumns, resolveCoordinate, table], + ) + + const runEdgeScroll = React.useCallback(() => { + scrollFrameRef.current = null + if (!fillDragRef.current && !table._isSelectingCells) return + + const element = scrollRef.current + if (!element) return + const rect = element.getBoundingClientRect() + const { clientX, clientY } = pointerRef.current + const topBoundary = rect.top + HEADER_HEIGHT + frozenRowsHeight + const leftBoundary = rect.left + ROW_HEADER_WIDTH + startWidth + const rightBoundary = rect.right - endWidth + + const deltaX = edgeDelta( + clientX, + leftBoundary, + rightBoundary, + EDGE_SCROLL_ZONE, + ) + const deltaY = edgeDelta( + clientY, + topBoundary, + rect.bottom, + EDGE_SCROLL_ZONE, + ) + + if (deltaX || deltaY) { + element.scrollLeft += deltaX + element.scrollTop += deltaY + const syntheticEvent = new MouseEvent('mousemove', { + clientX, + clientY, + bubbles: true, + }) + updateDragTarget(syntheticEvent) + } + + scrollFrameRef.current = requestAnimationFrame(runEdgeScroll) + }, [endWidth, frozenRowsHeight, startWidth, table, updateDragTarget]) + + const ensureEdgeScroll = React.useCallback(() => { + if (scrollFrameRef.current == null) { + scrollFrameRef.current = requestAnimationFrame(runEdgeScroll) + } + }, [runEdgeScroll]) + + React.useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + pointerRef.current = { + clientX: event.clientX, + clientY: event.clientY, + } + if (fillDragRef.current || table._isSelectingCells) { + updateDragTarget(event) + ensureEdgeScroll() + } + } + + const handleMouseUp = () => { + const fillDrag = fillDragRef.current + if (fillDrag?.preview) { + interactions.applyFill(fillDrag.source, fillDrag.preview) + } + fillDragRef.current = null + setFillPreview(null) + if (scrollFrameRef.current != null) { + cancelAnimationFrame(scrollFrameRef.current) + scrollFrameRef.current = null + } + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + if (scrollFrameRef.current != null) { + cancelAnimationFrame(scrollFrameRef.current) + } + } + }, [ensureEdgeScroll, interactions, table, updateDragTarget]) + + const startFillDrag = React.useCallback( + (event: React.MouseEvent, source: GridBounds) => { + event.preventDefault() + event.stopPropagation() + scrollRef.current?.focus({ preventScroll: true }) + pointerRef.current = { + clientX: event.clientX, + clientY: event.clientY, + } + fillDragRef.current = { source, preview: null } + setFillPreview(null) + ensureEdgeScroll() + }, + [ensureEdgeScroll], + ) + + const virtualRows = rowVirtualizer.getVirtualItems() + const virtualColumns = columnVirtualizer.getVirtualItems() + const canvasWidth = Math.max(columnVirtualizer.getTotalSize(), 720) + const canvasHeight = Math.max(rowVirtualizer.getTotalSize(), 320) + + return ( + <> +
{ + if (event.target === event.currentTarget) { + event.currentTarget.focus({ preventScroll: true }) + } + }} + > +
+ + {() => ( + setOpenMenu({ column, rect })} + /> + )} + + + {topRows.length ? ( +
+ {topRows.map((row, index) => ( + { + setOpenMenu(null) + setOpenCellMenu({ x, y, column }) + }} + /> + ))} +
+ ) : null} + + {virtualRows.map((virtualRow) => { + const row = centerRows[virtualRow.index] + return ( + { + setOpenMenu(null) + setOpenCellMenu({ x, y, column }) + }} + /> + ) + })} +
+
+ + {openMenu ? ( + setOpenMenu(null)} + /> + ) : null} + {openCellMenu ? ( + setOpenCellMenu(null)} + /> + ) : null} + + ) +}) + +interface HeaderRowProps { + table: SpreadsheetTable + virtualColumns: Array + centerHeaders: Array + startHeaders: Array + endHeaders: Array + interactions: GridInteractions + onOpenMenu: (column: SpreadsheetTableColumn, rect: DOMRect) => void +} + +function HeaderRow({ + table, + virtualColumns, + centerHeaders, + startHeaders, + endHeaders, + interactions, + onOpenMenu, +}: HeaderRowProps) { + const bounds = table.getCellSelectionBounds() + const rowCount = table.getRowsInDisplayOrder().length + + return ( +
+ + {startHeaders.map((header) => ( + + ))} + {virtualColumns.map((virtualColumn) => { + const header = centerHeaders[virtualColumn.index] + return ( + + ) + })} + {endHeaders.map((header) => ( + + ))} +
+ ) +} + +interface HeaderCellProps { + header: SpreadsheetTableHeader + table: SpreadsheetTable + bounds: Array + rowCount: number + interactions: GridInteractions + left?: number + pinned?: 'start' | 'end' + onOpenMenu: (column: SpreadsheetTableColumn, rect: DOMRect) => void +} + +function HeaderCell({ + header, + table, + bounds, + rowCount, + interactions, + left, + pinned, + onOpenMenu, +}: HeaderCellProps) { + const { column } = header + const columnIndex = table.getCellSelectionColumnIndexes()[column.id] ?? -1 + const fullySelected = bounds.some( + (bound) => + bound.minRowIndex === 0 && + bound.maxRowIndex === rowCount - 1 && + columnIndex >= bound.minColumnIndex && + columnIndex <= bound.maxColumnIndex, + ) + const meta = column.columnDef.meta + const sorted = column.getIsSorted() + const filtered = column.getIsFiltered() + const style = getColumnPositionStyle(column, left, pinned) + + return ( +
interactions.selectColumn(column.id, event)} + > + {meta?.letter} + +
{ + event.stopPropagation() + column.resetSize() + }} + onMouseDown={(event) => { + event.stopPropagation() + header.getResizeHandler()(event) + }} + onTouchStart={(event) => { + event.stopPropagation() + header.getResizeHandler()(event) + }} + /> +
+ ) +} + +interface SubscribedRowProps { + row: SpreadsheetTableRow + top: number + frozen: boolean + table: SpreadsheetTable + virtualColumns: Array + interactions: GridInteractions + fillPreview: FillPreview | null + onStartFill: (event: React.MouseEvent, source: GridBounds) => void + onOpenContextMenu: ( + x: number, + y: number, + column: SpreadsheetTableColumn, + ) => void +} + +function SubscribedRow(props: SubscribedRowProps) { + const { row, table } = props + return ( + + rowSelectionKey( + ranges, + table.getCellSelectionBounds(), + row.getDisplayIndex(), + row.id, + ) + } + > + {() => } + + ) +} + +function SpreadsheetRowView({ + row, + top, + frozen, + table, + virtualColumns, + interactions, + fillPreview, + onStartFill, + onOpenContextMenu, +}: SubscribedRowProps) { + const rowIndex = row.getDisplayIndex() + const bounds = table.getCellSelectionBounds() + const activeBound = bounds.at(-1) + const centerCells = row.getCenterVisibleCells() + const startCells = row.getStartVisibleCells() + const endCells = row.getEndVisibleCells() + const fullySelected = bounds.some( + (bound) => + bound.minColumnIndex === 0 && + bound.maxColumnIndex === + [ + ...table.getStartVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getEndVisibleLeafColumns(), + ].length - + 1 && + rowIndex >= bound.minRowIndex && + rowIndex <= bound.maxRowIndex, + ) + + return ( +
+ + {startCells.map((cell) => ( + + ))} + {virtualColumns.map((virtualColumn) => { + const cell = centerCells[virtualColumn.index] + return ( + + ) + })} + {endCells.map((cell) => ( + + ))} +
+ ) +} + +interface SpreadsheetCellProps { + cell: SpreadsheetTableCell + rowIndex: number + activeBound?: CellSelectionBounds + fillPreview: FillPreview | null + table: SpreadsheetTable + interactions: GridInteractions + left?: number + pinned?: 'start' | 'end' + onStartFill: (event: React.MouseEvent, source: GridBounds) => void + onOpenContextMenu: ( + x: number, + y: number, + column: SpreadsheetTableColumn, + ) => void +} + +function SpreadsheetCell({ + cell, + rowIndex, + activeBound, + fillPreview, + table, + interactions, + left, + pinned, + onStartFill, + onOpenContextMenu, +}: SpreadsheetCellProps) { + const columnIndex = + table.getCellSelectionColumnIndexes()[cell.column.id] ?? -1 + const edges = cell.getSelectionEdges() + const fillTarget = + fillPreview && + isWithinBounds(fillPreview.destination, rowIndex, columnIndex) + const showFillHandle = + activeBound && + rowIndex === activeBound.maxRowIndex && + columnIndex === activeBound.maxColumnIndex + const isEditing = + interactions.editing?.rowId === cell.row.id && + interactions.editing.columnId === cell.column.id + const className = [ + 'spreadsheet-cell', + pinned && 'cell-pinned', + cell.getIsSelected() && 'cell-selected', + cell.getIsFocused() && 'cell-focused', + edges.top && 'cell-edge-top', + edges.right && 'cell-edge-right', + edges.bottom && 'cell-edge-bottom', + edges.left && 'cell-edge-left', + fillTarget && 'cell-fill-preview', + cell.row.original.kind === 'field-header' && 'cell-field-header', + ] + .filter(Boolean) + .join(' ') + + return ( +
{ + if (isEditing) return + const grid = + event.currentTarget.closest('.spreadsheet-grid') + grid?.focus({ preventScroll: true }) + cell.getSelectionStartHandler(document)(event) + }} + onMouseEnter={cell.getSelectionExtendHandler()} + onDoubleClick={() => + interactions.startEditing(cell.row.id, cell.column.id) + } + onContextMenu={(event) => { + event.preventDefault() + if (!cell.getIsSelected()) { + table.setFocusedCell(cell.row.id, cell.column.id) + } + event.currentTarget + .closest('.spreadsheet-grid') + ?.focus({ preventScroll: true }) + onOpenContextMenu(event.clientX, event.clientY, cell.column) + }} + > + {isEditing ? ( + event.currentTarget.select()} + onMouseDown={(event) => event.stopPropagation()} + onChange={(event) => interactions.setEditingDraft(event.target.value)} + onKeyDown={interactions.handleEditorKeyDown} + onBlur={() => interactions.commitEditing()} + /> + ) : ( + + {formatRenderedValue(cell.getValue())} + + )} + {showFillHandle ? ( + + onStartFill(event, { + minRowIndex: activeBound.minRowIndex, + maxRowIndex: activeBound.maxRowIndex, + minColumnIndex: activeBound.minColumnIndex, + maxColumnIndex: activeBound.maxColumnIndex, + }) + } + /> + ) : null} +
+ ) +} + +function getColumnPositionStyle( + column: SpreadsheetTableColumn, + left?: number, + pinned?: 'start' | 'end', +): React.CSSProperties { + if (pinned === 'start') { + return { + width: column.getSize(), + insetInlineStart: ROW_HEADER_WIDTH + column.getStart('start'), + } + } + if (pinned === 'end') { + return { + width: column.getSize(), + insetInlineEnd: column.getAfter('end'), + } + } + return { width: column.getSize(), left } +} + +function rowSelectionKey( + ranges: CellSelectionState, + bounds: Array, + rowIndex: number, + rowId: string, +) { + const active = ranges.at(-1) + let key = active?.anchorRowId === rowId ? `f${active.anchorColumnId}` : '' + + for (const bound of bounds) { + const self = rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex + const above = + rowIndex - 1 >= bound.minRowIndex && rowIndex - 1 <= bound.maxRowIndex + const below = + rowIndex + 1 >= bound.minRowIndex && rowIndex + 1 <= bound.maxRowIndex + + if (self || above || below) { + key += `|${self ? 1 : 0}${above ? 1 : 0}${below ? 1 : 0}:${bound.minColumnIndex}-${bound.maxColumnIndex}` + } + } + + return key +} + +function isWithinBounds( + bounds: GridBounds, + rowIndex: number, + columnIndex: number, +) { + return ( + rowIndex >= bounds.minRowIndex && + rowIndex <= bounds.maxRowIndex && + columnIndex >= bounds.minColumnIndex && + columnIndex <= bounds.maxColumnIndex + ) +} + +function formatRenderedValue(value: unknown) { + if (value == null) return '' + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE' + if (typeof value === 'number') { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(value) + } + return String(value) +} + +function edgeDelta(value: number, start: number, end: number, zone: number) { + if (value < start + zone) { + const ratio = Math.min(1, Math.max(0, (start + zone - value) / zone)) + return -Math.ceil(MAX_EDGE_SCROLL_SPEED * ratio) + } + if (value > end - zone) { + const ratio = Math.min(1, Math.max(0, (value - (end - zone)) / zone)) + return Math.ceil(MAX_EDGE_SCROLL_SPEED * ratio) + } + return 0 +} diff --git a/examples/react/spreadsheet/src/index.css b/examples/react/spreadsheet/src/index.css new file mode 100644 index 0000000000..d0979e22b2 --- /dev/null +++ b/examples/react/spreadsheet/src/index.css @@ -0,0 +1,867 @@ +:root { + color: #242424; + background: #e7e7e7; + font-family: Aptos, 'Segoe UI', Arial, sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-width: 760px; + min-height: 620px; + height: 100%; + margin: 0; +} + +button, +input, +select { + color: inherit; + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.42; +} + +.spreadsheet-app { + display: grid; + grid-template-rows: auto auto minmax(330px, 1fr) auto; + height: 100vh; + min-height: 620px; + overflow: hidden; + background: #fff; + border: 1px solid #9e9e9e; +} + +.excel-chrome { + z-index: 50; + background: #f7f7f7; + box-shadow: 0 1px 2px rgb(0 0 0 / 12%); +} + +.excel-titlebar { + position: relative; + display: grid; + grid-template-columns: minmax(120px, 1fr) auto minmax(120px, 1fr); + align-items: center; + height: 35px; + padding: 0 10px; + color: #fff; + background: #217346; +} + +.quick-access, +.titlebar-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.quick-access button { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + color: #fff; + background: transparent; + border: 0; + border-radius: 2px; + font-size: 17px; +} + +.quick-access button:hover:not(:disabled) { + background: rgb(255 255 255 / 16%); +} + +.excel-document-title { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.excel-logo { + display: grid; + place-items: center; + width: 23px; + height: 23px; + background: #185c37; + border: 1px solid rgb(255 255 255 / 26%); + font-size: 13px; + font-weight: 700; +} + +.excel-document-title h1, +.excel-document-title p { + margin: 0; + color: #fff; + line-height: 1.05; + text-align: center; +} + +.excel-document-title h1 { + font-size: 12px; + font-weight: 500; +} + +.excel-document-title p { + margin-top: 2px; + opacity: 0.72; + font-size: 9px; +} + +.titlebar-actions { + justify-content: flex-end; + gap: 22px; + padding-right: 4px; + font-size: 13px; +} + +.ribbon-tab-row { + display: flex; + align-items: stretch; + height: 32px; + padding-left: 0; + color: #333; + background: #fff; + border-bottom: 1px solid #d0d0d0; +} + +.ribbon-tab-row button, +.file-tab { + display: grid; + place-items: center; + min-width: 62px; + padding: 0 14px; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + font-size: 12px; +} + +.file-tab { + min-width: 55px; + color: #fff; + background: #217346; +} + +.ribbon-tab-row button:hover { + background: #f1f1f1; +} + +.ribbon-tab-row .ribbon-tab-active { + color: #217346; + border-bottom-color: #217346; + font-weight: 600; +} + +.spreadsheet-ribbon { + display: flex; + align-items: stretch; + min-height: 64px; + overflow-x: auto; + background: #f8f8f8; + border-bottom: 1px solid #b8b8b8; + white-space: nowrap; +} + +.ribbon-group { + position: relative; + display: flex; + align-items: center; + min-width: 116px; + padding: 5px 9px 15px; + border-right: 1px solid #d3d3d3; +} + +.ribbon-group > small { + position: absolute; + right: 5px; + bottom: 2px; + left: 5px; + overflow: hidden; + color: #666; + font-size: 9px; + text-align: center; + text-overflow: ellipsis; +} + +.ribbon-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.ribbon-buttons > div { + display: grid; +} + +.ribbon-buttons button { + min-height: 24px; + padding: 3px 7px; + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + font-size: 11px; +} + +.ribbon-buttons button:hover:not(:disabled) { + background: #e5e5e5; + border-color: #c9c9c9; +} + +.ribbon-large-button { + display: grid; + place-items: center; + min-width: 45px; +} + +.ribbon-large-button span { + color: #217346; + font-size: 22px; + line-height: 1; +} + +.ribbon-selects { + gap: 12px; +} + +.ribbon-selects label { + display: grid; + gap: 3px; + color: #555; + font-size: 10px; +} + +.ribbon-selects select { + height: 25px; + background: #fff; + border: 1px solid #aaa; +} + +.ribbon-note { + max-width: 340px; + color: #666; + font-size: 11px; + white-space: normal; +} + +.dataset-size { + padding: 0 7px; + color: #666; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.value-bar { + display: grid; + grid-template-columns: 92px 35px minmax(220px, 1fr); + min-height: 29px; + background: #fff; + border-bottom: 1px solid #a6a6a6; +} + +.value-bar output { + display: flex; + align-items: center; + padding: 0 8px; + overflow: hidden; + border-right: 1px solid #bcbcbc; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.value-bar-icon { + display: grid; + place-items: center; + color: #666; + border-right: 1px solid #d0d0d0; + font-family: Georgia, serif; + font-size: 13px; + font-style: italic; +} + +.value-bar input { + min-width: 0; + padding: 0 8px; + background: #fff; + border: 0; + outline: 0; + font-size: 12px; +} + +.value-bar input:focus { + box-shadow: inset 0 0 0 1px #217346; +} + +.spreadsheet-grid { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + outline: none; + overscroll-behavior: contain; + background: #fff; + cursor: cell; + user-select: none; + -webkit-user-select: none; +} + +.spreadsheet-grid:focus-visible { + box-shadow: inset 0 0 0 1px #217346; +} + +.spreadsheet-canvas { + position: relative; + min-width: 100%; + isolation: isolate; +} + +.spreadsheet-row { + position: absolute; + inset-inline-start: 0; + top: 0; + display: flex; + width: 100%; +} + +.spreadsheet-header-row { + position: sticky; + top: 0; + z-index: 30; + background: #f0f0f0; + border-bottom: 1px solid #a6a6a6; +} + +.frozen-row-region { + position: sticky; + top: 26px; + z-index: 20; + width: 100%; + background: #fff; + box-shadow: 0 2px 2px -1px rgb(0 0 0 / 35%); +} + +.spreadsheet-data-row { + z-index: 1; + background: #fff; +} + +.spreadsheet-row-frozen { + z-index: 21; +} + +.corner-header, +.row-header { + position: sticky; + inset-inline-start: 0; + z-index: 26; + flex: 0 0 42px; + width: 42px; + padding: 0; + color: #444; + background: linear-gradient(#f6f6f6, #e9e9e9); + border: 0; + border-right: 1px solid #a6a6a6; + border-bottom: 1px solid #c8c8c8; + font-size: 10px; + font-variant-numeric: tabular-nums; + text-align: center; + user-select: none; +} + +.corner-header { + height: 26px; + z-index: 40; +} + +.corner-header span { + position: absolute; + right: 3px; + bottom: 3px; + width: 0; + height: 0; + border-bottom: 7px solid #8c8c8c; + border-left: 7px solid transparent; +} + +.row-header { + height: 24px; + cursor: default; +} + +.row-header:hover, +.corner-header:hover { + background: #ddd; +} + +.header-selected { + color: #fff; + background: #217346; +} + +.column-header, +.spreadsheet-cell { + position: absolute; + top: 0; + flex: 0 0 auto; + overflow: hidden; + background: #fff; + border-right: 1px solid #d9d9d9; + border-bottom: 1px solid #d9d9d9; +} + +.column-header { + display: grid; + place-items: center; + height: 26px; + padding: 0 22px 0 5px; + background: linear-gradient(#f7f7f7, #e9e9e9); + border-right-color: #b6b6b6; + border-bottom-color: #a6a6a6; + cursor: default; + user-select: none; +} + +.column-header:hover { + background: #ddd; +} + +.column-pinned, +.cell-pinned { + position: sticky; + z-index: 24; +} + +.cell-pinned { + z-index: 12; +} + +.column-pinned:last-of-type, +.cell-pinned:last-of-type { + box-shadow: 2px 0 3px -2px rgb(0 0 0 / 48%); +} + +.column-letter { + display: block; + width: 100%; + overflow: hidden; + color: #333; + font-size: 10px; + font-weight: 500; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-selected.column-header { + background: #217346; +} + +.header-selected .column-letter { + color: #fff; +} + +.column-menu-button { + position: absolute; + top: 3px; + right: 3px; + display: grid; + place-items: center; + width: 18px; + height: 19px; + padding: 0; + color: #666; + background: transparent; + border: 0; + opacity: 0; + font-size: 9px; +} + +.column-header:hover .column-menu-button, +.column-menu-button:focus-visible, +.column-menu-active { + opacity: 1; +} + +.column-menu-button:hover, +.column-menu-active { + color: #fff; + background: #217346; +} + +.column-resizer { + position: absolute; + top: 0; + right: -3px; + z-index: 5; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; +} + +.column-resizer:hover, +.column-resizer-active { + background: #217346; +} + +.spreadsheet-cell { + --edge-top: 0; + --edge-right: 0; + --edge-bottom: 0; + --edge-left: 0; + display: flex; + align-items: center; + height: 24px; + padding: 0 5px; + outline: none; + cursor: cell; + font-size: 11px; + white-space: nowrap; +} + +.spreadsheet-cell::after { + position: absolute; + z-index: 3; + inset: -1px; + border-color: #217346; + border-style: solid; + border-width: var(--edge-top) var(--edge-right) var(--edge-bottom) + var(--edge-left); + content: ''; + pointer-events: none; +} + +.spreadsheet-cell:hover { + background: #f7fbf8; +} + +.cell-value { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.cell-field-header { + background: #f2f2f2; + font-weight: 600; +} + +.cell-selected { + background: #e2f0d9; +} + +.cell-field-header.cell-selected { + background: #c6e0b4; +} + +.cell-focused { + box-shadow: inset 0 0 0 2px #217346; +} + +.cell-edge-top { + --edge-top: 2px; +} + +.cell-edge-right { + --edge-right: 2px; +} + +.cell-edge-bottom { + --edge-bottom: 2px; +} + +.cell-edge-left { + --edge-left: 2px; +} + +.cell-fill-preview { + background: + repeating-linear-gradient( + 135deg, + rgb(33 115 70 / 7%) 0 4px, + rgb(33 115 70 / 15%) 4px 8px + ), + #edf6e9; + outline: 1px dashed #217346; + outline-offset: -2px; +} + +.fill-handle { + position: absolute; + right: -3px; + bottom: -3px; + z-index: 8; + width: 7px; + height: 7px; + background: #217346; + border: 1px solid #fff; + cursor: crosshair; +} + +.cell-editor { + position: absolute; + z-index: 10; + inset: -1px; + width: calc(100% + 2px); + min-width: 100%; + height: calc(100% + 2px); + padding: 0 5px; + background: #fff; + border: 2px solid #217346; + outline: none; + cursor: text; + font-size: 11px; + user-select: text; + -webkit-user-select: text; +} + +.column-menu, +.cell-context-menu { + position: fixed; + z-index: 1000; + display: grid; + gap: 1px; + padding: 5px; + color: #222; + background: #fff; + border: 1px solid #aaa; + border-radius: 2px; + box-shadow: 2px 4px 12px rgb(0 0 0 / 24%); +} + +.column-menu { + width: 264px; +} + +.cell-context-menu { + width: 210px; +} + +.column-menu-title { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 8px 8px; +} + +.column-menu-title span { + color: #217346; + font-size: 11px; + font-weight: 700; +} + +.column-menu button, +.cell-context-menu button { + min-height: 27px; + padding: 5px 8px; + background: transparent; + border: 0; + border-radius: 1px; + font-size: 11px; + text-align: left; +} + +.cell-context-menu button { + display: grid; + grid-template-columns: 21px 1fr auto; + align-items: center; +} + +.cell-context-menu kbd { + color: #777; + font: inherit; + font-size: 10px; +} + +.column-menu button:hover:not(:disabled), +.cell-context-menu button:hover:not(:disabled) { + background: #e6f1e9; +} + +.column-menu-separator, +.menu-rule { + height: 1px; + margin: 4px 3px; + background: #d6d6d6; +} + +.column-menu label { + display: grid; + gap: 5px; + padding: 4px 8px 7px; + color: #555; + font-size: 10px; +} + +.column-menu input { + width: 100%; + height: 27px; + padding: 0 7px; + border: 1px solid #aaa; + outline: none; +} + +.column-menu input:focus { + border-color: #217346; + box-shadow: 0 0 0 1px #217346; +} + +.spreadsheet-footer { + display: flex; + align-items: center; + min-height: 31px; + padding: 0 7px; + background: #f3f3f3; + border-top: 1px solid #aaa; +} + +.sheet-controls { + display: flex; + align-items: center; + align-self: stretch; + gap: 2px; +} + +.sheet-navigation { + display: flex; + gap: 1px; + padding: 0 5px; + color: #555; + font-size: 9px; +} + +.sheet-navigation button, +.zoom-control button { + display: grid; + width: 22px; + height: 22px; + padding: 0; + place-items: center; + color: #555; + background: transparent; + border: 0; +} + +.sheet-navigation button:hover:not(:disabled), +.zoom-control button:hover:not(:disabled) { + color: #217346; + background: #ddd; +} + +.sheet-navigation button:disabled, +.zoom-control button:disabled { + color: #aaa; +} + +.add-sheet { + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: 0; + border-radius: 50%; + font-size: 18px; +} + +.add-sheet:hover { + background: #ddd; +} + +.sheet-tabs { + display: flex; + align-self: stretch; + min-width: 0; + overflow-x: auto; +} + +.sheet-tab { + align-self: stretch; + min-width: 82px; + padding: 0 12px; + color: #444; + background: transparent; + border: 0; + border-bottom: 3px solid transparent; + font-size: 11px; +} + +.sheet-tab:hover { + background: #e4e4e4; +} + +.sheet-tab-active { + color: #217346; + background: #fff; + border-bottom-color: #217346; + font-weight: 600; +} + +.status-ready { + margin-left: 10px; + color: #555; + font-size: 10px; +} + +.selection-summary { + display: flex; + justify-content: flex-end; + gap: 14px; + min-width: 0; + margin-left: auto; + color: #4c4c4c; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.zoom-control { + display: flex; + align-items: center; + gap: 5px; + margin-left: 18px; + color: #555; + font-size: 10px; +} + +.zoom-control input { + width: 80px; + height: 14px; + accent-color: #217346; +} + +.zoom-control output { + min-width: 34px; +} + +@media (max-width: 900px) { + .titlebar-actions, + .ribbon-note, + .selection-summary span:not(:first-child), + .zoom-control input { + display: none; + } + + .excel-titlebar { + grid-template-columns: 80px 1fr 80px; + } + + .spreadsheet-ribbon { + min-height: 58px; + } +} diff --git a/examples/react/spreadsheet/src/main.tsx b/examples/react/spreadsheet/src/main.tsx new file mode 100644 index 0000000000..4c380ef382 --- /dev/null +++ b/examples/react/spreadsheet/src/main.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Spreadsheet } from './Spreadsheet' +import './index.css' + +/** + * This example is purely experimental and intended for exploration. + * + * TanStack Table is designed around web data-grid standards, not full + * spreadsheet behavior. TanStack Table alone cannot fully reimplement every + * Excel-like feature, and it is not intended to do so. This example instead + * demonstrates how much spreadsheet-like functionality and performance can + * be achieved on the web with TanStack Table as the underlying engine. + */ +const rootElement = document.getElementById('root') + +if (!rootElement) throw new Error('Failed to find the root element') + +ReactDOM.createRoot(rootElement).render( + + + , +) diff --git a/examples/react/spreadsheet/src/spreadsheetModel.ts b/examples/react/spreadsheet/src/spreadsheetModel.ts new file mode 100644 index 0000000000..95235fd090 --- /dev/null +++ b/examples/react/spreadsheet/src/spreadsheetModel.ts @@ -0,0 +1,521 @@ +import { faker } from '@faker-js/faker' + +export const DEFAULT_ROW_COUNT = 2_000 +export const DEFAULT_COLUMN_COUNT = 100 +export const STRESS_ROW_COUNT = 10_000 +export const STRESS_COLUMN_COUNT = 250 + +export type CellValue = string | number | boolean | null +export type SpreadsheetColumnType = 'text' | 'number' | 'date' | 'boolean' + +export interface SpreadsheetRow { + id: string + cells: Array + kind: 'field-header' | 'data' +} + +export interface SpreadsheetColumnMeta { + id: string + index: number + letter: string + label: string + initialType: SpreadsheetColumnType +} + +export interface SpreadsheetData { + columns: Array + rows: Array +} + +export interface CellPatch { + rowId: string + columnId: string + before: CellValue + after: CellValue +} + +export interface SpreadsheetCommand { + label: string + patches: Array +} + +export interface GridCoordinate { + rowIndex: number + columnIndex: number +} + +export interface GridBounds { + minRowIndex: number + maxRowIndex: number + minColumnIndex: number + maxColumnIndex: number +} + +export type FillDirection = 'up' | 'down' | 'left' | 'right' + +export interface FillPreview { + direction: FillDirection + destination: GridBounds + expanded: GridBounds +} + +const seededColumnDefinitions: Array<{ + label: string + type: SpreadsheetColumnType +}> = [ + { label: 'Account', type: 'text' }, + { label: 'Owner', type: 'text' }, + { label: 'Region', type: 'text' }, + { label: 'Status', type: 'text' }, + { label: 'Revenue', type: 'number' }, + { label: 'Units', type: 'number' }, + { label: 'Margin', type: 'number' }, + { label: 'Start Date', type: 'date' }, + { label: 'Due Date', type: 'date' }, + { label: 'Priority', type: 'text' }, + { label: 'Active', type: 'boolean' }, + { label: 'Notes', type: 'text' }, +] + +const regions = ['North', 'South', 'East', 'West', 'Central'] +const statuses = ['Planned', 'Active', 'Blocked', 'Complete'] +const priorities = ['Low', 'Medium', 'High', 'Urgent'] + +export function columnIndexToLetter(index: number) { + let current = index + 1 + let result = '' + + while (current > 0) { + const remainder = (current - 1) % 26 + result = String.fromCharCode(65 + remainder) + result + current = Math.floor((current - 1) / 26) + } + + return result +} + +export function columnId(index: number) { + return `column-${index}` +} + +export function parseColumnIndex(id: string) { + const value = Number(id.slice('column-'.length)) + return Number.isInteger(value) ? value : -1 +} + +export function makeSpreadsheetData( + rowCount: number, + columnCount: number, + seed: number, +): SpreadsheetData { + faker.seed(seed) + + const columns = makeColumns(columnCount) + + const rows: Array = [ + { + id: `row-${seed}-0`, + kind: 'field-header', + cells: columns.map((column) => column.label), + }, + ] + + for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) { + const dataIndex = rowIndex - 1 + const startDate = new Date(2025, 0, 1 + (dataIndex % 330)) + const dueDate = new Date(startDate) + dueDate.setDate(dueDate.getDate() + 14 + (dataIndex % 90)) + + const seededValues: Array = [ + faker.company.name(), + faker.person.fullName(), + regions[dataIndex % regions.length], + statuses[dataIndex % statuses.length], + 25_000 + ((dataIndex * 7_919) % 975_000), + 10 + ((dataIndex * 37) % 5_000), + Math.round((8 + ((dataIndex * 17) % 62)) * 10) / 10, + toIsoDate(startDate), + toIsoDate(dueDate), + priorities[dataIndex % priorities.length], + dataIndex % 5 !== 0, + dataIndex % 7 === 0 ? 'Follow up next week' : '', + ] + + const cells = Array.from({ length: columnCount }, (_, columnIndex) => { + if (columnIndex < seededValues.length) { + return seededValues[columnIndex]! + } + + if (columnIndex % 3 === 0) { + return `Item ${dataIndex + 1}-${columnIndex + 1}` + } + + return ((dataIndex + 1) * (columnIndex + 3)) % 10_000 + }) + + rows.push({ + id: `row-${seed}-${rowIndex}`, + kind: 'data', + cells, + }) + } + + return { columns, rows } +} + +export function makeBlankSpreadsheetData( + rowCount: number, + columnCount: number, + seed: number, +): SpreadsheetData { + const columns = makeColumns(columnCount) + const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({ + id: `row-${seed}-${rowIndex}`, + kind: rowIndex === 0 ? ('field-header' as const) : ('data' as const), + cells: + rowIndex === 0 + ? columns.map((column) => column.label) + : Array(columnCount).fill(null), + })) + + return { columns, rows } +} + +function makeColumns(columnCount: number) { + return Array.from({ length: columnCount }, (_, index) => { + const seeded = seededColumnDefinitions.at(index) + return { + id: columnId(index), + index, + letter: columnIndexToLetter(index), + label: seeded?.label ?? `Field ${index + 1}`, + initialType: seeded?.type ?? (index % 3 === 0 ? 'text' : 'number'), + } satisfies SpreadsheetColumnMeta + }) +} + +function toIsoDate(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function formatCellValue(value: CellValue) { + if (value == null) return '' + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE' + return String(value) +} + +export function parseInputValue(value: string): CellValue { + const trimmed = value.trim() + + if (!trimmed) return null + if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase() === 'true' + if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) { + const number = Number(trimmed) + if (Number.isFinite(number)) return number + } + + return value +} + +export function cellValuesEqual(left: CellValue, right: CellValue) { + return Object.is(left, right) +} + +export function escapeTsvValue(value: CellValue) { + const text = formatCellValue(value) + const safeText = + typeof value === 'string' && /^[\t\r ]*[=+@-]/.test(value) + ? `'${text}` + : text + + return /["\t\n\r]/.test(safeText) + ? `"${safeText.replace(/"/g, '""')}"` + : safeText +} + +export function serializeTsv(ranges: Array>>) { + return ranges + .map((range) => + range + .map((row) => + row.map((value) => escapeTsvValue(value as CellValue)).join('\t'), + ) + .join('\n'), + ) + .join('\n\n') +} + +export function parseTsv(text: string): Array> { + const rows: Array> = [[]] + let value = '' + let quoted = false + + for (let index = 0; index < text.length; index++) { + const character = text[index] + + if (quoted) { + if (character === '"' && text[index + 1] === '"') { + value += '"' + index++ + } else if (character === '"') { + quoted = false + } else { + value += character + } + continue + } + + if (character === '"' && value.length === 0) { + quoted = true + } else if (character === '\t') { + rows[rows.length - 1].push(value) + value = '' + } else if (character === '\n') { + rows[rows.length - 1].push(value) + rows.push([]) + value = '' + } else if (character !== '\r') { + value += character + } + } + + rows[rows.length - 1].push(value) + + if ( + rows.length > 1 && + rows[rows.length - 1].length === 1 && + rows[rows.length - 1][0] === '' + ) { + rows.pop() + } + + return rows.length ? rows : [['']] +} + +export function getFillPreview( + source: GridBounds, + hover: GridCoordinate, +): FillPreview | null { + const rowDistance = + hover.rowIndex < source.minRowIndex + ? hover.rowIndex - source.minRowIndex + : hover.rowIndex > source.maxRowIndex + ? hover.rowIndex - source.maxRowIndex + : 0 + const columnDistance = + hover.columnIndex < source.minColumnIndex + ? hover.columnIndex - source.minColumnIndex + : hover.columnIndex > source.maxColumnIndex + ? hover.columnIndex - source.maxColumnIndex + : 0 + + if (rowDistance === 0 && columnDistance === 0) return null + + if (Math.abs(rowDistance) >= Math.abs(columnDistance) && rowDistance !== 0) { + if (rowDistance < 0) { + const destination = { + minRowIndex: hover.rowIndex, + maxRowIndex: source.minRowIndex - 1, + minColumnIndex: source.minColumnIndex, + maxColumnIndex: source.maxColumnIndex, + } + return { + direction: 'up', + destination, + expanded: { ...source, minRowIndex: hover.rowIndex }, + } + } + + const destination = { + minRowIndex: source.maxRowIndex + 1, + maxRowIndex: hover.rowIndex, + minColumnIndex: source.minColumnIndex, + maxColumnIndex: source.maxColumnIndex, + } + return { + direction: 'down', + destination, + expanded: { ...source, maxRowIndex: hover.rowIndex }, + } + } + + if (columnDistance < 0) { + const destination = { + minRowIndex: source.minRowIndex, + maxRowIndex: source.maxRowIndex, + minColumnIndex: hover.columnIndex, + maxColumnIndex: source.minColumnIndex - 1, + } + return { + direction: 'left', + destination, + expanded: { ...source, minColumnIndex: hover.columnIndex }, + } + } + + const destination = { + minRowIndex: source.minRowIndex, + maxRowIndex: source.maxRowIndex, + minColumnIndex: source.maxColumnIndex + 1, + maxColumnIndex: hover.columnIndex, + } + return { + direction: 'right', + destination, + expanded: { ...source, maxColumnIndex: hover.columnIndex }, + } +} + +export function buildFillPatches(options: { + source: GridBounds + preview: FillPreview + rowIds: Array + columnIds: Array + getValue: (rowId: string, columnId: string) => CellValue +}): Array { + const { source, preview, rowIds, columnIds, getValue } = options + const patches: Array = [] + const vertical = preview.direction === 'up' || preview.direction === 'down' + + for ( + let rowIndex = preview.destination.minRowIndex; + rowIndex <= preview.destination.maxRowIndex; + rowIndex++ + ) { + const rowId = rowIds[rowIndex] + if (!rowId) continue + + for ( + let columnIndex = preview.destination.minColumnIndex; + columnIndex <= preview.destination.maxColumnIndex; + columnIndex++ + ) { + const destinationColumnId = columnIds[columnIndex] + if (!destinationColumnId) continue + + const sourceValues = vertical + ? range(source.minRowIndex, source.maxRowIndex).map( + (sourceRowIndex) => { + const sourceRowId = rowIds[sourceRowIndex] + return getValue(sourceRowId, destinationColumnId) + }, + ) + : range(source.minColumnIndex, source.maxColumnIndex).map( + (sourceColumnIndex) => { + const sourceColumnId = columnIds[sourceColumnIndex] + return getValue(rowId, sourceColumnId) + }, + ) + + const after = getFilledValue({ + sourceValues, + sourceStart: vertical ? source.minRowIndex : source.minColumnIndex, + sourceEnd: vertical ? source.maxRowIndex : source.maxColumnIndex, + destinationIndex: vertical ? rowIndex : columnIndex, + }) + const before = getValue(rowId, destinationColumnId) + + if (!cellValuesEqual(before, after)) { + patches.push({ + rowId, + columnId: destinationColumnId, + before, + after, + }) + } + } + } + + return patches +} + +function range(start: number, end: number) { + return Array.from({ length: end - start + 1 }, (_, index) => start + index) +} + +function getFilledValue(options: { + sourceValues: Array + sourceStart: number + sourceEnd: number + destinationIndex: number +}): CellValue { + const { sourceValues, sourceStart, sourceEnd, destinationIndex } = options + const sequence = inferSequence(sourceValues) + + if (sequence) { + const offset = + destinationIndex > sourceEnd + ? destinationIndex - sourceEnd + : destinationIndex - sourceStart + const base = + destinationIndex > sourceEnd + ? sourceValues[sourceValues.length - 1]! + : sourceValues[0]! + + if (sequence.type === 'number') { + return (base as number) + sequence.step * offset + } + + const date = parseIsoDate(base as string)! + date.setUTCDate(date.getUTCDate() + sequence.step * offset) + return date.toISOString().slice(0, 10) + } + + return sourceValues[ + positiveModulo(destinationIndex - sourceStart, sourceValues.length) + ]! +} + +function inferSequence(values: Array) { + if (values.length < 2 || values.some((value) => value == null)) return null + + if (values.every((value) => typeof value === 'number')) { + const numbers = values + const step = numbers[1] - numbers[0] + if ( + Number.isFinite(step) && + numbers.every( + (number, index) => + index === 0 || Math.abs(number - numbers[index - 1] - step) < 1e-9, + ) + ) { + return { type: 'number' as const, step } + } + } + + const dates = values.map((value) => + typeof value === 'string' ? parseIsoDate(value) : null, + ) + if (dates.every((date): date is Date => date != null)) { + const day = 86_400_000 + const step = (dates[1].getTime() - dates[0].getTime()) / day + if ( + Number.isInteger(step) && + dates.every( + (date, index) => + index === 0 || + (date.getTime() - dates[index - 1].getTime()) / day === step, + ) + ) { + return { type: 'date' as const, step } + } + } + + return null +} + +function parseIsoDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + const date = new Date(`${value}T00:00:00.000Z`) + return Number.isNaN(date.getTime()) || + date.toISOString().slice(0, 10) !== value + ? null + : date +} + +function positiveModulo(value: number, divisor: number) { + return ((value % divisor) + divisor) % divisor +} diff --git a/examples/react/spreadsheet/src/spreadsheetTable.ts b/examples/react/spreadsheet/src/spreadsheetTable.ts new file mode 100644 index 0000000000..dd231b9df0 --- /dev/null +++ b/examples/react/spreadsheet/src/spreadsheetTable.ts @@ -0,0 +1,120 @@ +import { + cellSelectionFeature, + columnFilteringFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + metaHelper, + rowPinningFeature, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + sortFn_text, + tableFeatures, +} from '@tanstack/react-table' +import type { + Cell, + Column, + Header, + ReactTable, + Row, + RowData, + RowModel, + Table, + TableFeatures, + TableState, +} from '@tanstack/react-table' +import type { SpreadsheetColumnMeta, SpreadsheetRow } from './spreadsheetModel' + +function createSpreadsheetSortedRowModel< + TFeatures extends TableFeatures, + TData extends RowData & { kind: 'field-header' | 'data' }, +>() { + const createBaseRowModel = createSortedRowModel() + + return (table: Table) => { + const getBaseRowModel = createBaseRowModel(table) + + return (): RowModel => { + const model = getBaseRowModel() + const headerIndex = model.rows.findIndex( + (row) => row.original.kind === 'field-header', + ) + if (headerIndex <= 0) return model + + const header = model.rows[headerIndex] + const rows = [ + header, + ...model.rows.slice(0, headerIndex), + ...model.rows.slice(headerIndex + 1), + ] + const flatHeaderIndex = model.flatRows.indexOf(header) + const flatRows = + flatHeaderIndex <= 0 + ? model.flatRows + : [ + header, + ...model.flatRows.slice(0, flatHeaderIndex), + ...model.flatRows.slice(flatHeaderIndex + 1), + ] + + return { ...model, rows, flatRows } + } + } +} + +export const spreadsheetFeatures = tableFeatures({ + cellSelectionFeature, + columnFilteringFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + rowPinningFeature, + rowSortingFeature, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSpreadsheetSortedRowModel(), + filterFns: { + includesString: filterFn_includesString, + }, + sortFns: { + alphanumeric: sortFn_alphanumeric, + basic: sortFn_basic, + text: sortFn_text, + }, + columnMeta: metaHelper(), +}) + +export type SpreadsheetFeatures = typeof spreadsheetFeatures +export type SpreadsheetTableState = Pick< + TableState, + | 'sorting' + | 'columnFilters' + | 'columnSizing' + | 'columnResizing' + | 'columnPinning' + | 'rowPinning' +> +export type SpreadsheetTable = ReactTable< + SpreadsheetFeatures, + SpreadsheetRow, + SpreadsheetTableState +> +export type SpreadsheetTableRow = Row +export type SpreadsheetTableColumn = Column< + SpreadsheetFeatures, + SpreadsheetRow, + unknown +> +export type SpreadsheetTableHeader = Header< + SpreadsheetFeatures, + SpreadsheetRow, + unknown +> +export type SpreadsheetTableCell = Cell< + SpreadsheetFeatures, + SpreadsheetRow, + unknown +> diff --git a/examples/react/spreadsheet/src/useGridInteractions.ts b/examples/react/spreadsheet/src/useGridInteractions.ts new file mode 100644 index 0000000000..c3bcb90c2b --- /dev/null +++ b/examples/react/spreadsheet/src/useGridInteractions.ts @@ -0,0 +1,620 @@ +import React from 'react' +import { + buildFillPatches, + cellValuesEqual, + formatCellValue, + parseInputValue, + parseTsv, + serializeTsv, +} from './spreadsheetModel' +import type { + CellSelectionBounds, + CellSelectionDirection, +} from '@tanstack/react-table' +import type { + CellPatch, + CellValue, + FillPreview, + GridBounds, + SpreadsheetColumnMeta, + SpreadsheetRow, +} from './spreadsheetModel' +import type { SpreadsheetTable } from './spreadsheetTable' + +export interface EditingCell { + rowId: string + columnId: string + draft: string +} + +interface GridInteractionOptions { + table: SpreadsheetTable + rows: Array + columns: Array + execute: (label: string, patches: Array) => void + undo: () => void + redo: () => void + scrollToCell: (rowId: string, columnId: string) => void +} + +export function useGridInteractions(options: GridInteractionOptions) { + const { table, rows, columns, execute, undo, redo, scrollToCell } = options + const [editing, setEditing] = React.useState(null) + + const rowById = React.useMemo( + () => new Map(rows.map((row) => [row.id, row])), + [rows], + ) + const columnIndexById = React.useMemo( + () => new Map(columns.map((column) => [column.id, column.index])), + [columns], + ) + + const getValue = React.useCallback( + (rowId: string, columnId: string): CellValue => { + const row = rowById.get(rowId) + const columnIndex = columnIndexById.get(columnId) + if (!row || columnIndex == null) return null + return row.cells[columnIndex] ?? null + }, + [columnIndexById, rowById], + ) + + const getDisplayRows = React.useCallback( + () => table.getRowsInDisplayOrder(), + [table], + ) + const getDisplayColumns = React.useCallback( + () => [ + ...table.getStartVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getEndVisibleLeafColumns(), + ], + [table], + ) + + const getActiveRange = React.useCallback(() => { + const ranges = table.atoms.cellSelection.get() + return ranges.at(-1) + }, [table]) + + const scrollToActiveCorner = React.useCallback(() => { + requestAnimationFrame(() => { + const active = getActiveRange() + if (active) scrollToCell(active.focusRowId, active.focusColumnId) + }) + }, [getActiveRange, scrollToCell]) + + const moveSelection = React.useCallback( + (direction: CellSelectionDirection, extend = false) => { + if (extend) table.extendCellSelection(direction) + else table.moveCellSelection(direction) + scrollToActiveCorner() + }, + [scrollToActiveCorner, table], + ) + + const startEditing = React.useCallback( + (rowId: string, columnId: string, replacement?: string) => { + table.setFocusedCell(rowId, columnId) + setEditing({ + rowId, + columnId, + draft: replacement ?? formatCellValue(getValue(rowId, columnId)), + }) + scrollToCell(rowId, columnId) + }, + [getValue, scrollToCell, table], + ) + + const commitCellValue = React.useCallback( + ( + rowId: string, + columnId: string, + draft: string, + move?: CellSelectionDirection, + ) => { + const before = getValue(rowId, columnId) + const after = parseInputValue(draft) + if (!cellValuesEqual(before, after)) { + execute('Edit cell', [ + { + rowId, + columnId, + before, + after, + }, + ]) + } + + table.setFocusedCell(rowId, columnId) + setEditing(null) + if (move) { + table.moveCellSelection(move) + scrollToActiveCorner() + } + }, + [execute, getValue, scrollToActiveCorner, table], + ) + + const commitEditing = React.useCallback( + (move?: CellSelectionDirection) => { + if (!editing) return + commitCellValue(editing.rowId, editing.columnId, editing.draft, move) + }, + [commitCellValue, editing], + ) + + const cancelEditing = React.useCallback(() => setEditing(null), []) + + const getSelectedBounds = React.useCallback( + () => table.getCellSelectionBounds(), + [table], + ) + + const patchesForBounds = React.useCallback( + ( + bounds: ReadonlyArray, + getAfter: ( + rowOffset: number, + columnOffset: number, + before: CellValue, + ) => CellValue, + ) => { + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const patchesByCell = new Map() + + for (const bound of bounds) { + for ( + let rowIndex = bound.minRowIndex; + rowIndex <= bound.maxRowIndex; + rowIndex++ + ) { + const row = displayRows.at(rowIndex) + if (!row) continue + + for ( + let columnIndex = bound.minColumnIndex; + columnIndex <= bound.maxColumnIndex; + columnIndex++ + ) { + const column = displayColumns.at(columnIndex) + if (!column) continue + + const before = getValue(row.id, column.id) + const after = getAfter( + rowIndex - bound.minRowIndex, + columnIndex - bound.minColumnIndex, + before, + ) + if (cellValuesEqual(before, after)) continue + + patchesByCell.set(`${row.id}\u0000${column.id}`, { + rowId: row.id, + columnId: column.id, + before, + after, + }) + } + } + } + + return [...patchesByCell.values()] + }, + [getDisplayColumns, getDisplayRows, getValue], + ) + + const clearSelection = React.useCallback(() => { + const patches = patchesForBounds(getSelectedBounds(), () => null) + execute('Clear cells', patches) + }, [execute, getSelectedBounds, patchesForBounds]) + + const copySelection = React.useCallback( + (event: React.ClipboardEvent) => { + const text = serializeTsv(table.getSelectedCellRangesData()) + if (!text) return + event.preventDefault() + event.clipboardData.setData('text/plain', text) + }, + [table], + ) + + const copyToClipboard = React.useCallback(async () => { + const text = serializeTsv(table.getSelectedCellRangesData()) + if (!text) return + try { + await navigator.clipboard.writeText(text) + } catch { + // Clipboard access can be denied by an embedded docs preview. Keyboard + // copy remains available through the grid's native copy handler. + } + }, [table]) + + const cutSelection = React.useCallback( + (event: React.ClipboardEvent) => { + copySelection(event) + const patches = patchesForBounds(getSelectedBounds(), () => null) + execute('Cut cells', patches) + }, + [copySelection, execute, getSelectedBounds, patchesForBounds], + ) + + const cutToClipboard = React.useCallback(async () => { + await copyToClipboard() + const patches = patchesForBounds(getSelectedBounds(), () => null) + execute('Cut cells', patches) + }, [copyToClipboard, execute, getSelectedBounds, patchesForBounds]) + + const selectBounds = React.useCallback( + (bounds: GridBounds) => { + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const anchorRow = displayRows.at(bounds.minRowIndex) + const focusRow = displayRows.at(bounds.maxRowIndex) + const anchorColumn = displayColumns.at(bounds.minColumnIndex) + const focusColumn = displayColumns.at(bounds.maxColumnIndex) + + if (!anchorRow || !focusRow || !anchorColumn || !focusColumn) return + table.selectCellRange({ + anchorRowId: anchorRow.id, + focusRowId: focusRow.id, + anchorColumnId: anchorColumn.id, + focusColumnId: focusColumn.id, + }) + scrollToCell(focusRow.id, focusColumn.id) + }, + [getDisplayColumns, getDisplayRows, scrollToCell, table], + ) + + const pasteText = React.useCallback( + (text: string) => { + const active = getActiveRange() + if (!active) return + + const matrix = parseTsv(text) + + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const rowIndex = displayRows.findIndex( + (row) => row.id === active.anchorRowId, + ) + const columnIndex = displayColumns.findIndex( + (column) => column.id === active.anchorColumnId, + ) + if (rowIndex < 0 || columnIndex < 0) return + + const activeBound = getSelectedBounds().at(-1) + const fillActiveRange = + matrix.length === 1 && + matrix[0]?.length === 1 && + activeBound != null && + (activeBound.maxRowIndex > activeBound.minRowIndex || + activeBound.maxColumnIndex > activeBound.minColumnIndex) + + if (fillActiveRange) { + const after = parseInputValue(matrix[0][0] ?? '') + const patches = patchesForBounds([activeBound], () => after) + execute('Paste cells', patches) + return + } + + const patches: Array = [] + let maxRowIndex = rowIndex + let maxColumnIndex = columnIndex + + matrix.forEach((matrixRow, rowOffset) => { + const row = displayRows.at(rowIndex + rowOffset) + if (!row) return + maxRowIndex = rowIndex + rowOffset + + matrixRow.forEach((text, columnOffset) => { + const column = displayColumns.at(columnIndex + columnOffset) + if (!column) return + maxColumnIndex = Math.max(maxColumnIndex, columnIndex + columnOffset) + const before = getValue(row.id, column.id) + const after = parseInputValue(text) + if (!cellValuesEqual(before, after)) { + patches.push({ + rowId: row.id, + columnId: column.id, + before, + after, + }) + } + }) + }) + + execute('Paste cells', patches) + selectBounds({ + minRowIndex: rowIndex, + maxRowIndex, + minColumnIndex: columnIndex, + maxColumnIndex, + }) + }, + [ + execute, + getActiveRange, + getDisplayColumns, + getDisplayRows, + getSelectedBounds, + getValue, + patchesForBounds, + selectBounds, + ], + ) + + const pasteSelection = React.useCallback( + (event: React.ClipboardEvent) => { + if (!getActiveRange()) return + event.preventDefault() + pasteText(event.clipboardData.getData('text/plain')) + }, + [getActiveRange, pasteText], + ) + + const pasteFromClipboard = React.useCallback(async () => { + try { + pasteText(await navigator.clipboard.readText()) + } catch { + // Keyboard paste remains available when the Clipboard API is denied. + } + }, [pasteText]) + + const applyFill = React.useCallback( + (source: GridBounds, preview: FillPreview) => { + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const patches = buildFillPatches({ + source, + preview, + rowIds: displayRows.map((row) => row.id), + columnIds: displayColumns.map((column) => column.id), + getValue, + }) + + execute('Fill cells', patches) + selectBounds(preview.expanded) + }, + [execute, getDisplayColumns, getDisplayRows, getValue, selectBounds], + ) + + const selectColumn = React.useCallback( + (columnId: string, event: React.MouseEvent) => { + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const clickedIndex = displayColumns.findIndex( + (column) => column.id === columnId, + ) + if (!displayRows.length || clickedIndex < 0) return + + let minColumnIndex = clickedIndex + let maxColumnIndex = clickedIndex + if (event.shiftKey) { + const activeBound = getSelectedBounds().at(-1) + if (activeBound) { + minColumnIndex = Math.min(activeBound.minColumnIndex, clickedIndex) + maxColumnIndex = Math.max(activeBound.maxColumnIndex, clickedIndex) + } + } + + table.selectCellRange( + { + anchorRowId: displayRows[0].id, + focusRowId: displayRows[displayRows.length - 1].id, + anchorColumnId: displayColumns[minColumnIndex].id, + focusColumnId: displayColumns[maxColumnIndex].id, + }, + { additive: event.metaKey || event.ctrlKey }, + ) + }, + [getDisplayColumns, getDisplayRows, getSelectedBounds, table], + ) + + const selectRow = React.useCallback( + (rowId: string, event: React.MouseEvent) => { + const displayRows = getDisplayRows() + const displayColumns = getDisplayColumns() + const clickedIndex = displayRows.findIndex((row) => row.id === rowId) + if (!displayColumns.length || clickedIndex < 0) return + + let minRowIndex = clickedIndex + let maxRowIndex = clickedIndex + if (event.shiftKey) { + const activeBound = getSelectedBounds().at(-1) + if (activeBound) { + minRowIndex = Math.min(activeBound.minRowIndex, clickedIndex) + maxRowIndex = Math.max(activeBound.maxRowIndex, clickedIndex) + } + } + + table.selectCellRange( + { + anchorRowId: displayRows[minRowIndex].id, + focusRowId: displayRows[maxRowIndex].id, + anchorColumnId: displayColumns[0].id, + focusColumnId: displayColumns[displayColumns.length - 1].id, + }, + { additive: event.metaKey || event.ctrlKey }, + ) + }, + [getDisplayColumns, getDisplayRows, getSelectedBounds, table], + ) + + const handleGridKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (editing) return + const modifier = event.metaKey || event.ctrlKey + const key = event.key + + if (modifier && key.toLowerCase() === 'z') { + event.preventDefault() + if (event.shiftKey) redo() + else undo() + return + } + if (modifier && key.toLowerCase() === 'y') { + event.preventDefault() + redo() + return + } + if (modifier && key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const arrows: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + } + const direction = arrows[key] + if (direction) { + event.preventDefault() + moveSelection(direction, event.shiftKey) + return + } + + if (key === 'Tab') { + event.preventDefault() + moveSelection(event.shiftKey ? 'left' : 'right') + return + } + if (key === 'Enter') { + event.preventDefault() + moveSelection(event.shiftKey ? 'up' : 'down') + return + } + if (key === 'F2') { + event.preventDefault() + const active = getActiveRange() + if (active) startEditing(active.anchorRowId, active.anchorColumnId) + return + } + if (key === 'Delete' || key === 'Backspace') { + event.preventDefault() + clearSelection() + return + } + if (key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + if ( + key.length === 1 && + !modifier && + !event.altKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault() + const active = getActiveRange() + if (active) { + startEditing(active.anchorRowId, active.anchorColumnId, key) + } + } + }, + [ + clearSelection, + editing, + getActiveRange, + moveSelection, + redo, + startEditing, + table, + undo, + ], + ) + + const handleEditorKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + cancelEditing() + } else if (event.key === 'Enter') { + event.preventDefault() + commitEditing(event.shiftKey ? 'up' : 'down') + } else if (event.key === 'Tab') { + event.preventDefault() + commitEditing(event.shiftKey ? 'left' : 'right') + } + }, + [cancelEditing, commitEditing], + ) + + const getRangeLabel = React.useCallback(() => { + const bound = getSelectedBounds().at(-1) + if (!bound) return '' + const start = `${columns[bound.minColumnIndex]?.letter ?? '?'}${ + bound.minRowIndex + 1 + }` + const end = `${columns[bound.maxColumnIndex]?.letter ?? '?'}${ + bound.maxRowIndex + 1 + }` + return start === end ? start : `${start}:${end}` + }, [columns, getSelectedBounds]) + + const getSelectionSummary = React.useCallback(() => { + const count = table.getSelectedCellCount() + // Materializing the values for a whole 10k × 250 stress grid would turn a + // cheap selection into millions of allocations just to populate the + // status bar. Keep exact counts, but only calculate numeric stats for + // ranges small enough to summarize interactively. + if (count > 10_000) { + return { + count, + numericCount: 0, + sum: 0, + average: 0, + } + } + + const values = table + .getSelectedCellRangesData() + .flat(2) + .filter((value): value is number => typeof value === 'number') + const sum = values.reduce((total, value) => total + value, 0) + return { + count, + numericCount: values.length, + sum, + average: values.length ? sum / values.length : 0, + } + }, [table]) + + return { + editing, + setEditingDraft: React.useCallback( + (draft: string) => + setEditing((current) => (current ? { ...current, draft } : current)), + [], + ), + getValue, + getActiveRange, + getSelectedBounds, + getRangeLabel, + getSelectionSummary, + startEditing, + commitCellValue, + commitEditing, + cancelEditing, + clearSelection, + copySelection, + copyToClipboard, + cutSelection, + cutToClipboard, + pasteSelection, + pasteFromClipboard, + applyFill, + selectBounds, + selectColumn, + selectRow, + handleGridKeyDown, + handleEditorKeyDown, + } +} + +export type GridInteractions = ReturnType diff --git a/examples/react/spreadsheet/src/useSpreadsheetHistory.ts b/examples/react/spreadsheet/src/useSpreadsheetHistory.ts new file mode 100644 index 0000000000..dfb0071341 --- /dev/null +++ b/examples/react/spreadsheet/src/useSpreadsheetHistory.ts @@ -0,0 +1,141 @@ +import React from 'react' +import type { + CellPatch, + SpreadsheetCommand, + SpreadsheetRow, +} from './spreadsheetModel' + +interface HistoryState { + rows: Array + past: Array + future: Array +} + +type HistoryAction = + | { type: 'execute'; command: SpreadsheetCommand } + | { type: 'reset'; rows: Array } + | { type: 'undo' } + | { type: 'redo' } + +const HISTORY_LIMIT = 100 + +export function useSpreadsheetHistory( + initialRows: Array, + columnIndexById: ReadonlyMap, +) { + const reducer = React.useCallback( + (state: HistoryState, action: HistoryAction): HistoryState => { + const rowIndexById = new Map( + state.rows.map((row, index) => [row.id, index]), + ) + + if (action.type === 'reset') { + return { rows: action.rows, past: [], future: [] } + } + + if (action.type === 'execute') { + if (!action.command.patches.length) return state + return { + rows: applyPatches( + state.rows, + action.command.patches, + 'after', + rowIndexById, + columnIndexById, + ), + past: [...state.past, action.command].slice(-HISTORY_LIMIT), + future: [], + } + } + + if (action.type === 'undo') { + const command = state.past.at(-1) + if (!command) return state + return { + rows: applyPatches( + state.rows, + command.patches, + 'before', + rowIndexById, + columnIndexById, + ), + past: state.past.slice(0, -1), + future: [command, ...state.future], + } + } + + const command = state.future.at(0) + if (!command) return state + return { + rows: applyPatches( + state.rows, + command.patches, + 'after', + rowIndexById, + columnIndexById, + ), + past: [...state.past, command].slice(-HISTORY_LIMIT), + future: state.future.slice(1), + } + }, + [columnIndexById], + ) + + const [state, dispatch] = React.useReducer(reducer, { + rows: initialRows, + past: [], + future: [], + }) + + const execute = React.useCallback( + (label: string, patches: Array) => { + dispatch({ type: 'execute', command: { label, patches } }) + }, + [], + ) + + const reset = React.useCallback((rows: Array) => { + dispatch({ type: 'reset', rows }) + }, []) + + return { + rows: state.rows, + canUndo: state.past.length > 0, + canRedo: state.future.length > 0, + lastCommand: state.past[state.past.length - 1]?.label, + execute, + reset, + undo: React.useCallback(() => dispatch({ type: 'undo' }), []), + redo: React.useCallback(() => dispatch({ type: 'redo' }), []), + } +} + +function applyPatches( + rows: Array, + patches: Array, + value: 'before' | 'after', + rowIndexById: ReadonlyMap, + columnIndexById: ReadonlyMap, +) { + const nextRows = rows.slice() + const clonedRows = new Map() + + for (const patch of patches) { + const rowIndex = rowIndexById.get(patch.rowId) + const columnIndex = columnIndexById.get(patch.columnId) + if (rowIndex == null || columnIndex == null) continue + + let row = clonedRows.get(rowIndex) + if (!row) { + const current = rows.at(rowIndex) + if (!current) continue + row = { ...current, cells: current.cells.slice() } + clonedRows.set(rowIndex, row) + nextRows[rowIndex] = row + } + + row.cells[columnIndex] = patch[value] + } + + return nextRows +} diff --git a/examples/react/spreadsheet/src/vite-env.d.ts b/examples/react/spreadsheet/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/react/spreadsheet/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts new file mode 100644 index 0000000000..eb52aaa5fc --- /dev/null +++ b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts @@ -0,0 +1,386 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Locator, Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + + return errors +} + +async function openExample(page: Page) { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + await expect(page.getByRole('grid')).toBeVisible() + return { errors, server } + } catch (error) { + await server.close() + throw error + } +} + +function cell(page: Page, rowIndex: number, columnIndex: number) { + return page.locator( + `[data-row-id="row-7-${rowIndex}"][data-column-id="column-${columnIndex}"]`, + ) +} + +function displayedCell(page: Page, rowIndex: number, columnIndex: number) { + return page.locator( + `[data-row-index="${rowIndex}"] [data-column-id="column-${columnIndex}"]`, + ) +} + +async function replaceCellValue(target: Locator, value: string) { + await target.dblclick() + const editor = target.getByRole('textbox') + await editor.fill(value) + await editor.press('Enter') +} + +test('renders a virtualized spreadsheet without page errors', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + await expect(page.getByText('TanStack Sheet')).toBeVisible() + await expect(page.getByText('2,000 × 100')).toBeVisible() + await expect(page.getByRole('columnheader', { name: /^A/ })).toBeVisible() + await expect(cell(page, 0, 0)).toBeVisible() + await expect(cell(page, 0, 0)).toHaveText('Account') + await expect(cell(page, 0, 1)).toHaveText('Owner') + await expect(page.getByRole('grid')).toHaveCSS('user-select', 'none') + + const renderedCells = await page.locator('[data-sheet-cell]').count() + expect(renderedCells).toBeGreaterThan(0) + expect(renderedCells).toBeLessThan(2_000) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('edits a cell and supports atomic undo and redo', async ({ page }) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 1, 0) + const original = await target.textContent() + + await replaceCellValue(target, 'Edited account') + await expect(target).toContainText('Edited account') + + await page.getByRole('button', { name: 'Undo' }).click() + await expect(target).toHaveText(original ?? '') + + await page.getByRole('button', { name: 'Redo' }).click() + await expect(target).toContainText('Edited account') + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('edits the active cell from the fx value bar', async ({ page }) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 1, 0) + const original = await target.textContent() + + await target.click() + const valueBar = page.getByRole('textbox', { name: 'Cell value' }) + await expect(valueBar).toHaveValue(original ?? '') + await valueBar.fill('Edited from fx') + await expect(valueBar).toHaveValue('Edited from fx') + await valueBar.press('Enter') + + await expect(target).toHaveText('Edited from fx') + await page.getByRole('button', { name: 'Undo' }).click() + await expect(target).toHaveText(original ?? '') + await page.getByRole('button', { name: 'Redo' }).click() + await expect(target).toHaveText('Edited from fx') + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('adds, switches, and navigates sheets and changes the zoom', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const sheet1 = page.getByRole('tab', { name: 'Sheet1' }) + await expect(sheet1).toHaveAttribute('aria-selected', 'true') + + await page.getByRole('button', { name: 'Add sheet' }).click() + const sheet2 = page.getByRole('tab', { name: 'Sheet2' }) + await expect(sheet2).toHaveAttribute('aria-selected', 'true') + + const sheet2Target = displayedCell(page, 1, 0) + await expect(sheet2Target).toHaveText('') + await replaceCellValue(sheet2Target, 'Saved on Sheet2') + + await page.getByRole('button', { name: 'Previous sheet' }).click() + await expect(sheet1).toHaveAttribute('aria-selected', 'true') + await expect(displayedCell(page, 1, 0)).not.toHaveText('Saved on Sheet2') + + await page.getByRole('button', { name: 'Next sheet' }).click() + await expect(sheet2).toHaveAttribute('aria-selected', 'true') + await expect(displayedCell(page, 1, 0)).toHaveText('Saved on Sheet2') + + await sheet1.click() + await expect(sheet1).toHaveAttribute('aria-selected', 'true') + await sheet2.click() + await expect(sheet2).toHaveAttribute('aria-selected', 'true') + + const canvas = page.locator('.spreadsheet-canvas') + const widthAt100 = + (await displayedCell(page, 0, 0).boundingBox())?.width ?? 0 + await page.getByRole('button', { name: 'Zoom in' }).click() + await expect(page.locator('.zoom-control output')).toHaveText('110%') + await expect(canvas).toHaveAttribute('data-zoom', '110') + + await page.getByRole('button', { name: 'Zoom out' }).click() + await expect(page.locator('.zoom-control output')).toHaveText('100%') + + await page.getByRole('slider', { name: 'Zoom' }).fill('125') + await expect(page.locator('.zoom-control output')).toHaveText('125%') + await expect(canvas).toHaveAttribute('data-zoom', '125') + await expect + .poll( + async () => (await displayedCell(page, 0, 0).boundingBox())?.width ?? 0, + ) + .toBeGreaterThan(widthAt100 * 1.2) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('extends a numeric series with the fill handle', async ({ page }) => { + const { errors, server } = await openExample(page) + + try { + await replaceCellValue(cell(page, 1, 5), '1') + await replaceCellValue(cell(page, 2, 5), '3') + + await cell(page, 1, 5).click() + await cell(page, 2, 5).click({ modifiers: ['Shift'] }) + + const handle = page.getByTestId('fill-handle') + const target = cell(page, 4, 5) + await expect(handle).toBeVisible() + await expect(target).toBeVisible() + + const handleBox = await handle.boundingBox() + const targetBox = await target.boundingBox() + if (!handleBox || !targetBox) + throw new Error('Fill drag bounds unavailable') + + await page.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + targetBox.height / 2, + { steps: 5 }, + ) + await page.mouse.up() + + await expect(cell(page, 3, 5)).toHaveText('5') + await expect(cell(page, 4, 5)).toHaveText('7') + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('filters and sorts from a column header menu', async ({ page }) => { + const { errors, server } = await openExample(page) + + try { + await page.getByRole('button', { name: 'Open C column menu' }).click() + await page + .getByRole('textbox', { name: 'Filter values containing' }) + .fill('North') + await page.keyboard.press('Escape') + + await expect(cell(page, 0, 2)).toHaveText('Region') + await expect(cell(page, 1, 2)).toHaveText('North') + await expect(page.getByRole('grid')).toHaveAttribute('aria-rowcount', '401') + + const revenueBefore = Number( + (await cell(page, 1, 4).textContent())?.replaceAll(',', ''), + ) + await page.getByRole('button', { name: 'Open E column menu' }).click() + await page.getByRole('button', { name: 'Sort Z → A' }).click() + const revenueAfter = Number( + ( + await page + .locator('[data-row-index="1"] [data-column-id="column-4"]') + .textContent() + )?.replaceAll(',', ''), + ) + + expect(revenueAfter).toBeGreaterThanOrEqual(revenueBefore) + await expect(cell(page, 0, 4)).toHaveText('Revenue') + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('keeps frozen rows and columns visible during two-axis scrolling', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const grid = page.getByRole('grid') + const frozenCell = cell(page, 0, 0) + const before = await frozenCell.boundingBox() + if (!before) throw new Error('Frozen cell bounds unavailable') + + await grid.evaluate((element) => { + element.scrollTop = 4_000 + element.scrollLeft = 4_000 + element.dispatchEvent(new Event('scroll')) + }) + + await expect + .poll(() => grid.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(3_000) + await expect + .poll(() => grid.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(3_000) + + await expect(frozenCell).toBeVisible() + const after = await frozenCell.boundingBox() + if (!after) throw new Error('Frozen cell bounds unavailable after scroll') + + expect(Math.abs(after.x - before.x)).toBeLessThan(2) + expect(Math.abs(after.y - before.y)).toBeLessThan(2) + await expect( + page.locator('[data-column-id="column-30"]').first(), + ).toBeVisible() + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('resizes a column without breaking the virtualized layout', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 0, 0) + const before = await target.boundingBox() + const resizer = page.getByRole('separator', { name: 'Resize column A' }) + const resizerBox = await resizer.boundingBox() + if (!before || !resizerBox) throw new Error('Resize bounds unavailable') + + await page.mouse.move( + resizerBox.x + resizerBox.width / 2, + resizerBox.y + resizerBox.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + resizerBox.x + 50, + resizerBox.y + resizerBox.height / 2, + ) + await page.mouse.up() + + await expect + .poll(async () => (await target.boundingBox())?.width ?? 0) + .toBeGreaterThan(before.width + 30) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('provides Excel-style ribbon and cell context actions', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + await page.getByRole('tab', { name: 'View' }).click() + await page.getByRole('combobox', { name: 'Freeze rows' }).selectOption('2') + await expect( + page.locator('.spreadsheet-row-frozen [data-sheet-cell]'), + ).not.toHaveCount(0) + + const target = cell(page, 1, 0) + await target.click({ button: 'right' }) + const menu = page.getByRole('menu', { name: 'Cell actions' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem', { name: /Cut/ })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: /Copy/ })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: /Paste/ })).toBeVisible() + + const original = await target.textContent() + await menu.getByRole('menuitem', { name: 'Clear contents' }).click() + await expect(target).toHaveText('') + await page.getByRole('button', { name: 'Undo' }).click() + await expect(target).toHaveText(original ?? '') + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('keeps the 10k × 250 stress grid interactive', async ({ page }) => { + const { errors, server } = await openExample(page) + + try { + await page.getByRole('button', { name: 'Stress data' }).click() + + const grid = page.getByRole('grid') + await expect(page.getByText('10,000 × 250')).toBeVisible() + await expect(grid).toHaveAttribute('aria-rowcount', '10000') + await expect(grid).toHaveAttribute('aria-colcount', '250') + + await page.getByRole('button', { name: 'Select all cells' }).click() + await expect(page.getByText('2,500,000 selected')).toBeVisible() + + await grid.evaluate((element) => { + element.scrollTop = 200_000 + element.scrollLeft = 20_000 + element.dispatchEvent(new Event('scroll')) + }) + + await expect + .poll(() => grid.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(150_000) + await expect + .poll(() => grid.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(15_000) + + const renderedCells = await page.locator('[data-sheet-cell]').count() + expect(renderedCells).toBeGreaterThan(0) + expect(renderedCells).toBeLessThan(2_000) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/react/spreadsheet/tsconfig.json b/examples/react/spreadsheet/tsconfig.json new file mode 100644 index 0000000000..e46d524f7a --- /dev/null +++ b/examples/react/spreadsheet/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true + }, + "include": ["src", "tests/e2e", "vite.config.js", "vite.config.ts"] +} diff --git a/examples/react/spreadsheet/vite.config.js b/examples/react/spreadsheet/vite.config.js new file mode 100644 index 0000000000..7476ecdb52 --- /dev/null +++ b/examples/react/spreadsheet/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import rollupReplace from '@rollup/plugin-replace' + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + port: 6565, + }, + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + }), + react(), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21ded05370..f00cfac038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9441,6 +9441,52 @@ importers: specifier: ^8.1.0 version: 8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0) + examples/react/spreadsheet: + dependencies: + '@faker-js/faker': + specifier: ^10.5.0 + version: 10.5.0 + '@tanstack/react-store': + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-table': + specifier: workspace:* + version: link:../../../packages/react-table + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0)) + '@rollup/plugin-replace': + specifier: ^6.0.3 + version: 6.0.3(rollup@4.61.1) + '@types/react': + specifier: 19.2.16 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.0 + version: 8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0) + examples/react/sub-components: dependencies: '@faker-js/faker': From c9c24ca0c48d379f60ac98827be434b1c7d85513 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sun, 26 Jul 2026 11:55:16 -0500 Subject: [PATCH 2/2] add autofit --- .../react/spreadsheet/src/Spreadsheet.tsx | 1 - .../react/spreadsheet/src/SpreadsheetGrid.tsx | 43 ++++++++++++++++++- .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 32 ++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/examples/react/spreadsheet/src/Spreadsheet.tsx b/examples/react/spreadsheet/src/Spreadsheet.tsx index c9223c6893..f6557c37cf 100644 --- a/examples/react/spreadsheet/src/Spreadsheet.tsx +++ b/examples/react/spreadsheet/src/Spreadsheet.tsx @@ -90,7 +90,6 @@ export function Spreadsheet() { header: column.label, size: getInitialColumnSize(column), minSize: 72, - maxSize: 420, filterFn: fieldAwareIncludesStringFilter, sortFn: column.initialType === 'number' || diff --git a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx index 4f354bb76f..614ff426ed 100644 --- a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx +++ b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx @@ -25,6 +25,7 @@ import type { GridInteractions } from './useGridInteractions' export const ROW_HEIGHT = 24 export const HEADER_HEIGHT = 26 export const ROW_HEADER_WIDTH = 42 +const CELL_HORIZONTAL_PADDING = 10 const EDGE_SCROLL_ZONE = 32 const MAX_EDGE_SCROLL_SPEED = 22 @@ -619,7 +620,11 @@ function HeaderCell({ aria-label={`Resize column ${meta?.letter}`} onDoubleClick={(event) => { event.stopPropagation() - column.resetSize() + const width = getAutoFitColumnWidth(table, column) + table.setColumnSizing((current) => ({ + ...current, + [column.id]: width, + })) }} onMouseDown={(event) => { event.stopPropagation() @@ -970,6 +975,42 @@ function formatRenderedValue(value: unknown) { return String(value) } +let textMeasurementContext: CanvasRenderingContext2D | null = null + +function measureTextWidth(value: string, bold = false) { + if (!textMeasurementContext) { + textMeasurementContext = document.createElement('canvas').getContext('2d') + } + if (!textMeasurementContext) return value.length * 6 + + textMeasurementContext.font = `${bold ? '600 ' : ''}11px Arial, sans-serif` + return textMeasurementContext.measureText(value).width +} + +function getAutoFitColumnWidth( + table: SpreadsheetTable, + column: SpreadsheetTableColumn, +) { + const columnIndex = column.columnDef.meta?.index + if (columnIndex == null) return column.getSize() + + let widest = 0 + for (const row of table.options.data) { + widest = Math.max( + widest, + measureTextWidth( + formatRenderedValue(row.cells[columnIndex]), + row.kind === 'field-header', + ), + ) + } + + return Math.max( + column.columnDef.minSize ?? 0, + Math.ceil(widest + CELL_HORIZONTAL_PADDING + 2), + ) +} + function edgeDelta(value: number, start: number, end: number, zone: number) { if (value < start + zone) { const ratio = Math.min(1, Math.max(0, (start + zone - value) / zone)) diff --git a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts index eb52aaa5fc..13bd49d530 100644 --- a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts +++ b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts @@ -4,6 +4,7 @@ import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampl import type { Locator, Page } from '@playwright/test' const exampleDir = path.resolve() +const ROW_HEIGHT_FOR_TEST = 24 function collectPageErrors(page: Page) { const errors: Array = [] @@ -318,6 +319,37 @@ test('resizes a column without breaking the virtualized layout', async ({ } }) +test('auto-fits a column beyond the default width cap on resize double-click', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 1, 0) + const row = target.locator('..') + const longValue = 'W'.repeat(80) + + await replaceCellValue(target, longValue) + await expect(target).toHaveText(longValue) + await expect(target.locator('.cell-value')).toHaveCSS( + 'white-space', + 'nowrap', + ) + await expect(row).toHaveCSS('height', `${ROW_HEIGHT_FOR_TEST}px`) + + const widthBefore = (await target.boundingBox())?.width ?? 0 + await page.getByRole('separator', { name: 'Resize column A' }).dblclick() + + await expect + .poll(async () => (await target.boundingBox())?.width ?? 0) + .toBeGreaterThan(Math.max(420, widthBefore)) + await expect(row).toHaveCSS('height', `${ROW_HEIGHT_FOR_TEST}px`) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + test('provides Excel-style ribbon and cell context actions', async ({ page, }) => {