From 10e4ef277f32f8f3aebbcfd2d1a7235088c43aae Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sat, 1 Aug 2026 07:41:26 -0500 Subject: [PATCH 1/2] feat: support spreadsheet header drag selection --- .../react/spreadsheet/src/SpreadsheetGrid.tsx | 181 +++++++++++++++--- .../spreadsheet/src/useGridInteractions.ts | 78 ++++---- .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 103 ++++++++++ 3 files changed, 289 insertions(+), 73 deletions(-) diff --git a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx index 614ff426ed..f871964331 100644 --- a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx +++ b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx @@ -5,6 +5,7 @@ import { ColumnMenu } from './ColumnMenu' import { getFillPreview } from './spreadsheetModel' import type { CellSelectionBounds, + CellSelectionRangeOperation, CellSelectionState, } from '@tanstack/react-table' import type { VirtualItem } from '@tanstack/react-virtual' @@ -55,6 +56,13 @@ interface FillDrag { preview: FillPreview | null } +interface HeaderSelectionDrag { + axis: 'column' | 'row' + anchorId: string + baseSelection: CellSelectionState + operation: CellSelectionRangeOperation +} + export const SpreadsheetGrid = React.forwardRef< SpreadsheetGridHandle, SpreadsheetGridProps @@ -139,6 +147,7 @@ export const SpreadsheetGrid = React.forwardRef< ) const [fillPreview, setFillPreview] = React.useState(null) const fillDragRef = React.useRef(null) + const headerSelectionDragRef = React.useRef(null) const pointerRef = React.useRef({ clientX: 0, clientY: 0 }) const scrollFrameRef = React.useRef(null) @@ -220,6 +229,27 @@ export const SpreadsheetGrid = React.forwardRef< ], ) + const applyHeaderSelectionDrag = React.useCallback( + (drag: HeaderSelectionDrag, focusId: string) => { + if (drag.axis === 'column') { + interactions.selectColumnRange( + drag.anchorId, + focusId, + drag.baseSelection, + drag.operation, + ) + } else { + interactions.selectRowRange( + drag.anchorId, + focusId, + drag.baseSelection, + drag.operation, + ) + } + }, + [interactions], + ) + const updateDragTarget = React.useCallback( (event: MouseEvent) => { const coordinate = resolveCoordinate(event.clientX, event.clientY) @@ -233,17 +263,33 @@ export const SpreadsheetGrid = React.forwardRef< return } + const headerDrag = headerSelectionDragRef.current + if (headerDrag) { + const focusId = + headerDrag.axis === 'column' + ? getDisplayColumns()[coordinate.columnIndex]?.id + : table.getRowsInDisplayOrder()[coordinate.rowIndex]?.id + if (focusId) applyHeaderSelectionDrag(headerDrag, focusId) + 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], + [applyHeaderSelectionDrag, getDisplayColumns, resolveCoordinate, table], ) const runEdgeScroll = React.useCallback(() => { scrollFrameRef.current = null - if (!fillDragRef.current && !table._isSelectingCells) return + if ( + !fillDragRef.current && + !headerSelectionDragRef.current && + !table._isSelectingCells + ) { + return + } const element = scrollRef.current if (!element) return @@ -253,18 +299,15 @@ export const SpreadsheetGrid = React.forwardRef< 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, - ) + const headerDrag = headerSelectionDragRef.current + const deltaX = + headerDrag?.axis === 'row' + ? 0 + : edgeDelta(clientX, leftBoundary, rightBoundary, EDGE_SCROLL_ZONE) + const deltaY = + headerDrag?.axis === 'column' + ? 0 + : edgeDelta(clientY, topBoundary, rect.bottom, EDGE_SCROLL_ZONE) if (deltaX || deltaY) { element.scrollLeft += deltaX @@ -286,13 +329,63 @@ export const SpreadsheetGrid = React.forwardRef< } }, [runEdgeScroll]) + const startHeaderSelection = React.useCallback( + ( + event: React.MouseEvent, + axis: HeaderSelectionDrag['axis'], + id: string, + fullySelected: boolean, + ) => { + if (event.button !== 0) return + event.preventDefault() + scrollRef.current?.focus({ preventScroll: true }) + + const currentSelection = table.atoms.cellSelection.get() + const activeOperation = currentSelection.at(-1) + let anchorId = id + let baseSelection: CellSelectionState = [] + let operation: CellSelectionRangeOperation = 'include' + + if (event.shiftKey && activeOperation) { + anchorId = + axis === 'column' + ? activeOperation.anchorColumnId + : activeOperation.anchorRowId + baseSelection = currentSelection.slice(0, -1) + operation = activeOperation.operation ?? 'include' + } else if (event.metaKey || event.ctrlKey) { + baseSelection = currentSelection + operation = fullySelected ? 'exclude' : 'include' + } + + const drag = { axis, anchorId, baseSelection, operation } + headerSelectionDragRef.current = drag + pointerRef.current = { clientX: event.clientX, clientY: event.clientY } + applyHeaderSelectionDrag(drag, id) + ensureEdgeScroll() + }, + [applyHeaderSelectionDrag, ensureEdgeScroll, table], + ) + + const extendHeaderSelection = React.useCallback( + (axis: HeaderSelectionDrag['axis'], id: string) => { + const drag = headerSelectionDragRef.current + if (drag?.axis === axis) applyHeaderSelectionDrag(drag, id) + }, + [applyHeaderSelectionDrag], + ) + React.useEffect(() => { const handleMouseMove = (event: MouseEvent) => { pointerRef.current = { clientX: event.clientX, clientY: event.clientY, } - if (fillDragRef.current || table._isSelectingCells) { + if ( + fillDragRef.current || + headerSelectionDragRef.current || + table._isSelectingCells + ) { updateDragTarget(event) ensureEdgeScroll() } @@ -304,6 +397,7 @@ export const SpreadsheetGrid = React.forwardRef< interactions.applyFill(fillDrag.source, fillDrag.preview) } fillDragRef.current = null + headerSelectionDragRef.current = null setFillPreview(null) if (scrollFrameRef.current != null) { cancelAnimationFrame(scrollFrameRef.current) @@ -380,7 +474,8 @@ export const SpreadsheetGrid = React.forwardRef< centerHeaders={table.getCenterLeafHeaders()} startHeaders={table.getStartLeafHeaders()} endHeaders={table.getEndLeafHeaders()} - interactions={interactions} + onStartSelection={startHeaderSelection} + onExtendSelection={extendHeaderSelection} onOpenMenu={(column, rect) => setOpenMenu({ column, rect })} /> )} @@ -400,6 +495,8 @@ export const SpreadsheetGrid = React.forwardRef< table={table} virtualColumns={virtualColumns} interactions={interactions} + onStartHeaderSelection={startHeaderSelection} + onExtendHeaderSelection={extendHeaderSelection} fillPreview={fillPreview} onStartFill={startFillDrag} onOpenContextMenu={(x, y, column) => { @@ -422,6 +519,8 @@ export const SpreadsheetGrid = React.forwardRef< table={table} virtualColumns={virtualColumns} interactions={interactions} + onStartHeaderSelection={startHeaderSelection} + onExtendHeaderSelection={extendHeaderSelection} fillPreview={fillPreview} onStartFill={startFillDrag} onOpenContextMenu={(x, y, column) => { @@ -462,7 +561,13 @@ interface HeaderRowProps { centerHeaders: Array startHeaders: Array endHeaders: Array - interactions: GridInteractions + onStartSelection: ( + event: React.MouseEvent, + axis: 'column', + id: string, + fullySelected: boolean, + ) => void + onExtendSelection: (axis: 'column', id: string) => void onOpenMenu: (column: SpreadsheetTableColumn, rect: DOMRect) => void } @@ -472,7 +577,8 @@ function HeaderRow({ centerHeaders, startHeaders, endHeaders, - interactions, + onStartSelection, + onExtendSelection, onOpenMenu, }: HeaderRowProps) { const bounds = table.getCellSelectionBounds() @@ -500,7 +606,8 @@ function HeaderRow({ table={table} bounds={bounds} rowCount={rowCount} - interactions={interactions} + onStartSelection={onStartSelection} + onExtendSelection={onExtendSelection} onOpenMenu={onOpenMenu} /> ))} @@ -514,7 +621,8 @@ function HeaderRow({ table={table} bounds={bounds} rowCount={rowCount} - interactions={interactions} + onStartSelection={onStartSelection} + onExtendSelection={onExtendSelection} onOpenMenu={onOpenMenu} /> ) @@ -527,7 +635,8 @@ function HeaderRow({ table={table} bounds={bounds} rowCount={rowCount} - interactions={interactions} + onStartSelection={onStartSelection} + onExtendSelection={onExtendSelection} onOpenMenu={onOpenMenu} /> ))} @@ -540,7 +649,8 @@ interface HeaderCellProps { table: SpreadsheetTable bounds: Array rowCount: number - interactions: GridInteractions + onStartSelection: HeaderRowProps['onStartSelection'] + onExtendSelection: HeaderRowProps['onExtendSelection'] left?: number pinned?: 'start' | 'end' onOpenMenu: (column: SpreadsheetTableColumn, rect: DOMRect) => void @@ -551,7 +661,8 @@ function HeaderCell({ table, bounds, rowCount, - interactions, + onStartSelection, + onExtendSelection, left, pinned, onOpenMenu, @@ -583,7 +694,10 @@ function HeaderCell({ aria-colindex={columnIndex + 1} aria-selected={fullySelected} style={style} - onClick={(event) => interactions.selectColumn(column.id, event)} + onMouseDown={(event) => + onStartSelection(event, 'column', column.id, fullySelected) + } + onMouseEnter={() => onExtendSelection('column', column.id)} > {meta?.letter} @@ -850,7 +976,7 @@ function SpreadsheetCell({ tabIndex={isEditing ? -1 : cell.getTabIndex()} style={getColumnPositionStyle(cell.column, left, pinned)} onMouseDown={(event) => { - if (isEditing) return + if (isEditing || event.button !== 0) return const grid = event.currentTarget.closest('.spreadsheet-grid') grid?.focus({ preventScroll: true }) @@ -862,9 +988,6 @@ function SpreadsheetCell({ } onContextMenu={(event) => { event.preventDefault() - if (!cell.getIsSelected()) { - table.setFocusedCell(cell.row.id, cell.column.id) - } event.currentTarget .closest('.spreadsheet-grid') ?.focus({ preventScroll: true }) diff --git a/examples/react/spreadsheet/src/useGridInteractions.ts b/examples/react/spreadsheet/src/useGridInteractions.ts index c3bcb90c2b..881127222a 100644 --- a/examples/react/spreadsheet/src/useGridInteractions.ts +++ b/examples/react/spreadsheet/src/useGridInteractions.ts @@ -10,6 +10,8 @@ import { import type { CellSelectionBounds, CellSelectionDirection, + CellSelectionRangeOperation, + CellSelectionState, } from '@tanstack/react-table' import type { CellPatch, @@ -380,66 +382,54 @@ export function useGridInteractions(options: GridInteractionOptions) { [execute, getDisplayColumns, getDisplayRows, getValue, selectBounds], ) - const selectColumn = React.useCallback( - (columnId: string, event: React.MouseEvent) => { + const selectColumnRange = React.useCallback( + ( + anchorColumnId: string, + focusColumnId: string, + baseSelection: CellSelectionState, + operation: CellSelectionRangeOperation, + ) => { 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) - } - } + if (!displayRows.length || !displayColumns.length) return - table.selectCellRange( + table.setCellSelection([ + ...baseSelection, { anchorRowId: displayRows[0].id, focusRowId: displayRows[displayRows.length - 1].id, - anchorColumnId: displayColumns[minColumnIndex].id, - focusColumnId: displayColumns[maxColumnIndex].id, + anchorColumnId, + focusColumnId, + operation, }, - { additive: event.metaKey || event.ctrlKey }, - ) + ]) }, - [getDisplayColumns, getDisplayRows, getSelectedBounds, table], + [getDisplayColumns, getDisplayRows, table], ) - const selectRow = React.useCallback( - (rowId: string, event: React.MouseEvent) => { + const selectRowRange = React.useCallback( + ( + anchorRowId: string, + focusRowId: string, + baseSelection: CellSelectionState, + operation: CellSelectionRangeOperation, + ) => { 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) - } - } + if (!displayRows.length || !displayColumns.length) return - table.selectCellRange( + table.setCellSelection([ + ...baseSelection, { - anchorRowId: displayRows[minRowIndex].id, - focusRowId: displayRows[maxRowIndex].id, + anchorRowId, + focusRowId, anchorColumnId: displayColumns[0].id, focusColumnId: displayColumns[displayColumns.length - 1].id, + operation, }, - { additive: event.metaKey || event.ctrlKey }, - ) + ]) }, - [getDisplayColumns, getDisplayRows, getSelectedBounds, table], + [getDisplayColumns, getDisplayRows, table], ) const handleGridKeyDown = React.useCallback( @@ -610,8 +600,8 @@ export function useGridInteractions(options: GridInteractionOptions) { pasteFromClipboard, applyFill, selectBounds, - selectColumn, - selectRow, + selectColumnRange, + selectRowRange, handleGridKeyDown, handleEditorKeyDown, } diff --git a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts index 1a799a6666..c7bf4c153c 100644 --- a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts +++ b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts @@ -96,6 +96,108 @@ test('subtracts cells from a selection with Ctrl/Cmd', async ({ page }) => { } }) +test('keeps selection unchanged when opening a cell context menu', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const start = cell(page, 1, 0) + const end = cell(page, 2, 1) + const contextTarget = cell(page, 4, 3) + + await start.click() + await end.click({ modifiers: ['Shift'] }) + await expect(page.locator('[data-sheet-cell].cell-selected')).toHaveCount(4) + + await contextTarget.click({ button: 'right' }) + + await expect(page.getByRole('menu', { name: 'Cell actions' })).toBeVisible() + await expect(page.locator('[data-sheet-cell].cell-selected')).toHaveCount(4) + await expect(contextTarget).not.toHaveClass(/cell-selected|cell-focused/) + await expect(start).toHaveClass(/cell-focused/) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('selects ranges by dragging across column and row headers', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const columnA = page.getByRole('columnheader', { name: /^A/ }) + const columnC = page.getByRole('columnheader', { name: /^C/ }) + const columnABox = await columnA.boundingBox() + const columnCBox = await columnC.boundingBox() + if (!columnABox || !columnCBox) + throw new Error('Column header drag bounds unavailable') + + await page.mouse.move( + columnABox.x + columnABox.width / 2, + columnABox.y + columnABox.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + columnCBox.x + columnCBox.width / 2, + columnCBox.y + columnCBox.height / 2, + { steps: 5 }, + ) + await page.mouse.up() + + for (const letter of ['A', 'B', 'C']) { + await expect( + page.getByRole('columnheader', { name: new RegExp(`^${letter}`) }), + ).toHaveAttribute('aria-selected', 'true') + } + await expect( + page.getByRole('columnheader', { name: /^D/ }), + ).toHaveAttribute('aria-selected', 'false') + + const row2 = page.getByRole('button', { + name: 'Select row 2', + exact: true, + }) + const row5 = page.getByRole('button', { + name: 'Select row 5', + exact: true, + }) + const row2Box = await row2.boundingBox() + const row5Box = await row5.boundingBox() + if (!row2Box || !row5Box) + throw new Error('Row header drag bounds unavailable') + + await page.mouse.move( + row2Box.x + row2Box.width / 2, + row2Box.y + row2Box.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + row5Box.x + row5Box.width / 2, + row5Box.y + row5Box.height / 2, + { steps: 5 }, + ) + await page.mouse.up() + + for (const rowNumber of [2, 3, 4, 5]) { + await expect( + page.getByRole('button', { + name: `Select row ${rowNumber}`, + exact: true, + }), + ).toHaveAttribute('aria-selected', 'true') + } + await expect( + page.getByRole('button', { name: 'Select row 6', exact: true }), + ).toHaveAttribute('aria-selected', 'false') + 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) @@ -386,6 +488,7 @@ test('provides Excel-style ribbon and cell context actions', async ({ ).not.toHaveCount(0) const target = cell(page, 1, 0) + await target.click() await target.click({ button: 'right' }) const menu = page.getByRole('menu', { name: 'Cell actions' }) await expect(menu).toBeVisible() From 77567a93ee2c6ba5fd48f4d7d7ca9f77569d487d Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Sat, 1 Aug 2026 08:43:48 -0500 Subject: [PATCH 2/2] port spreadsheet examples to svelte and solid --- docs/config.json | 2 + examples/react/spreadsheet/package.json | 1 + .../react/spreadsheet/src/SpreadsheetGrid.tsx | 64 +- .../spreadsheet/src/useGridInteractions.ts | 81 +- .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 111 ++ examples/solid/spreadsheet/index.html | 12 + examples/solid/spreadsheet/package.json | 26 + .../solid/spreadsheet/src/CellContextMenu.tsx | 105 ++ examples/solid/spreadsheet/src/ColumnMenu.tsx | 99 ++ .../solid/spreadsheet/src/Spreadsheet.tsx | 652 +++++++++ .../solid/spreadsheet/src/SpreadsheetGrid.tsx | 1208 +++++++++++++++++ .../spreadsheet/src/createGridInteractions.ts | 560 ++++++++ .../src/createSpreadsheetHistory.ts | 110 ++ examples/solid/spreadsheet/src/index.css | 867 ++++++++++++ examples/solid/spreadsheet/src/index.tsx | 12 + .../solid/spreadsheet/src/spreadsheetModel.ts | 521 +++++++ .../solid/spreadsheet/src/spreadsheetTable.ts | 106 ++ examples/solid/spreadsheet/src/vite-env.d.ts | 1 + .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 655 +++++++++ examples/solid/spreadsheet/tsconfig.json | 21 + examples/solid/spreadsheet/vite.config.ts | 8 + examples/svelte/spreadsheet/index.html | 12 + examples/svelte/spreadsheet/package.json | 27 + examples/svelte/spreadsheet/src/App.svelte | 92 ++ .../spreadsheet/src/CellContextMenu.svelte | 39 + .../svelte/spreadsheet/src/ColumnMenu.svelte | 24 + .../spreadsheet/src/SpreadsheetGrid.svelte | 220 +++ .../src/createGridInteractions.svelte.ts | 575 ++++++++ examples/svelte/spreadsheet/src/index.css | 867 ++++++++++++ examples/svelte/spreadsheet/src/main.ts | 7 + .../src/spreadsheetHistory.svelte.ts | 105 ++ .../spreadsheet/src/spreadsheetModel.ts | 521 +++++++ .../spreadsheet/src/spreadsheetTable.ts | 106 ++ examples/svelte/spreadsheet/src/vite-env.d.ts | 1 + examples/svelte/spreadsheet/svelte.config.js | 3 + .../spreadsheet/tests/e2e/spreadsheet.spec.ts | 655 +++++++++ examples/svelte/spreadsheet/tsconfig.json | 21 + examples/svelte/spreadsheet/vite.config.js | 17 + pnpm-lock.yaml | 67 + 39 files changed, 8511 insertions(+), 70 deletions(-) create mode 100644 examples/solid/spreadsheet/index.html create mode 100644 examples/solid/spreadsheet/package.json create mode 100644 examples/solid/spreadsheet/src/CellContextMenu.tsx create mode 100644 examples/solid/spreadsheet/src/ColumnMenu.tsx create mode 100644 examples/solid/spreadsheet/src/Spreadsheet.tsx create mode 100644 examples/solid/spreadsheet/src/SpreadsheetGrid.tsx create mode 100644 examples/solid/spreadsheet/src/createGridInteractions.ts create mode 100644 examples/solid/spreadsheet/src/createSpreadsheetHistory.ts create mode 100644 examples/solid/spreadsheet/src/index.css create mode 100644 examples/solid/spreadsheet/src/index.tsx create mode 100644 examples/solid/spreadsheet/src/spreadsheetModel.ts create mode 100644 examples/solid/spreadsheet/src/spreadsheetTable.ts create mode 100644 examples/solid/spreadsheet/src/vite-env.d.ts create mode 100644 examples/solid/spreadsheet/tests/e2e/spreadsheet.spec.ts create mode 100644 examples/solid/spreadsheet/tsconfig.json create mode 100644 examples/solid/spreadsheet/vite.config.ts create mode 100644 examples/svelte/spreadsheet/index.html create mode 100644 examples/svelte/spreadsheet/package.json create mode 100644 examples/svelte/spreadsheet/src/App.svelte create mode 100644 examples/svelte/spreadsheet/src/CellContextMenu.svelte create mode 100644 examples/svelte/spreadsheet/src/ColumnMenu.svelte create mode 100644 examples/svelte/spreadsheet/src/SpreadsheetGrid.svelte create mode 100644 examples/svelte/spreadsheet/src/createGridInteractions.svelte.ts create mode 100644 examples/svelte/spreadsheet/src/index.css create mode 100644 examples/svelte/spreadsheet/src/main.ts create mode 100644 examples/svelte/spreadsheet/src/spreadsheetHistory.svelte.ts create mode 100644 examples/svelte/spreadsheet/src/spreadsheetModel.ts create mode 100644 examples/svelte/spreadsheet/src/spreadsheetTable.ts create mode 100644 examples/svelte/spreadsheet/src/vite-env.d.ts create mode 100644 examples/svelte/spreadsheet/svelte.config.js create mode 100644 examples/svelte/spreadsheet/tests/e2e/spreadsheet.spec.ts create mode 100644 examples/svelte/spreadsheet/tsconfig.json create mode 100644 examples/svelte/spreadsheet/vite.config.js diff --git a/docs/config.json b/docs/config.json index ae593d0f0b..4c0c53c003 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1509,6 +1509,7 @@ "label": "solid", "children": [ { "label": "Composable Tables (createTableHook)", "to": "framework/solid/examples/composable-tables" }, + { "label": "Experimental Spreadsheet", "to": "framework/solid/examples/spreadsheet" }, { "label": "With TanStack Virtual - Columns", "to": "framework/solid/examples/virtualized-columns" }, { "label": "With TanStack Virtual - Rows", "to": "framework/solid/examples/virtualized-rows" }, { "label": "With TanStack Virtual - Infinite Scrolling", "to": "framework/solid/examples/virtualized-infinite-scrolling" }, @@ -1521,6 +1522,7 @@ "label": "svelte", "children": [ { "label": "Composable Tables (createTableHook)", "to": "framework/svelte/examples/composable-tables" }, + { "label": "Experimental Spreadsheet", "to": "framework/svelte/examples/spreadsheet" }, { "label": "With TanStack Virtual - Columns", "to": "framework/svelte/examples/virtualized-columns" }, { "label": "With TanStack Virtual - Rows", "to": "framework/svelte/examples/virtualized-rows" }, { "label": "With TanStack Virtual - Infinite Scrolling", "to": "framework/svelte/examples/virtualized-infinite-scrolling" }, diff --git a/examples/react/spreadsheet/package.json b/examples/react/spreadsheet/package.json index 9f86104221..7f8f87573e 100644 --- a/examples/react/spreadsheet/package.json +++ b/examples/react/spreadsheet/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@faker-js/faker": "^10.5.0", + "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^9.0.0-beta.65", "@tanstack/react-virtual": "^3.14.9", diff --git a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx index f871964331..838d78a422 100644 --- a/examples/react/spreadsheet/src/SpreadsheetGrid.tsx +++ b/examples/react/spreadsheet/src/SpreadsheetGrid.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useHotkeys } from '@tanstack/react-hotkeys' import { useVirtualizer } from '@tanstack/react-virtual' import { CellContextMenu } from './CellContextMenu' import { ColumnMenu } from './ColumnMenu' @@ -68,6 +69,67 @@ export const SpreadsheetGrid = React.forwardRef< SpreadsheetGridProps >(function SpreadsheetGrid({ table, interactions, zoom }, forwardedRef) { const scrollRef = React.useRef(null) + + useHotkeys( + [ + { hotkey: 'ArrowUp', callback: () => interactions.moveSelection('up') }, + { + hotkey: 'ArrowDown', + callback: () => interactions.moveSelection('down'), + }, + { + hotkey: 'ArrowLeft', + callback: () => interactions.moveSelection('left'), + }, + { + hotkey: 'ArrowRight', + callback: () => interactions.moveSelection('right'), + }, + { + hotkey: 'Shift+ArrowUp', + callback: () => interactions.moveSelection('up', true), + }, + { + hotkey: 'Shift+ArrowDown', + callback: () => interactions.moveSelection('down', true), + }, + { + hotkey: 'Shift+ArrowLeft', + callback: () => interactions.moveSelection('left', true), + }, + { + hotkey: 'Shift+ArrowRight', + callback: () => interactions.moveSelection('right', true), + }, + { hotkey: 'Tab', callback: () => interactions.moveSelection('right') }, + { + hotkey: 'Shift+Tab', + callback: () => interactions.moveSelection('left'), + }, + { hotkey: 'Enter', callback: () => interactions.moveSelection('down') }, + { + hotkey: 'Shift+Enter', + callback: () => interactions.moveSelection('up'), + }, + { hotkey: 'F2', callback: interactions.startEditingActive }, + { hotkey: 'Delete', callback: interactions.clearSelection }, + { hotkey: 'Backspace', callback: interactions.clearSelection }, + { + hotkey: 'Escape', + callback: () => table.resetCellSelection(true), + }, + { hotkey: 'Mod+A', callback: () => table.selectAllCells() }, + { hotkey: 'Mod+Z', callback: interactions.undo }, + { hotkey: 'Mod+Shift+Z', callback: interactions.redo }, + { hotkey: 'Mod+Y', callback: interactions.redo }, + ], + { + target: scrollRef, + enabled: interactions.editing == null, + preventDefault: true, + stopPropagation: true, + }, + ) const startColumns = table.getStartVisibleLeafColumns() const centerColumns = table.getCenterVisibleLeafColumns() const endColumns = table.getEndVisibleLeafColumns() @@ -447,7 +509,7 @@ export const SpreadsheetGrid = React.forwardRef< tabIndex={0} aria-rowcount={table.getRowsInDisplayOrder().length} aria-colcount={getDisplayColumns().length} - onKeyDown={interactions.handleGridKeyDown} + onKeyDown={interactions.handleGridTextEntry} onCopy={interactions.copySelection} onCut={interactions.cutSelection} onPaste={interactions.pasteSelection} diff --git a/examples/react/spreadsheet/src/useGridInteractions.ts b/examples/react/spreadsheet/src/useGridInteractions.ts index 881127222a..d2d8074dee 100644 --- a/examples/react/spreadsheet/src/useGridInteractions.ts +++ b/examples/react/spreadsheet/src/useGridInteractions.ts @@ -432,68 +432,16 @@ export function useGridInteractions(options: GridInteractionOptions) { [getDisplayColumns, getDisplayRows, table], ) - const handleGridKeyDown = React.useCallback( + const startEditingActive = React.useCallback(() => { + const active = getActiveRange() + if (active) startEditing(active.anchorRowId, active.anchorColumnId) + }, [getActiveRange, startEditing]) + + const handleGridTextEntry = 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 && @@ -507,16 +455,7 @@ export function useGridInteractions(options: GridInteractionOptions) { } } }, - [ - clearSelection, - editing, - getActiveRange, - moveSelection, - redo, - startEditing, - table, - undo, - ], + [editing, getActiveRange, startEditing], ) const handleEditorKeyDown = React.useCallback( @@ -588,6 +527,8 @@ export function useGridInteractions(options: GridInteractionOptions) { getRangeLabel, getSelectionSummary, startEditing, + startEditingActive, + moveSelection, commitCellValue, commitEditing, cancelEditing, @@ -602,8 +543,10 @@ export function useGridInteractions(options: GridInteractionOptions) { selectBounds, selectColumnRange, selectRowRange, - handleGridKeyDown, + handleGridTextEntry, handleEditorKeyDown, + undo, + redo, } } diff --git a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts index c7bf4c153c..18ddce86cc 100644 --- a/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts +++ b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts @@ -507,6 +507,117 @@ test('provides Excel-style ribbon and cell context actions', async ({ } }) +test('uses scoped hotkeys for grid navigation and range extension', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + await cell(page, 1, 1).click() + await page.keyboard.press('ArrowRight') + await expect(cell(page, 1, 2)).toHaveClass(/cell-focused/) + + await page.keyboard.press('Shift+ArrowDown') + await expect(cell(page, 1, 2)).toHaveClass(/cell-selected/) + await expect(cell(page, 2, 2)).toHaveClass(/cell-selected|cell-focused/) + + await page.keyboard.press('Tab') + await expect(cell(page, 1, 3)).toHaveClass(/cell-focused/) + await page.keyboard.press('Enter') + await expect(cell(page, 2, 3)).toHaveClass(/cell-focused/) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('uses hotkeys for editing, clearing, history, and select all', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 1, 0) + const original = await target.textContent() + + await target.click() + await page.keyboard.press('F2') + const editor = target.getByRole('textbox') + await expect(editor).toBeVisible() + await editor.fill('Hotkey edit') + await editor.press('Enter') + await expect(target).toHaveText('Hotkey edit') + + await target.click() + await page.keyboard.press('Backspace') + await expect(target).toHaveText('') + await page.keyboard.press('ControlOrMeta+z') + await expect(target).toHaveText('Hotkey edit') + await page.keyboard.press('ControlOrMeta+Shift+z') + await expect(target).toHaveText('') + await page.keyboard.press('ControlOrMeta+z') + await expect(target).toHaveText('Hotkey edit') + await page.keyboard.press('ControlOrMeta+y') + await expect(target).toHaveText('') + + await cell(page, 1, 1).click() + await page.keyboard.press('x') + const replacementEditor = cell(page, 1, 1).getByRole('textbox') + await expect(replacementEditor).toHaveValue('x') + await replacementEditor.press('Escape') + + await target.click() + await page.keyboard.press('ControlOrMeta+a') + await expect(page.getByText('200,000 selected')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByText('0 selected')).toBeVisible() + + expect(original).not.toBeNull() + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + +test('keeps grid hotkeys out of editors and preserves native clipboard events', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const target = cell(page, 1, 0) + await target.click() + + const valueBar = page.getByRole('textbox', { name: 'Cell value' }) + await valueBar.fill('Formula input') + await valueBar.press('ArrowLeft') + await expect(valueBar).toHaveValue('Formula input') + + await target.dblclick() + const editor = target.getByRole('textbox') + await editor.fill('Cell editor') + await editor.press('ArrowLeft') + await expect(editor).toHaveValue('Cell editor') + await editor.press('Escape') + + const copied = await page.getByRole('grid').evaluate((grid) => { + const data = new DataTransfer() + grid.dispatchEvent( + new ClipboardEvent('copy', { + bubbles: true, + cancelable: true, + clipboardData: data, + }), + ) + return data.getData('text/plain') + }) + expect(copied.length).toBeGreaterThan(0) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) + test('keeps the 10k × 250 stress grid interactive', async ({ page }) => { const { errors, server } = await openExample(page) diff --git a/examples/solid/spreadsheet/index.html b/examples/solid/spreadsheet/index.html new file mode 100644 index 0000000000..e4f536bdfb --- /dev/null +++ b/examples/solid/spreadsheet/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Table Spreadsheet + + +
+ + + diff --git a/examples/solid/spreadsheet/package.json b/examples/solid/spreadsheet/package.json new file mode 100644 index 0000000000..4c2b47bc7c --- /dev/null +++ b/examples/solid/spreadsheet/package.json @@ -0,0 +1,26 @@ +{ + "name": "tanstack-solid-table-example-spreadsheet", + "private": true, + "type": "module", + "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 --noEmit" + }, + "dependencies": { + "@faker-js/faker": "^10.5.0", + "@tanstack/solid-hotkeys": "^0.10.0", + "@tanstack/solid-table": "^9.0.0-beta.65", + "@tanstack/solid-virtual": "^3.13.36", + "solid-js": "^1.9.14" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "^8.2.0", + "vite-plugin-solid": "^2.11.14" + } +} diff --git a/examples/solid/spreadsheet/src/CellContextMenu.tsx b/examples/solid/spreadsheet/src/CellContextMenu.tsx new file mode 100644 index 0000000000..4b6f706fee --- /dev/null +++ b/examples/solid/spreadsheet/src/CellContextMenu.tsx @@ -0,0 +1,105 @@ +import { onCleanup, onMount } from 'solid-js' +import { Portal } from 'solid-js/web' +import type { GridInteractions } from './createGridInteractions' +import type { + SpreadsheetTable, + SpreadsheetTableColumn, +} from './spreadsheetTable' + +interface Props { + x: number + y: number + column: SpreadsheetTableColumn + table: SpreadsheetTable + interactions: GridInteractions + onClose: () => void +} + +export function CellContextMenu(props: Props) { + let menuRef: HTMLDivElement | undefined + onMount(() => { + const pointer = (event: PointerEvent) => { + if (!menuRef?.contains(event.target as Node)) props.onClose() + } + const key = (event: KeyboardEvent) => { + if (event.key === 'Escape') props.onClose() + } + document.addEventListener('pointerdown', pointer) + document.addEventListener('keydown', key) + onCleanup(() => { + document.removeEventListener('pointerdown', pointer) + document.removeEventListener('keydown', key) + }) + }) + const run = (action: () => void | Promise) => { + props.onClose() + void action() + } + return ( + +