interactions.selectColumn(column.id, event)}
+ >
+
{meta?.letter}
+
+
{
+ event.stopPropagation()
+ const width = getAutoFitColumnWidth(table, column)
+ table.setColumnSizing((current) => ({
+ ...current,
+ [column.id]: width,
+ }))
+ }}
+ 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)
+}
+
+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))
+ 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..13bd49d530
--- /dev/null
+++ b/examples/react/spreadsheet/tests/e2e/spreadsheet.spec.ts
@@ -0,0 +1,418 @@
+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()
+const ROW_HEIGHT_FOR_TEST = 24
+
+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('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,
+}) => {
+ 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':