From a785748db543f2f323d4089ef94ee109dab819c6 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:55:42 -0500 Subject: [PATCH 01/20] Sort animations working --- .../angular/src/lib/SimpleTableComponent.ts | 44 +- packages/core/CELL_ANIMATIONS.md | 425 +++++++++ packages/core/src/core/SimpleTableVanilla.ts | 70 ++ .../src/core/rendering/RenderOrchestrator.ts | 8 + .../src/core/rendering/SectionRenderer.ts | 159 ++++ .../core/src/core/rendering/TableRenderer.ts | 14 + .../core/src/managers/AnimationCoordinator.ts | 432 ++++++++++ packages/core/src/types/SimpleTableConfig.ts | 3 + packages/core/src/types/SimpleTableProps.ts | 3 + packages/core/src/types/TableRow.ts | 9 + .../utils/bodyCell/editors/booleanDropdown.ts | 5 +- .../src/utils/bodyCell/editors/datePicker.ts | 14 +- .../utils/bodyCell/editors/enumDropdown.ts | 9 +- packages/core/src/utils/bodyCell/styling.ts | 168 ++-- packages/core/src/utils/bodyCell/types.ts | 6 + packages/core/src/utils/bodyCellRenderer.ts | 80 +- packages/core/src/utils/rowFlattening.ts | 40 +- packages/core/src/utils/rowUtils.ts | 76 +- .../core/src/utils/stickyParentsRenderer.ts | 1 + .../examples/sales-example/SalesExample.ts | 1 + .../tests/41-CellAnimationsTests.stories.ts | 704 +++++++++++++++ ...llAnimationsVirtualizationTests.stories.ts | 808 ++++++++++++++++++ 22 files changed, 2943 insertions(+), 136 deletions(-) create mode 100644 packages/core/CELL_ANIMATIONS.md create mode 100644 packages/core/src/managers/AnimationCoordinator.ts create mode 100644 packages/core/stories/tests/41-CellAnimationsTests.stories.ts create mode 100644 packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts diff --git a/packages/angular/src/lib/SimpleTableComponent.ts b/packages/angular/src/lib/SimpleTableComponent.ts index bcc681b20..a5d0f5773 100644 --- a/packages/angular/src/lib/SimpleTableComponent.ts +++ b/packages/angular/src/lib/SimpleTableComponent.ts @@ -89,6 +89,7 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { @Input() initialSortDirection?: SimpleTableAngularProps["initialSortDirection"]; @Input() expandAll?: SimpleTableAngularProps["expandAll"]; @Input() autoExpandColumns?: SimpleTableAngularProps["autoExpandColumns"]; + @Input() animations?: SimpleTableAngularProps["animations"]; /** Emits the TableAPI once the table has mounted. */ @Output() tableReady = new EventEmitter(); @@ -104,7 +105,7 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { this.instance = new SimpleTableVanilla( container, - buildVanillaConfig(this.getProps(), this.appRef, this.envInjector) + buildVanillaConfig(this.getProps(), this.appRef, this.envInjector), ) as unknown as TableInstance; this.instance.mount(); @@ -112,9 +113,7 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { } ngOnChanges(): void { - this.instance?.update( - buildVanillaConfig(this.getProps(), this.appRef, this.envInjector) - ); + this.instance?.update(buildVanillaConfig(this.getProps(), this.appRef, this.envInjector)); } ngOnDestroy(): void { @@ -134,21 +133,27 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { }; if (this.footerRenderer !== undefined) props.footerRenderer = this.footerRenderer; - if (this.loadingStateRenderer !== undefined) props.loadingStateRenderer = this.loadingStateRenderer; + if (this.loadingStateRenderer !== undefined) + props.loadingStateRenderer = this.loadingStateRenderer; if (this.errorStateRenderer !== undefined) props.errorStateRenderer = this.errorStateRenderer; if (this.emptyStateRenderer !== undefined) props.emptyStateRenderer = this.emptyStateRenderer; - if (this.tableEmptyStateRenderer !== undefined) props.tableEmptyStateRenderer = this.tableEmptyStateRenderer; + if (this.tableEmptyStateRenderer !== undefined) + props.tableEmptyStateRenderer = this.tableEmptyStateRenderer; if (this.headerDropdown !== undefined) props.headerDropdown = this.headerDropdown; if (this.columnEditorConfig !== undefined) props.columnEditorConfig = this.columnEditorConfig; if (this.onCellClick !== undefined) props.onCellClick = this.onCellClick; if (this.onCellEdit !== undefined) props.onCellEdit = this.onCellEdit; if (this.onSortChange !== undefined) props.onSortChange = this.onSortChange; if (this.onFilterChange !== undefined) props.onFilterChange = this.onFilterChange; - if (this.onRowSelectionChange !== undefined) props.onRowSelectionChange = this.onRowSelectionChange; + if (this.onRowSelectionChange !== undefined) + props.onRowSelectionChange = this.onRowSelectionChange; if (this.onRowGroupExpand !== undefined) props.onRowGroupExpand = this.onRowGroupExpand; - if (this.onColumnOrderChange !== undefined) props.onColumnOrderChange = this.onColumnOrderChange; - if (this.onColumnVisibilityChange !== undefined) props.onColumnVisibilityChange = this.onColumnVisibilityChange; - if (this.onColumnWidthChange !== undefined) props.onColumnWidthChange = this.onColumnWidthChange; + if (this.onColumnOrderChange !== undefined) + props.onColumnOrderChange = this.onColumnOrderChange; + if (this.onColumnVisibilityChange !== undefined) + props.onColumnVisibilityChange = this.onColumnVisibilityChange; + if (this.onColumnWidthChange !== undefined) + props.onColumnWidthChange = this.onColumnWidthChange; if (this.onPageChange !== undefined) props.onPageChange = this.onPageChange; if (this.onLoadMore !== undefined) props.onLoadMore = this.onLoadMore; if (this.onGridReady !== undefined) props.onGridReady = this.onGridReady; @@ -160,29 +165,36 @@ export class SimpleTableComponent implements OnInit, OnChanges, OnDestroy { if (this.getRowId !== undefined) props.getRowId = this.getRowId; if (this.shouldPaginate !== undefined) props.shouldPaginate = this.shouldPaginate; if (this.rowsPerPage !== undefined) props.rowsPerPage = this.rowsPerPage; - if (this.serverSidePagination !== undefined) props.serverSidePagination = this.serverSidePagination; + if (this.serverSidePagination !== undefined) + props.serverSidePagination = this.serverSidePagination; if (this.totalRowCount !== undefined) props.totalRowCount = this.totalRowCount; if (this.height !== undefined) props.height = this.height; if (this.maxHeight !== undefined) props.maxHeight = this.maxHeight; if (this.columnResizing !== undefined) props.columnResizing = this.columnResizing; if (this.columnReordering !== undefined) props.columnReordering = this.columnReordering; if (this.editColumns !== undefined) props.editColumns = this.editColumns; - if (this.editColumnsInitOpen !== undefined) props.editColumnsInitOpen = this.editColumnsInitOpen; + if (this.editColumnsInitOpen !== undefined) + props.editColumnsInitOpen = this.editColumnsInitOpen; if (this.selectableCells !== undefined) props.selectableCells = this.selectableCells; if (this.selectableColumns !== undefined) props.selectableColumns = this.selectableColumns; - if (this.enableHeaderEditing !== undefined) props.enableHeaderEditing = this.enableHeaderEditing; + if (this.enableHeaderEditing !== undefined) + props.enableHeaderEditing = this.enableHeaderEditing; if (this.onHeaderEdit !== undefined) props.onHeaderEdit = this.onHeaderEdit; if (this.customTheme !== undefined) props.customTheme = this.customTheme; if (this.icons !== undefined) props.icons = this.icons; - if (this.externalFilterHandling !== undefined) props.externalFilterHandling = this.externalFilterHandling; - if (this.externalSortHandling !== undefined) props.externalSortHandling = this.externalSortHandling; + if (this.externalFilterHandling !== undefined) + props.externalFilterHandling = this.externalFilterHandling; + if (this.externalSortHandling !== undefined) + props.externalSortHandling = this.externalSortHandling; if (this.columnBorders !== undefined) props.columnBorders = this.columnBorders; if (this.rowButtons !== undefined) props.rowButtons = this.rowButtons; if (this.hideFooter !== undefined) props.hideFooter = this.hideFooter; if (this.initialSortColumn !== undefined) props.initialSortColumn = this.initialSortColumn; - if (this.initialSortDirection !== undefined) props.initialSortDirection = this.initialSortDirection; + if (this.initialSortDirection !== undefined) + props.initialSortDirection = this.initialSortDirection; if (this.expandAll !== undefined) props.expandAll = this.expandAll; if (this.autoExpandColumns !== undefined) props.autoExpandColumns = this.autoExpandColumns; + if (this.animations !== undefined) props.animations = this.animations; return props; } diff --git a/packages/core/CELL_ANIMATIONS.md b/packages/core/CELL_ANIMATIONS.md new file mode 100644 index 000000000..81a412288 --- /dev/null +++ b/packages/core/CELL_ANIMATIONS.md @@ -0,0 +1,425 @@ +# Cell Animations — Deep Analysis + +> Status: analysis only. No code, no API, no implementation timeline. Goal is to make the trade-offs and obstacles concrete enough that an implementation can be designed against this document. + +--- + +## 1. Goal and non-goals + +### Goal + +Animate body and header cells smoothly when the table's logical state changes their position on screen. The visible result should be that a cell **slides** from its old `(left, top)` to its new `(left, top)` rather than teleporting. + +The state changes that should be animated: + +- **Sort change** (rows reorder vertically). +- **Column reorder** (columns reorder horizontally). +- **Column resize** (cells right of the resized column shift horizontally; resized cells change width). +- **Row expand / collapse** (rows below shift vertically; nested rows enter/leave). +- **Filter / quick-filter change** (rows enter/leave; surviving rows shift). +- **Pin / unpin column** (cell migrates between sections). + +### Non-goals + +- **Scrolling is not animated.** Scroll already produces motion natively; layering a transition on top would feel laggy and conflict with the existing scroll fast path (`positionOnlyBody`). +- **No animation of cell _content_.** Only the cell's geometry (position, optionally width/height) animates. Text/checkbox/expand-icon updates stay instantaneous. +- **No re-architecture of the renderer.** The mechanism must layer on top of the existing vanilla-DOM renderer, not replace it. +- **No new heavyweight dependencies** (no framer-motion, no react-spring). The core package is framework-agnostic vanilla DOM; animation must be plain CSS transitions or the Web Animations API. + +--- + +## 2. How the table renders today + +This section captures the facts the animation system must work _with_, not _against_. + +### 2.1 Vanilla DOM, not React + +The core package builds the table by directly creating `
` elements via `document.createElement`. There is no React `key` reconciliation. Cell identity is owned by the renderer through a stable string id. + +Cell creation lives in [`packages/core/src/utils/bodyCell/styling.ts`](packages/core/src/utils/bodyCell/styling.ts) (`createBodyCellElement`) and [`packages/core/src/utils/headerCell/styling.ts`](packages/core/src/utils/headerCell/styling.ts) (`createHeaderCellElement`). + +### 2.2 Stable cell identity + +Each body cell's DOM `id` is computed by `getCellId({ accessor, rowId })` in [`packages/core/src/utils/cellUtils.ts`](packages/core/src/utils/cellUtils.ts) (L6–8): + +```ts +export const getCellId = ({ accessor, rowId }) => `${rowId}-${accessor}`; +``` + +Critically: **the same DOM node survives sort and column reorder** as long as both its row id and its accessor are still in the visible window. Only `style.left` and `style.top` change. This is the cornerstone fact that makes a FLIP-style animation viable here. + +### 2.3 Positioning: absolute px, no flex/grid + +Body cells: + +```56:60:packages/core/src/utils/bodyCell/styling.ts + cellElement.style.position = "absolute"; + cellElement.style.left = `${cell.left}px`; + cellElement.style.top = `${cell.top}px`; + cellElement.style.width = `${cell.width}px`; + cellElement.style.height = `${cell.height}px`; +``` + +(see `createBodyCellElement` ~L195–200; `updateBodyCellPosition` ~L411–418; `updateBodyCellElement` ~L437–441.) + +Header cells use the same scheme (`packages/core/src/utils/headerCell/styling.ts` L145–147 and L283–284). + +The implication for animation: **the cell is already a "free" absolutely-positioned element**. We can layer a `transform` on top of it without disturbing the layout calculation. We do **not** need to introduce a portal/ghost container. + +### 2.4 Three physical sections + +`renderBody` in [`packages/core/src/core/rendering/TableRenderer.ts`](packages/core/src/core/rendering/TableRenderer.ts) (L521–588) builds three independent DOM containers: + +- pinned-left +- main (the only horizontally scrolling one) +- pinned-right + +Each section has its own `startColIndex`. The pinned sections **always render every column they own** (no horizontal culling); only the main section is horizontally virtualized. + +### 2.5 Vertical virtualization (rows) + +`getViewportCalculations` in [`packages/core/src/utils/infiniteScrollUtils.ts`](packages/core/src/utils/infiniteScrollUtils.ts) (~L212+) computes a row index window from `scrollTop`, viewport height, row heights, and an overscan. It is consumed by `processRows` / `recomputeProcessRowsViewport` in [`packages/core/src/utils/rowProcessing.ts`](packages/core/src/utils/rowProcessing.ts) (L152–169, L254–265). + +If `contentHeight` is undefined the renderer treats _all_ rows as in-window (rowProcessing.ts L152–156). This is rare in practice for any non-trivial dataset. + +### 2.6 Horizontal virtualization (main only) + +`getVisibleBodyCells` in [`packages/core/src/utils/bodyCellRenderer.ts`](packages/core/src/utils/bodyCellRenderer.ts) (~L52–69) filters the AbsoluteBodyCell list by `scrollLeft`, viewport width, and ~100 px overscan. + +Cells outside the visible set are removed from the DOM in the removal pass (`bodyCellRenderer.ts` L283–294): + +```283:294:packages/core/src/utils/bodyCellRenderer.ts + // Remove cells that are no longer visible + renderedCells.forEach((element, cellId) => { + if (!visibleCellIds.has(cellId)) { + // Untrack from row hover map before removing (stable row id; visual row index can change on scroll) + const rowIdAttr = element.getAttribute("data-row-id"); + if (rowIdAttr) { + untrackCellByRow(rowIdAttr, element); + } + element.remove(); + renderedCells.delete(cellId); + } + }); +``` + +**This is the single most important obstacle to animation.** Anything we want to animate must either (a) still be in `visibleCellIds`, or (b) be exempted from this removal until the animation finishes. + +### 2.7 Sort flow + +`SortManager.updateSort` in [`packages/core/src/managers/SortManager.ts`](packages/core/src/managers/SortManager.ts) (L161–227) computes a new `sortedRows`, stores it in state, calls `onSortChange`, and `notifySubscribers`. The `RenderOrchestrator` reads `getSortedRows()` as `effectiveRows` (`RenderOrchestrator.ts` L266–270), flattens it, and produces `AbsoluteBodyCell` records whose `top` is derived from `tableRow.position` via `calculateRowTopPosition`. + +End result for a surviving cell: same DOM node, new `top`. `updateBodyCellElement` is called and writes the new `top` (`bodyCell/styling.ts` L437–441). The cell teleports. + +### 2.8 Column reorder flow + +Drag handlers in [`packages/core/src/utils/headerCell/dragging.ts`](packages/core/src/utils/headerCell/dragging.ts) build a new `headers` array and call `context.onTableHeaderDragEnd(newHeaders)` (L260). `TableRenderer` wires that to: + +```ts +deps.setHeaders(headers); +deps.onRender(); +``` + +`setHeaders` in [`packages/core/src/core/SimpleTableVanilla.ts`](packages/core/src/core/SimpleTableVanilla.ts) (L503–506) replaces `this.headers` and calls `renderOrchestrator.invalidateCache("header")`. Re-render recomputes each cell's `left` from cumulative leaf widths in `SectionRenderer.calculateAbsoluteBodyCells` (~L608–615). End result for a surviving cell: same DOM node, new `left`. The cell teleports. + +### 2.9 Scroll fast paths + +Two performance optimizations interact with animation: + +- **`positionOnlyBody`** is set to `true` when `source === "scroll-raf"` and `isScrolling === true` (`SimpleTableVanilla.ts` L568). In this mode `renderBodyCells` only updates positions and skips separator / content work (`bodyCellRenderer.ts` L245–252, L373–381). +- **No-op when scroll range unchanged.** In `RenderOrchestrator` (L495–504), if `verticalScrollFastPath && lastScrollRafPaintedRange.start === renderedStartIndex && ... .end === renderedEndIndex`, the render returns without touching the DOM. + +Both must be bypassed for renders that originate from sort, reorder, expand, etc. These are not "scroll" renders, so `positionOnlyBody` will already be `false` for them — but the animation system must explicitly **never** trigger off `scroll-raf` renders. + +### 2.10 Existing animations in the codebase + +Only CSS keyframe flashes exist: + +- `cell-flash` (`base.css` L1424–1436) — animates `background-color` only. Class is `.st-cell-updating` plus `cellUpdateFlash` flag in `bodyCell/styling.ts` L275–278. +- `copy-flash`, `warning-flash` — same pattern. + +There is **no FLIP, no `transition: transform`, no `framer-motion`, no `react-spring`** anywhere in `packages/core/src`. `requestAnimationFrame` is used only for scroll throttling. + +### 2.11 Optional future-helper that is unused today + +`getVisibleColumns` / `recalculateGridPositions` in [`packages/core/src/utils/columnVirtualizationUtils.ts`](packages/core/src/utils/columnVirtualizationUtils.ts) (L170–226, L342–373) define a CSS-grid-based path that is not imported anywhere. Worth ignoring for the animation design. + +--- + +## 3. The FLIP primitive and why it fits + +FLIP (First, Last, Invert, Play) is the canonical technique for animating an element from one layout state to another without animating the layout-affecting properties themselves. + +For each cell that exists both before and after a logical change: + +1. **First** — record the cell's pre-change rect (`top`, `left` either from the cell record or from `getBoundingClientRect`). +2. **Last** — let the existing renderer place the cell at its new `(left, top)`. The cell now sits at its destination. +3. **Invert** — immediately set `transform: translate3d(dx, dy, 0)` where `(dx, dy) = first - last`. The cell visually appears at its old position with no layout cost. +4. **Play** — on the next frame, set `transform: translate3d(0, 0, 0)` and `transition: transform `. The browser tweens the inverse transform back to identity. + +### 3.1 Why FLIP is the right primitive here + +- The renderer **already gives us "Last" for free**. After the normal re-render, every surviving cell sits at its correct final coordinates. We only have to capture "First" before the render and apply the invert+play after. +- Cells have **stable string ids** (`getCellId`), so matching first→last pairs is a `Map` lookup, not a DOM diff. +- Cells are already **`position: absolute`**, so a `transform` on them is purely additive — it does not interact with sibling layout, does not push other cells around, does not change scroll geometry. +- `transform` is GPU-composited; `transition: left 250ms` is not. With a few hundred visible cells the difference is the difference between buttery and stuttery. +- FLIP works **uniformly** for sort, reorder, resize, expand, and filter, because all of them reduce to "the cell ends up at a new `(left, top)`". One mechanism, many triggers. + +### 3.2 Why not `transition: left, top` directly + +- It triggers layout/paint per frame, not just composite. Bad for hundreds of cells. +- It fights with the existing `updateBodyCellPosition` writes during scroll. The transition would cause cells to "drift" toward their target while scrolling, which is wrong. +- It cannot be cleanly disabled on a per-render basis. With FLIP, scroll renders simply skip the snapshot+invert step. + +### 3.3 Why not Web Animations API instead of CSS transition + +WAAPI (`element.animate(...)`) is a fine alternative and gives finer control (cancel, reverse, finished promise). Either is acceptable. CSS `transition: transform` is simpler and sufficient for a v1; WAAPI is worth considering if cancellation semantics get complex. + +--- + +## 4. Virtualization-aware extensions + +Plain FLIP works only for cells that exist as DOM nodes both before and after the change. Virtualization breaks this assumption in two ways: + +- A cell that is currently visible may **leave the DOM** because its target is outside the new viewport window. The animation has nothing to animate to. +- A cell that should _enter_ the viewport from off-screen may **be created at its final position** with no inverted "first" rect to start from. + +Two complementary mechanisms address this. + +### 4.1 Expanded render window for the animation duration + +Temporarily widen the visibility windows so they cover the union of "what was visible before" and "what will be visible after". Concretely: + +- **Vertical (rows):** during the animation, `processRows` should render rows in `[min(startBefore, startAfter), max(endBefore, endAfter)]` instead of just the after-window. Implementation hook: extend `getViewportCalculations` consumers to accept an "extra range" provided by the animation coordinator. +- **Horizontal (main columns):** `getVisibleBodyCells` should accept a similar "extra range" so columns whose `left` was inside the viewport before, or will be inside after, are included. + +Why this is the cleanest mechanism: + +- It expresses the constraint exactly once, in the place the renderer already enforces visibility. +- It is naturally one-frame-only — the coordinator removes the extra range when the animation completes, and the next render culls back to the steady-state window. +- It composes with the existing removal pass: cells outside _both_ the after-window and the in-flight set get `remove()`d as usual after the animation finishes. + +Cost: at most a single additional viewport-worth of cells for the duration of the transition (typically 200–400 ms). + +### 4.2 In-flight retention set + +A `Set` owned by the animation coordinator. Any cell currently mid-transition is added; the removal pass at [`bodyCellRenderer.ts` L283–294](packages/core/src/utils/bodyCellRenderer.ts) consults this set: + +```ts +if (!visibleCellIds.has(cellId) && !animationCoordinator.isInFlight(cellId)) { + element.remove(); + renderedCells.delete(cellId); +} +``` + +When the cell's `transitionend` fires (or a safety timeout expires), the coordinator removes the id and the next render disposes of the node naturally. + +This handles the case of "cell leaves the viewport mid-flight" without coupling the animation to the virtualization math. + +### 4.3 Entering vs leaving cells + +- **Surviving** (in both snapshots): standard FLIP — invert + play to identity. +- **Entering** (in after only): the cell is created at its final position. Animate `opacity 0 → 1` (and optionally a subtle `scale` or `translate` from the direction of travel) over the same duration. No invert step. +- **Leaving** (in before only): keep the original DOM node alive via the in-flight retention set, animate `opacity 1 → 0`, optionally `translate` toward where its target _would_ be. Remove on completion. + +For a sort that swaps two rows, both rows are surviving (their row ids exist before and after); they FLIP. For a filter that hides half the rows, the hidden ones are leaving; the rest survive. + +### 4.4 Header cells + +Header cells use the exact same positioning model: + +```145:147:packages/core/src/utils/headerCell/styling.ts + cellElement.style.position = "absolute"; + cellElement.style.left = `${cell.left}px`; + cellElement.style.top = `${cell.top}px`; +``` + +The same FLIP coordinator applies, with snapshots taken in `renderHeaderCells` ([`packages/core/src/utils/headerCellRenderer.ts`](packages/core/src/utils/headerCellRenderer.ts)). On column reorder, headers and their column body cells should animate together with the same duration and easing so the column appears as a coherent moving block. + +### 4.5 Three sections, three coordinators + +Cells in pinned-left, main, and pinned-right live in different DOM containers with different scroll/clip behaviors. A single `transform` cannot move a cell from one container to another. The coordinator should treat each section independently for FLIP, and treat **cross-section migrations** (pin / unpin) as fade-out-at-old + fade-in-at-new rather than slide. + +--- + +## 5. Trigger taxonomy + +A complete catalogue of state changes that should hook the animation coordinator, and what is animated for each. + +| Trigger | Source | What changes per cell | FLIP target | Notes | +| ---------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Sort change | `SortManager.updateSort` (`SortManager.ts` L161–227) | `top` only | `translate3d(0, dy, 0)` | Snapshot before `notifySubscribers`; play after the resulting render. | +| Column reorder | `setHeaders` from `headerCell/dragging.ts` L260 | `left` only | `translate3d(dx, 0, 0)` | Headers and body cells snapshot together. | +| Column resize | Resize handlers in `headerCell/resizing.ts` | Resized column: `width`. Cells to the right: `left`. | `translate3d(dx, 0, 0)` for right-side cells; optional `width` tween for the resized column | If the user is mid-drag, do **not** animate (the drag is already a continuous motion). Only animate on commit/snap if applicable. | +| Row expand / collapse | `setExpandedRows` / `setCollapsedRows` (`SimpleTableVanilla.ts` L510–522) | All rows below: `top`. New nested rows: enter. | `translate3d(0, dy, 0)` for shifted rows; fade-in for newly mounted nested rows | Combine with row-height transition on the row container if visible. | +| Quick-filter / filter change | `FilterManager` → re-flatten | Surviving rows: `top`. Filtered-out rows: leave. | `translate3d(0, dy, 0)` + fade-out for leavers | Same pattern as sort plus leaver handling. | +| Pin / unpin column | column metadata change → `setHeaders` | Cell migrates between sections | Cross-section fade-out / fade-in | Cannot FLIP across containers. | +| Cell content update | `cellUpdateFlash` path (`bodyCell/styling.ts` L275–278) | None geometric | Existing CSS keyframe (`background-color`) | Already animated; do not touch. | +| **Scroll** | `scroll-raf` / `positionOnlyBody === true` | `top`/`left` per cell | **Do nothing.** | Explicitly excluded; the coordinator must ignore renders whose source is scroll. | + +### Hook points (for reference, not a design) + +The coordinator needs a way to be told "a logical change is about to happen" so it can snapshot. The cleanest signals already exist: + +- `SortManager` notifies subscribers — add a "pre-change" subscription, or wrap `updateSort` to capture before mutating state. +- `setHeaders` in `SimpleTableVanilla.ts` is a single funnel for header changes (drag reorder, resize commit, pin toggle). +- `setExpandedRows` / `setCollapsedRows` are similar single funnels. +- `FilterManager` updates similarly notify subscribers. + +For each, the contract is: capture rects → let the change apply → render runs → coordinator runs FLIP on the resulting DOM. + +--- + +## 6. Edge cases + +Each of these needs explicit handling. Many have been the failure mode of past attempts at table animation in other libraries. + +### 6.1 Animation interrupted by another animation + +User clicks a sort header twice quickly. The second click happens while the first transition is in flight. + +- **Wrong**: snapshot the cell's _logical_ pre-render `top`. The cell is currently mid-flight at some interpolated position; jumping back to the logical top would visibly stutter. +- **Right**: snapshot via `getBoundingClientRect()` so the new "first" reflects the cell's _actual rendered_ position. The new FLIP starts from where the user can see the cell, not from a logical state. + +### 6.2 Editor open during an animation + +If `isEditing && !isEditInDropdown` (`bodyCell/styling.ts` L214), the cell contains an inline editor with focus. Animating its position would move the editor mid-typing. + +Options: + +- Commit (or cancel) the editor before applying the change. +- Skip animation for the cell with the open editor; let it teleport. +- Hold the change until the editor closes. + +Recommendation: skip animation for that one cell and animate the rest. Document this as a known behavior. + +### 6.3 Selection rectangle / fill handle + +`SelectionManager` reads cell DOM rects to position selection borders and fill handles. If it queries during an animation it will read interpolated rects. + +Mitigation: SelectionManager should re-query on `transitionend` of the relevant cells, or explicitly disable selection rect updates while the coordinator reports an in-flight set. + +### 6.4 `cellUpdateFlash` overlap + +The flash is `animation: cell-flash 0.6s ease-in-out` on `.st-cell-updating` and only animates `background-color` (`base.css` L1425–1436). Confirmed safe — does not conflict with `transform`. The two animations can run simultaneously on the same cell. + +### 6.5 `prefers-reduced-motion` + +There is currently no usage of `prefers-reduced-motion` in `packages/core/src` (verified via grep). The coordinator should respect `window.matchMedia("(prefers-reduced-motion: reduce)").matches` and fall back to instant transitions. + +### 6.6 Opt-in by default + +Animation is a behavior change with non-zero CPU cost (extra rect reads, retained off-window cells, expanded render window). It must be opt-in. The existing `cellUpdateFlash?: boolean` in `SimpleTableProps` ([`packages/core/src/types/SimpleTableProps.ts`](packages/core/src/types/SimpleTableProps.ts) L30) is the obvious precedent. A future option might look like `animations?: boolean | { duration, easing }` (default `false`). + +### 6.7 Performance budget + +A typical visible viewport contains 200–500 cells. Per-cell work for FLIP: + +- 1 × `getBoundingClientRect()` (or 1 × Map read of cached `left`/`top`) before the change — read. +- 1 × `style.transform = "translate3d(...)"` after the change — write. +- 1 × `style.transform = "translate3d(0,0,0)"` + `style.transition = "transform Xms"` next frame — write. +- 1 × cleanup on `transitionend` — write. + +Reads must be **batched before any writes** to avoid layout thrash. The coordinator should: + +1. Pause and read all rects in one pass (or use the cell record's `left`/`top`, which avoids the rect read entirely — this is the preferred path). +2. Allow the renderer to write all new positions. +3. In a `requestAnimationFrame`, write all inverse transforms. +4. In the next `requestAnimationFrame`, write all `transition` declarations and zero transforms. + +### 6.8 Memory cleanup + +The in-flight retention set must be drained on: + +- Normal `transitionend`. +- `transitioncancel` (e.g. a new transition replaces the running one). +- Safety timeout (duration + 50 ms slack) in case `transitionend` is missed. +- Table `destroy()` / unmount. +- Theme switch or other full re-init. + +Failure to drain leaks DOM nodes outside the visible window. + +### 6.9 Variable row heights and auto-scaled column widths + +Row heights use `heightOffsets` / `buildCumulativeHeightMap` and `calculateRowTopPosition` (`infiniteScrollUtils.ts` L39–75, L428–445). Column widths can be normalized by `AutoScaleManager`. Both mean the post-change `(left, top)` for a cell is non-trivial. + +The animation coordinator should not attempt to compute the after-position itself. It should read it from the renderer's output (the `AbsoluteBodyCell` record placed on the DOM during "Last") or by `getBoundingClientRect()`. + +### 6.10 No-op renders + +`RenderOrchestrator` (L495–504) returns early when the scroll range is unchanged. Logical changes (sort, reorder, …) are not scroll renders, so this early return does not apply to them — but the coordinator must guard against snapshotting before a render that is going to early-return, otherwise the snapshot leaks. + +### 6.11 Pinned-section interactions + +- Sort change in a row that has both pinned and non-pinned cells: all three sections must snapshot/play in lockstep (same start time, same duration). +- Resize of a pinned column changes `left` for cells in the same pinned section only; main and the other pinned section are unaffected. The coordinator should still animate within the affected section. +- A column being unpinned migrates its body cells from one section's container to another. As noted in §4.5, this is a fade transition, not a slide. + +### 6.12 Drag-in-progress vs drag-end + +`headerCell/dragging.ts` calls `context.onTableHeaderDragEnd(newHeaders)` on each `dragover` (throttled, L260). The name is misleading — this fires continuously during the drag, not only on release. Animating each intermediate reorder would compete with the user's pointer. + +Recommendation: when the change source is a live drag (column reorder or column resize), **suppress** FLIP. The cell should follow the pointer immediately. Animate only on a "settled" change (drop, sort click, expand toggle, filter apply, …). + +### 6.13 SSR / no-DOM environments + +Coordinator code must guard `window`, `requestAnimationFrame`, and `matchMedia` for SSR. The existing renderer is already DOM-bound, but the animation hook must not crash a server-side import. + +--- + +## 7. Open questions and risks + +1. **Where exactly does the snapshot get taken?** + Options are: inside each manager (`SortManager`, etc.), inside `setHeaders`/`setExpandedRows` funnels, or via a dedicated "logical change" event on `RenderOrchestrator`. Each has trade-offs in coupling vs duplication. Worth deciding before implementation. + +2. **Should the after-positions come from cell records or from `getBoundingClientRect`?** + Records are cheaper (no forced reflow) but require trusting that the renderer wrote them correctly. Rects are authoritative but cost a layout. Probably: use records by default, fall back to rects only for interrupted animations (§6.1). + +3. **Default duration and easing.** + A starting point is 220 ms `cubic-bezier(0.2, 0.8, 0.2, 1)` for a "natural" slide. Worth A/B-ing with sort vs reorder to find a single value that feels right for both, or having distinct values per trigger. + +4. **WAAPI vs CSS transition.** + CSS transition is simpler and probably sufficient for v1. WAAPI gains: clean cancellation, `Animation.finished` promise, no `transitionend`-listener bookkeeping. Decide before writing the coordinator. + +5. **Scope of expanded render window.** + Expanding both row and column windows for one frame is cheap. Expanding for a row-expand event with 1000 newly-inserted child rows is not. The coordinator should cap how many extra cells it will retain. + +6. **Cross-section migration animation feel.** + Fade-only is the conservative default for pin/unpin. A fancier alternative is a "ghost element" portaled to the top-level table container that slides between sections and disappears on arrival. Fancier == more risk; not recommended for v1. + +7. **Interaction with React/Solid/Vue/Angular wrappers.** + The animation coordinator lives in `packages/core` so all wrappers inherit it for free, but each wrapper's prop surface must expose the new opt-in flag. Worth verifying no wrapper does its own DOM mutation that would conflict. + +8. **Test strategy.** + The Storybook test runner (per repo rules) can validate "the cell's final `top` equals the expected px after sort". Animating-state assertions are harder; visual regression with a paused mid-animation timeline is one option, but not a small undertaking. + +9. **Is there a use case for animating `width` (not just position)?** + Column resize is the obvious one. `transition: width` causes layout, but on a single resized column it is acceptable. Worth deciding whether the coordinator owns this or it lives in the resize handler. + +10. **What is the failure mode if a transition is dropped?** + The cell ends up at its final position visually (because we set `transform: translate3d(0,0,0)` immediately). The user sees no animation but no broken state. This is the desired graceful degradation. + +--- + +## 8. Glossary of relevant files + +| File | Role in animation design | +| ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`packages/core/src/utils/bodyCell/styling.ts`](packages/core/src/utils/bodyCell/styling.ts) | `createBodyCellElement`, `updateBodyCellPosition`, `updateBodyCellElement`. Where each cell's `left`/`top` is set. The coordinator's "play" step writes `transform` here (or via a sibling helper). | +| [`packages/core/src/utils/headerCell/styling.ts`](packages/core/src/utils/headerCell/styling.ts) | Same as above but for headers. L145–147 and L283–284. | +| [`packages/core/src/utils/cellUtils.ts`](packages/core/src/utils/cellUtils.ts) | `getCellId`. The key used to match first→last across renders. | +| [`packages/core/src/utils/bodyCellRenderer.ts`](packages/core/src/utils/bodyCellRenderer.ts) | `renderBodyCells`, `getVisibleBodyCells`. Removal pass at L283–294 must consult the in-flight retention set. Visibility filter must accept an "extra range" for the expanded window. | +| [`packages/core/src/utils/headerCellRenderer.ts`](packages/core/src/utils/headerCellRenderer.ts) | Header-side equivalent of `renderBodyCells`. | +| [`packages/core/src/core/rendering/RenderOrchestrator.ts`](packages/core/src/core/rendering/RenderOrchestrator.ts) | Central render loop. L266–270 picks `effectiveRows` from the sort manager. L495–504 is the scroll-range no-op. | +| [`packages/core/src/core/rendering/SectionRenderer.ts`](packages/core/src/core/rendering/SectionRenderer.ts) | `calculateAbsoluteBodyCells` / `calculateAbsoluteHeaderCells` — where post-render `left`/`top` come from. | +| [`packages/core/src/core/rendering/TableRenderer.ts`](packages/core/src/core/rendering/TableRenderer.ts) | `renderBody` builds the three sections. | +| [`packages/core/src/core/SimpleTableVanilla.ts`](packages/core/src/core/SimpleTableVanilla.ts) | Top-level orchestrator. L503–506 `setHeaders`, L510–522 `setExpandedRows`/`setCollapsedRows`. L568 sets `positionOnlyBody`. | +| [`packages/core/src/managers/SortManager.ts`](packages/core/src/managers/SortManager.ts) | `updateSort` (L161–227). Subscribers fire after the new sort is committed. | +| [`packages/core/src/utils/headerCell/dragging.ts`](packages/core/src/utils/headerCell/dragging.ts) | Column reorder. `onTableHeaderDragEnd` at L260 fires _during_ drag, not only on release (§6.12). | +| [`packages/core/src/utils/headerCell/resizing.ts`](packages/core/src/utils/headerCell/resizing.ts) | Column resize. Same drag-vs-commit distinction. | +| [`packages/core/src/utils/infiniteScrollUtils.ts`](packages/core/src/utils/infiniteScrollUtils.ts) | `getViewportCalculations`. Where the row window is computed; coordinator extends it for one frame. | +| [`packages/core/src/utils/rowProcessing.ts`](packages/core/src/utils/rowProcessing.ts) | `processRows` consumes the row window. | +| [`packages/core/src/managers/SectionScrollController.ts`](packages/core/src/managers/SectionScrollController.ts) | 20 px horizontal throttle; not an obstacle but worth noting (§6.7). | +| [`packages/core/src/managers/SelectionManager`](packages/core/src/managers/SelectionManager) | Reads cell rects; needs to re-query post-animation (§6.3). | +| [`packages/core/src/styles/base.css`](packages/core/src/styles/base.css) | Existing flash keyframes (L1424–1436). Confirmed not to use `transform`. | +| [`packages/core/src/types/SimpleTableProps.ts`](packages/core/src/types/SimpleTableProps.ts) | `cellUpdateFlash` precedent for the future opt-in flag. | diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 9bb807f2e..6f11aec60 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -5,6 +5,7 @@ import Row from "../types/Row"; import { CustomTheme, areCustomThemesEqual } from "../types/CustomTheme"; import RowState from "../types/RowState"; +import { AnimationCoordinator } from "../managers/AnimationCoordinator"; import { AutoScaleManager } from "../managers/AutoScaleManager"; import { DimensionManager } from "../managers/DimensionManager"; import { ScrollManager } from "../managers/ScrollManager"; @@ -77,6 +78,8 @@ export class SimpleTableVanilla { private headerRegistry: Map = new Map(); private rowIndexMap: Map = new Map(); + private animationCoordinator: AnimationCoordinator; + private autoScaleManager: AutoScaleManager | null = null; private dimensionManager: DimensionManager | null = null; private scrollManager: ScrollManager | null = null; @@ -117,6 +120,15 @@ export class SimpleTableVanilla { this.domManager = new DOMManager(); this.renderOrchestrator = new RenderOrchestrator(); + this.animationCoordinator = new AnimationCoordinator(); + this.animationCoordinator.setEnabled(config.animations ?? true); + if (config.animationDuration !== undefined) { + this.animationCoordinator.setDuration(config.animationDuration); + } + if (config.animationEasing !== undefined) { + this.animationCoordinator.setEasing(config.animationEasing); + } + this.rebuildRowIndexMap(); this.initializeManagers(); } @@ -137,6 +149,27 @@ export class SimpleTableVanilla { }); } + private getBodyContainers(): HTMLElement[] { + const refs = this.domManager.getRefs(); + return [ + refs.mainBodyRef.current, + refs.pinnedLeftRef.current, + refs.pinnedRightRef.current, + ].filter((el): el is HTMLDivElement => el !== null); + } + + /** + * Capture pre-change cell positions for the FLIP animation, including + * conceptual positions for cells outside the virtualization viewport so + * incoming cells can animate from off-screen on column reorder/sort. + */ + private captureAnimationSnapshot(): void { + this.animationCoordinator.captureSnapshot({ + containers: this.getBodyContainers(), + preLayouts: this.renderOrchestrator.getCurrentBodyLayouts(), + }); + } + private initializeManagers(): void { this.ariaAnnouncementManager = new AriaAnnouncementManager(); this.ariaAnnouncementManager.subscribe((message) => { @@ -171,6 +204,7 @@ export class SimpleTableVanilla { }); this.sortManager.subscribe((state) => { + this.captureAnimationSnapshot(); this.render("sortManager"); }); @@ -501,9 +535,16 @@ export class SimpleTableVanilla { } }, setHeaders: (headers: HeaderObject[]) => { + // Skip animation snapshot during a live header drag — the cells should + // follow the pointer immediately rather than tween between drag steps. + const isLiveDrag = this.draggedHeaderRef.current !== null; + if (!isLiveDrag) { + this.captureAnimationSnapshot(); + } this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, + animationCoordinator: this.animationCoordinator, setCollapsedHeaders: (headers: Set) => { this.collapsedHeaders = headers; }, @@ -579,12 +620,27 @@ export class SimpleTableVanilla { this.getRenderState(), this.mergedColumnEditorConfig, ); + + // FLIP play step. No-op when no snapshot is armed or when scroll-driven. + if (source !== "scroll-raf") { + this.animationCoordinator.play({ containers: this.getBodyContainers() }); + } } update(config: Partial): void { this.isUpdating = true; this.config = { ...this.config, ...config }; + if (config.animations !== undefined) { + this.animationCoordinator.setEnabled(config.animations); + } + if (config.animationDuration !== undefined) { + this.animationCoordinator.setDuration(config.animationDuration); + } + if (config.animationEasing !== undefined) { + this.animationCoordinator.setEasing(config.animationEasing); + } + if (config.rows !== undefined) { this.localRows = [...config.rows]; this.rebuildRowIndexMap(); @@ -599,6 +655,13 @@ export class SimpleTableVanilla { } if (config.defaultHeaders !== undefined) { + // Snapshot before mutating headers so the FLIP `play` at the end of the + // ensuing render can inverse-transform from the old layout. Skipped during + // a live header drag (drag handles its own positioning). + const isLiveDrag = this.draggedHeaderRef.current !== null; + if (!isLiveDrag) { + this.captureAnimationSnapshot(); + } this.headers = [...config.defaultHeaders]; this.essentialAccessors = TableInitializer.buildEssentialAccessors(this.headers); @@ -696,6 +759,7 @@ export class SimpleTableVanilla { this.scrollbarVisibilityManager?.destroy(); this.expandedDepthsManager?.destroy(); this.ariaAnnouncementManager?.destroy(); + this.animationCoordinator.destroy(); this.renderOrchestrator.cleanup(); this.domManager.destroy(this.container); @@ -749,6 +813,12 @@ export class SimpleTableVanilla { filterManager: this.filterManager, onRender: () => this.render("columnEditor-onRender"), setHeaders: (headers: HeaderObject[]) => { + // Skip animation snapshot during a live header drag — the cells should + // follow the pointer immediately rather than tween between drag steps. + const isLiveDrag = this.draggedHeaderRef.current !== null; + if (!isLiveDrag) { + this.captureAnimationSnapshot(); + } this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index dec2131c6..dce1ccb2f 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -10,6 +10,7 @@ import { SortManager } from "../../managers/SortManager"; import { FilterManager } from "../../managers/FilterManager"; import { SelectionManager } from "../../managers/SelectionManager"; import { RowSelectionManager } from "../../managers/RowSelectionManager"; +import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; import { TableRenderer } from "./TableRenderer"; import { flattenRows, FlattenRowsResult } from "../../utils/rowFlattening"; import { @@ -29,6 +30,7 @@ import { MergedColumnEditorConfig, ResolvedIcons } from "../initialization/Table import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths"; export interface RenderContext { + animationCoordinator?: AnimationCoordinator; cellRegistry: Map; collapsedHeaders: Set; collapsedRows: Map; @@ -135,6 +137,11 @@ export class RenderOrchestrator { return this.lastProcessedResult; } + /** See {@link TableRenderer.getCurrentBodyLayouts}. */ + getCurrentBodyLayouts(): Map> { + return this.tableRenderer.getCurrentBodyLayouts(); + } + invalidateCache(type?: "body" | "header" | "context" | "all"): void { this.tableRenderer.invalidateCache(type); if (!type || type === "all" || type === "body") { @@ -730,6 +737,7 @@ export class RenderOrchestrator { private buildRendererDeps(effectiveHeaders: HeaderObject[], context: RenderContext) { return { + animationCoordinator: context.animationCoordinator, config: context.config, customTheme: context.customTheme, resolvedIcons: context.resolvedIcons, diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index 5278e5644..fb06954d0 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -13,7 +13,9 @@ import { } from "../../utils/bodyCellRenderer"; import TableRow from "../../types/TableRow"; import { rowIdToString } from "../../utils/rowUtils"; +import { getCellId } from "../../utils/cellUtils"; import { DEFAULT_CUSTOM_THEME } from "../../types/CustomTheme"; +import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; import { calculateTotalHeight, calculateRowTopPosition, @@ -53,6 +55,9 @@ export interface BodySectionParams { fullTableRows?: TableRow[]; renderedStartIndex?: number; renderedEndIndex?: number; + /** When provided, body cell renderer hands outgoing cells to the coordinator + * for FLIP-style out-animation instead of removing them immediately. */ + animationCoordinator?: AnimationCoordinator; } interface BodyCellsCacheEntry { @@ -70,6 +75,61 @@ interface BodyCellsCacheEntry { }; } +/** + * Per-section snapshot of just enough state to recompute every cell position + * (left × top for every row × every leaf header) on demand. Used by the + * animation coordinator: it needs positions for off-screen rows so cells + * sliding in or out of the visible band have a real "from" / "to" point. + * + * Rebuilt on every body section render so it always reflects the layout the + * user is currently looking at — i.e. when captureAnimationSnapshot fires + * before the next render runs, this represents the *pre-change* layout. + */ +interface BodySectionSnapshotConfig { + rows: TableRow[]; + headerPositions: Array<{ accessor: Accessor; left: number; width: number }>; + rowHeight: number; + heightOffsets?: Array<[number, number]>; + customTheme?: any; +} + +/** + * Compute every cell position (every row × every leaf header) implied by a + * snapshot config. Used both for FLIP "First" snapshots (pre-change layout) + * and to feed the renderer the post-change layout for cells that exit the + * visible band — the off-screen `top` is what we want them to slide *to*. + */ +function computeFullSectionLayout( + config: BodySectionSnapshotConfig, +): Map { + const layout = new Map(); + const dataRows = config.rows.filter((r) => !r.nestedTable && !r.stateIndicator); + for (const tableRow of dataRows) { + const top = config.customTheme + ? calculateRowTopPosition({ + position: tableRow.position, + rowHeight: config.rowHeight, + heightOffsets: config.heightOffsets, + customTheme: config.customTheme, + }) + : tableRow.position * config.rowHeight; + const rowKey = tableRow.stableRowKey ?? rowIdToString(tableRow.rowId); + for (const header of config.headerPositions) { + const cellId = getCellId({ + accessor: header.accessor, + rowId: rowKey, + }); + layout.set(cellId, { + left: header.left, + top, + width: header.width, + height: config.rowHeight, + }); + } + } + return layout; +} + const BODY_CELL_BAND_PADDING = 28; interface HeaderCellsCacheEntry { @@ -96,6 +156,7 @@ export class SectionRenderer { private bodyCellsCache: Map = new Map(); private headerCellsCache: Map = new Map(); private contextCache: Map = new Map(); + private bodySectionSnapshots: Map = new Map(); // Track the next colIndex for each section after rendering private nextColIndexMap: Map = new Map(); @@ -229,6 +290,7 @@ export class SectionRenderer { fullTableRows, renderedStartIndex, renderedEndIndex, + animationCoordinator, } = params; const sectionKey = pinned || "main"; @@ -291,6 +353,31 @@ export class SectionRenderer { renderedEndIndex, ); + // Cache just enough state to recompute the position of every cell in this + // section (including off-screen rows) for animation snapshots. The + // animation coordinator reads these on captureAnimationSnapshot and + // needs them to FLIP cells that were never in the DOM (rows that slide + // into view from off-screen) or that won't be in the DOM after the + // change (rows that slide out of view). + this.captureSnapshotConfig( + sectionKey, + filteredHeaders, + collapsedHeaders, + fullTableRows ?? rows, + rowHeight, + heightOffsets, + context.customTheme ?? DEFAULT_CUSTOM_THEME, + ); + + // The post-render full layout maps every cell id (visible OR off-screen) + // to its destination in the *new* state. The body cell renderer hands + // this to the animation coordinator so cells exiting the visible band + // can slide to their off-screen post-change position before being torn + // down — instead of just disappearing in place. + const fullCellLayout = animationCoordinator + ? this.getFullSectionLayout(sectionKey) + : null; + const dataRowCount = rows.filter((r) => !r.nestedTable && !r.stateIndicator).length; const maxColIndex = absoluteCells.length > 0 && dataRowCount > 0 @@ -314,6 +401,8 @@ export class SectionRenderer { currentScrollLeft, rows, positionOnly, + animationCoordinator, + fullCellLayout ?? undefined, ); // Render nested grid rows (full-width rows that contain a nested SimpleTable) or spacers in pinned sections @@ -634,6 +723,7 @@ export class SectionRenderer { rowIndex, colIndex, rowId: rowIdToString(tableRow.rowId), + stableRowKey: tableRow.stableRowKey, displayRowNumber: tableRow.displayPosition, depth: tableRow.depth, isOdd: rowIndex % 2 === 1, @@ -1019,12 +1109,81 @@ export class SectionRenderer { return this.nextColIndexMap.get(sectionKey) ?? 0; } + /** + * Build a per-section layout map covering every cell in the dataset (every + * row × every leaf header), not just the cells in the current virtualization + * band. Used by the animation coordinator: it needs positions for off-screen + * rows so that: + * + * - Cells that newly enter the visible band (e.g. row sorted from bottom + * to top) can FLIP in from their actual pre-change off-screen `top`. + * - Cells that leave the visible band (e.g. row sorted from top to + * bottom) can be retained and slid to their actual post-change + * off-screen `top` before being removed. + * + * The body container clips overflow so cells whose interpolated position + * falls outside the viewport simply aren't painted — the animation looks + * like a slide in from / out to the viewport edge. + */ + getCurrentBodyLayouts(): Map> { + const out = new Map>(); + this.bodySectionSnapshots.forEach((config, sectionKey) => { + const section = this.bodySections.get(sectionKey); + if (!section) return; + out.set(section, computeFullSectionLayout(config)); + }); + return out; + } + + /** + * Compute every cell position the section currently knows about (every row + * × every leaf header), including positions for off-screen rows, by using + * the most recent snapshot config for `sectionKey`. Returns null if no + * snapshot has been captured for this section yet. + */ + getFullSectionLayout(sectionKey: string): Map | null { + const config = this.bodySectionSnapshots.get(sectionKey); + return config ? computeFullSectionLayout(config) : null; + } + + /** + * Refresh the per-section snapshot config so getCurrentBodyLayouts can + * recompute positions for any row × column combination the section + * currently knows about. + */ + private captureSnapshotConfig( + sectionKey: string, + headers: HeaderObject[], + collapsedHeaders: Set, + rows: TableRow[], + rowHeight: number, + heightOffsets?: Array<[number, number]>, + customTheme?: any, + ): void { + const leafHeaders = this.getLeafHeaders(headers, collapsedHeaders); + const headerPositions: Array<{ accessor: Accessor; left: number; width: number }> = []; + let currentLeft = 0; + for (const header of leafHeaders) { + const width = typeof header.width === "number" ? header.width : 150; + headerPositions.push({ accessor: header.accessor, left: currentLeft, width }); + currentLeft += width; + } + this.bodySectionSnapshots.set(sectionKey, { + rows, + headerPositions, + rowHeight, + heightOffsets, + customTheme, + }); + } + cleanup(): void { this.headerSections.clear(); this.bodySections.clear(); this.bodyCellsCache.clear(); this.headerCellsCache.clear(); this.contextCache.clear(); + this.bodySectionSnapshots.clear(); this.nextColIndexMap.clear(); } } diff --git a/packages/core/src/core/rendering/TableRenderer.ts b/packages/core/src/core/rendering/TableRenderer.ts index 33fdfdaf0..1e15e4a7f 100644 --- a/packages/core/src/core/rendering/TableRenderer.ts +++ b/packages/core/src/core/rendering/TableRenderer.ts @@ -22,10 +22,12 @@ import { SortManager } from "../../managers/SortManager"; import { FilterManager } from "../../managers/FilterManager"; import { SelectionManager } from "../../managers/SelectionManager"; import { RowSelectionManager } from "../../managers/RowSelectionManager"; +import type { AnimationCoordinator, CellPosition } from "../../managers/AnimationCoordinator"; import { recalculateAllSectionWidths } from "../../utils/resizeUtils/sectionWidths"; import { canDisplaySection } from "../../utils/generalUtils"; export interface TableRendererDeps { + animationCoordinator?: AnimationCoordinator; cellRegistry: Map; collapsedHeaders: Set; collapsedRows: Map; @@ -107,6 +109,11 @@ export class TableRenderer { this.sectionRenderer.invalidateCache(type); } + /** See {@link SectionRenderer.getCurrentBodyLayouts}. */ + getCurrentBodyLayouts(): Map> { + return this.sectionRenderer.getCurrentBodyLayouts(); + } + renderHeader( container: HTMLElement, calculatedHeaderHeight: number, @@ -518,6 +525,10 @@ export class TableRenderer { // Track which sections should exist (like React's component list) const sectionsToKeep: HTMLElement[] = []; + // Skip animation hookup during the position-only fast path on scroll — + // outgoing/incoming cells must not be animated when the user is scrolling. + const animationCoordinator = deps.positionOnlyBody ? undefined : deps.animationCoordinator; + if (pinnedLeftHeaders.length > 0) { const leftSection = this.sectionRenderer.renderBodySection({ headers: deps.effectiveHeaders, @@ -534,6 +545,7 @@ export class TableRenderer { fullTableRows: processedResult.currentTableRows, renderedStartIndex: processedResult.renderedStartIndex, renderedEndIndex: processedResult.renderedEndIndex, + animationCoordinator, }); deps.pinnedLeftRef.current = leftSection as HTMLDivElement; sectionsToKeep.push(leftSection); @@ -557,6 +569,7 @@ export class TableRenderer { fullTableRows: processedResult.currentTableRows, renderedStartIndex: processedResult.renderedStartIndex, renderedEndIndex: processedResult.renderedEndIndex, + animationCoordinator, }); deps.mainBodyRef.current = mainSection as HTMLDivElement; sectionsToKeep.push(mainSection); @@ -581,6 +594,7 @@ export class TableRenderer { fullTableRows: processedResult.currentTableRows, renderedStartIndex: processedResult.renderedStartIndex, renderedEndIndex: processedResult.renderedEndIndex, + animationCoordinator, }); deps.pinnedRightRef.current = rightSection as HTMLDivElement; sectionsToKeep.push(rightSection); diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts new file mode 100644 index 000000000..c61d4403e --- /dev/null +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -0,0 +1,432 @@ +import { getRenderedCells } from "../utils/bodyCell/eventTracking"; + +export interface AnimationCoordinatorOptions { + duration?: number; + easing?: string; +} + +export interface CellPosition { + left: number; + top: number; + width: number; + height: number; +} + +interface CellSnapshot { + left: number; + top: number; +} + +interface InFlightCell { + element: HTMLElement; + cleanupTimeout: number; + transitionEndHandler: (event: TransitionEvent) => void; + isRetained: boolean; +} + +const DEFAULT_DURATION = 240; +const DEFAULT_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; +const MIN_DELTA = 0.5; +const SAFETY_TIMEOUT_SLACK = 80; +const RETAINED_CLASS = "st-cell-animating-out"; +const RETAINED_ATTR = "data-animating-out"; + +/** + * FLIP-style animation coordinator for body cells with virtualization awareness. + * + * Triggered explicitly via {@link captureSnapshot} (before a layout-affecting + * change) and {@link play} (after the renderer has placed cells at their new + * positions). + * + * Three classes of cells participate in an animation: + * - Persistent cells (visible before AND after): the same DOM node moves to + * a new `top`/`left`; FLIP slides it from the old visual spot. + * - Incoming cells (off-screen before, in DOM after): the renderer creates + * them at their new position; if the snapshot has their pre-change + * position (computed for ALL rows, not just the band), FLIP slides them + * in from there. The portion that's outside the body's overflow clip is + * never painted, so cells appear to slide in from the viewport edge. + * - Outgoing cells (in DOM before, off-screen after): the renderer hands + * them to {@link retainCell} along with their post-change off-screen + * position; FLIP slides them out to that position, then removes them. + */ +export class AnimationCoordinator { + private enabled = false; + private duration: number; + private easing: string; + /** Pre-change positions for any cell we want to consider for animation. */ + private snapshot: Map | null = null; + private inFlight: Map = new Map(); + /** Outgoing cells the renderer handed off; keyed per container so play() finds them. */ + private retainedCells: Map> = new Map(); + private prefersReducedMotion: boolean; + + constructor(opts: AnimationCoordinatorOptions = {}) { + this.duration = opts.duration ?? DEFAULT_DURATION; + this.easing = opts.easing ?? DEFAULT_EASING; + this.prefersReducedMotion = readPrefersReducedMotion(); + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + if (!enabled) { + this.cancel(); + } + } + + setDuration(duration: number): void { + if (Number.isFinite(duration) && duration > 0) { + this.duration = duration; + } + } + + setEasing(easing: string): void { + if (typeof easing === "string" && easing.length > 0) { + this.easing = easing; + } + } + + isEnabled(): boolean { + return this.enabled && !this.prefersReducedMotion; + } + + isInFlight(cellId: string): boolean { + return this.inFlight.has(cellId); + } + + /** + * Capture pre-change positions for cells we may want to animate. + * + * @param args.containers Body containers; rendered cells are read from the DOM. + * @param args.preLayouts Optional per-container conceptual layout. Should + * include positions for ALL rows in the dataset (not just the visible + * band) so cells that newly enter the band can FLIP in from their actual + * pre-change location and cells that leave the band can FLIP out to it. + */ + captureSnapshot(args: { + containers: Array; + preLayouts?: Map>; + }): void { + if (!this.isEnabled()) { + this.snapshot = null; + return; + } + + const next = new Map(); + + for (const container of args.containers) { + if (!container) continue; + + // 1. DOM-rendered cells: read live position (handles in-flight transforms). + const cells = getRenderedCells(container); + cells.forEach((element, cellId) => { + if (!next.has(cellId)) { + next.set(cellId, this.readPosition(cellId, element)); + } + }); + + // 2. Already-retained cells from a prior animation: read live visual pos. + const retained = this.retainedCells.get(container); + if (retained) { + retained.forEach((element, cellId) => { + if (!next.has(cellId)) { + next.set(cellId, this.readPosition(cellId, element)); + } + }); + } + + // 3. Conceptual layout for cells not currently in DOM (off-screen rows). + // The supplied layout takes a back seat to live DOM reads so in-flight + // cells use their real visual position rather than a stale absolute one. + const preLayout = args.preLayouts?.get(container); + if (preLayout) { + preLayout.forEach((pos, cellId) => { + if (!next.has(cellId)) { + next.set(cellId, { left: pos.left, top: pos.top }); + } + }); + } + } + + this.snapshot = next.size > 0 ? next : null; + } + + /** + * The renderer asks before removing a cell whether the coordinator wants to + * keep it for an out-animation. + */ + shouldRetain(cellId: string): boolean { + return Boolean(this.snapshot?.has(cellId)); + } + + /** + * Hand a cell that the renderer would otherwise remove to the coordinator. + * The coordinator updates its absolute positioning to the post-change layout + * and will animate it from the snapshotted pre-change visual position to + * that new position during {@link play}, then remove it from the DOM. + * + * The new position can be off-screen (e.g. the row sorted to a position + * outside the visible band) — the body container's `overflow: hidden` + * naturally clips the cell as it slides past the viewport edge. + */ + retainCell(args: { + cellId: string; + element: HTMLElement; + container: HTMLElement; + newPosition: CellPosition; + }): void { + const { cellId, element, container, newPosition } = args; + + let map = this.retainedCells.get(container); + if (!map) { + map = new Map(); + this.retainedCells.set(container, map); + } + + // If we already have a retained cell with this id, drop it immediately so + // we don't accumulate phantom DOM nodes (e.g. user mashes the same toggle). + const existing = map.get(cellId); + if (existing && existing !== element) { + this.cancelInFlight(cellId); + existing.remove(); + } + + // Strip the id so DOM lookups (e.g. document.getElementById, tests) prefer + // the live cell that the renderer is about to create. The retained node is + // still positioned absolutely and visually slides to its new spot. + if (element.id) element.removeAttribute("id"); + element.classList.add(RETAINED_CLASS); + element.setAttribute(RETAINED_ATTR, "true"); + + element.style.left = `${newPosition.left}px`; + element.style.top = `${newPosition.top}px`; + element.style.width = `${newPosition.width}px`; + element.style.height = `${newPosition.height}px`; + // Disable pointer events on departing cells so they don't intercept clicks. + element.style.pointerEvents = "none"; + + map.set(cellId, element); + } + + /** + * Discard any retained cell with this id in the given container. Called by + * the renderer when it's about to create a fresh cell with the same id, so + * we don't have two DOM nodes claiming the same logical slot. + */ + discardRetainedIfPresent(cellId: string, container: HTMLElement): void { + const map = this.retainedCells.get(container); + if (!map) return; + const element = map.get(cellId); + if (!element) return; + this.cancelInFlight(cellId); + map.delete(cellId); + element.remove(); + } + + /** + * Apply the FLIP invert + play step to every cell present in the snapshot + * that is now in the DOM (either as an actively rendered cell or as a + * retained cell). Clears the snapshot. + */ + play(args: { containers: Array }): void { + const snapshot = this.snapshot; + this.snapshot = null; + + if (!this.isEnabled() || !snapshot) { + // Nothing to play; clean up any leftover retained cells. + this.retainedCells.forEach((map) => { + map.forEach((element) => element.remove()); + map.clear(); + }); + this.retainedCells.clear(); + return; + } + + type Pending = { + cellId: string; + element: HTMLElement; + dx: number; + dy: number; + isRetained: boolean; + }; + const pending: Pending[] = []; + const seen = new Set(); + + const consider = (element: HTMLElement, cellId: string, isRetained: boolean) => { + if (seen.has(cellId)) return; + const before = snapshot.get(cellId); + if (!before) return; + // Skip cells with an open inline editor (animating breaks input focus). + if (element.querySelector(".st-cell-editing")) return; + + const currentLeft = parsePx(element.style.left); + const currentTop = parsePx(element.style.top); + const dx = before.left - currentLeft; + const dy = before.top - currentTop; + if (Math.abs(dx) < MIN_DELTA && Math.abs(dy) < MIN_DELTA) { + // No visual movement — if this was a retained cell with no movement + // (a degenerate case), still drop it so we don't leak DOM. + if (isRetained) element.remove(); + return; + } + + pending.push({ cellId, element, dx, dy, isRetained }); + seen.add(cellId); + }; + + for (const container of args.containers) { + if (!container) continue; + + // Retained (outgoing) cells animate first so we collect them. + const retained = this.retainedCells.get(container); + if (retained) { + retained.forEach((element, cellId) => consider(element, cellId, true)); + } + + // Active cells: incoming + persistent. + const cells = getRenderedCells(container); + cells.forEach((element, cellId) => consider(element, cellId, false)); + } + + // FLIP "First" frame: apply inverse transforms synchronously so the next + // paint shows cells at their old positions. + for (const { cellId, element, dx, dy } of pending) { + this.cancelInFlight(cellId); + element.style.transition = "none"; + element.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; + element.style.willChange = "transform"; + } + + if (pending.length === 0) return; + + requestAnimationFrame(() => { + for (const { cellId, element, isRetained } of pending) { + if (!element.isConnected) continue; + this.startTransition(cellId, element, isRetained); + } + }); + } + + /** + * Cancel every in-flight transition and clear any armed snapshot. Active + * cells snap to their final positions; retained cells are removed from the + * DOM so we don't leak nodes. + */ + cancel(): void { + this.snapshot = null; + const entries = Array.from(this.inFlight.entries()); + this.inFlight.clear(); + for (const [cellId, entry] of entries) { + window.clearTimeout(entry.cleanupTimeout); + entry.element.removeEventListener("transitionend", entry.transitionEndHandler); + this.finishElement(cellId, entry.element, entry.isRetained); + } + // Clean up any retained cells that weren't in flight (e.g. cell was + // retained but never reached the play step). + this.retainedCells.forEach((map) => { + map.forEach((element) => element.remove()); + map.clear(); + }); + this.retainedCells.clear(); + } + + destroy(): void { + this.cancel(); + } + + private readPosition(cellId: string, element: HTMLElement): CellSnapshot { + const inFlight = this.inFlight.get(cellId); + if (inFlight) { + const rect = element.getBoundingClientRect(); + const parent = element.offsetParent as HTMLElement | null; + if (parent) { + const parentRect = parent.getBoundingClientRect(); + return { + left: rect.left - parentRect.left + parent.scrollLeft, + top: rect.top - parentRect.top + parent.scrollTop, + }; + } + return { left: rect.left, top: rect.top }; + } + return { + left: parsePx(element.style.left), + top: parsePx(element.style.top), + }; + } + + private startTransition(cellId: string, element: HTMLElement, isRetained: boolean): void { + element.style.transition = `transform ${this.duration}ms ${this.easing}`; + element.style.transform = "translate3d(0, 0, 0)"; + + const transitionEndHandler = (event: TransitionEvent) => { + if (event.propertyName !== "transform") return; + this.finalizeCell(cellId, element); + }; + element.addEventListener("transitionend", transitionEndHandler); + + const cleanupTimeout = window.setTimeout(() => { + this.finalizeCell(cellId, element); + }, this.duration + SAFETY_TIMEOUT_SLACK); + + this.inFlight.set(cellId, { + element, + cleanupTimeout, + transitionEndHandler, + isRetained, + }); + } + + private cancelInFlight(cellId: string): void { + const entry = this.inFlight.get(cellId); + if (!entry) return; + window.clearTimeout(entry.cleanupTimeout); + entry.element.removeEventListener("transitionend", entry.transitionEndHandler); + this.inFlight.delete(cellId); + } + + private finalizeCell(cellId: string, element: HTMLElement): void { + const entry = this.inFlight.get(cellId); + const isRetained = entry?.isRetained ?? this.isCellRetained(element); + if (entry) { + window.clearTimeout(entry.cleanupTimeout); + entry.element.removeEventListener("transitionend", entry.transitionEndHandler); + this.inFlight.delete(cellId); + } + this.finishElement(cellId, element, isRetained); + } + + private finishElement(cellId: string, element: HTMLElement, isRetained: boolean): void { + if (isRetained) { + this.retainedCells.forEach((map) => { + if (map.get(cellId) === element) map.delete(cellId); + }); + element.remove(); + return; + } + element.style.transition = ""; + element.style.transform = ""; + element.style.willChange = ""; + } + + private isCellRetained(element: HTMLElement): boolean { + return element.hasAttribute(RETAINED_ATTR); + } +} + +const parsePx = (value: string): number => { + if (!value) return 0; + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const readPrefersReducedMotion = (): boolean => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +}; diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts index 30492fc27..c6c5ab3a5 100644 --- a/packages/core/src/types/SimpleTableConfig.ts +++ b/packages/core/src/types/SimpleTableConfig.ts @@ -24,6 +24,9 @@ import { VanillaIconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; export interface SimpleTableConfig { + animations?: boolean; + animationDuration?: number; + animationEasing?: string; autoExpandColumns?: boolean; canExpandRowGroup?: (row: Row) => boolean; cellUpdateFlash?: boolean; diff --git a/packages/core/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts index c31ca480c..8b369c299 100644 --- a/packages/core/src/types/SimpleTableProps.ts +++ b/packages/core/src/types/SimpleTableProps.ts @@ -25,6 +25,9 @@ import { IconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; export interface SimpleTableProps { + animations?: boolean; // Flag for animating cells on sort and programmatic column reorder (default: true) + animationDuration?: number; // Cell animation duration in ms (default: 240). Honored only when `animations` is true. + animationEasing?: string; // Cell animation CSS easing function (default: cubic-bezier(0.2, 0.8, 0.2, 1)). Honored only when `animations` is true. autoExpandColumns?: boolean; // Flag for converting pixel widths to proportional fr units that fill table width canExpandRowGroup?: (row: Row) => boolean; // Function to conditionally control if a row group can be expanded cellUpdateFlash?: boolean; // Flag for flash animation after cell update diff --git a/packages/core/src/types/TableRow.ts b/packages/core/src/types/TableRow.ts index 6a2b4a072..b1f716506 100644 --- a/packages/core/src/types/TableRow.ts +++ b/packages/core/src/types/TableRow.ts @@ -13,6 +13,15 @@ type TableRow = { // Example: [1, "stores", 5] or [1, "stores", 5, "STORE-101"] // Use rowIdToString(rowId) to convert to string for Map keys rowId: (string | number)[]; + /** + * Position-independent identity for the row, used as the basis for the + * cell DOM `id` and the animation coordinator's snapshot key. When + * `getRowId` is provided, this is `String(customId)` (optionally prefixed + * by grouping keys for nested rows). Lets the same DOM cell survive a + * sort, so FLIP can animate the row to its new position. Falls back to + * the positional rowId string when `getRowId` is absent. + */ + stableRowKey?: string; // Path to reach this row using row IDs (when getRowId is provided) // Example: ['REG-1', 'stores', 'STORE-101'] means find region with id='REG-1', then its stores, then store with id='STORE-101' rowPath?: (string | number)[]; diff --git a/packages/core/src/utils/bodyCell/editors/booleanDropdown.ts b/packages/core/src/utils/bodyCell/editors/booleanDropdown.ts index 11d9166a2..133685398 100644 --- a/packages/core/src/utils/bodyCell/editors/booleanDropdown.ts +++ b/packages/core/src/utils/bodyCell/editors/booleanDropdown.ts @@ -87,7 +87,10 @@ export const createBooleanDropdown = ( content.appendChild(falseOption); // Get the cell element as trigger (use getCellId for consistency with body cell IDs) - const cellId = getCellId({ accessor: header.accessor, rowId: cell.rowId }); + const cellId = getCellId({ + accessor: header.accessor, + rowId: cell.stableRowKey ?? cell.rowId, + }); const cellElement = document.getElementById(cellId) as HTMLElement; // Create and show dropdown diff --git a/packages/core/src/utils/bodyCell/editors/datePicker.ts b/packages/core/src/utils/bodyCell/editors/datePicker.ts index 02fb2d918..274579ce4 100644 --- a/packages/core/src/utils/bodyCell/editors/datePicker.ts +++ b/packages/core/src/utils/bodyCell/editors/datePicker.ts @@ -117,14 +117,7 @@ export const createDatePicker = ( addTrackedEventListener(todayBtn, "click", () => { const today = new Date(); - const noon = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate(), - 12, - 0, - 0, - ); + const noon = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12, 0, 0); handleDateSelect(noon); }); @@ -284,7 +277,10 @@ export const createDatePicker = ( renderCalendar(); // Get the cell element as trigger - use getCellId for consistency with body cell IDs - const cellId = getCellId({ accessor: header.accessor, rowId: cell.rowId }); + const cellId = getCellId({ + accessor: header.accessor, + rowId: cell.stableRowKey ?? cell.rowId, + }); const cellElement = document.getElementById(cellId) as HTMLElement; // Create and show dropdown diff --git a/packages/core/src/utils/bodyCell/editors/enumDropdown.ts b/packages/core/src/utils/bodyCell/editors/enumDropdown.ts index 7000e5093..76130bf5e 100644 --- a/packages/core/src/utils/bodyCell/editors/enumDropdown.ts +++ b/packages/core/src/utils/bodyCell/editors/enumDropdown.ts @@ -43,9 +43,7 @@ export const createEnumDropdown = ( options.forEach((option: EnumOption, index: number) => { const optionElement = document.createElement("div"); - optionElement.className = `st-dropdown-item ${ - currentValue === option.value ? "selected" : "" - }`; + optionElement.className = `st-dropdown-item ${currentValue === option.value ? "selected" : ""}`; optionElement.textContent = option.label; optionElement.setAttribute("role", "option"); optionElement.setAttribute("aria-selected", String(currentValue === option.value)); @@ -91,7 +89,10 @@ export const createEnumDropdown = ( optionElements.forEach((el) => wrapper.appendChild(el)); // Get the cell element as trigger (use getCellId for consistency with body cell IDs) - const cellId = getCellId({ accessor: header.accessor, rowId: cell.rowId }); + const cellId = getCellId({ + accessor: header.accessor, + rowId: cell.stableRowKey ?? cell.rowId, + }); const cellElement = document.getElementById(cellId) as HTMLElement; // Create and show dropdown diff --git a/packages/core/src/utils/bodyCell/styling.ts b/packages/core/src/utils/bodyCell/styling.ts index bae938f76..a4ba69499 100644 --- a/packages/core/src/utils/bodyCell/styling.ts +++ b/packages/core/src/utils/bodyCell/styling.ts @@ -15,6 +15,10 @@ const rowCellsMap = new Map>(); // read the latest row data even when the cell DOM node is reused across renders. const cellRowRefMap = new WeakMap(); +// Per-element registry key so we can re-key entries when a cell is reused +// for a different row across sort/scroll without leaving stale entries behind. +const cellRegistryKeyMap = new WeakMap(); + // Track current hovered row for cleanup let currentHoveredRowId: string | null = null; @@ -27,10 +31,7 @@ const trackCellByRow = (rowId: string, cellElement: HTMLElement): void => { }; // Helper to remove cell from row tracking -export const untrackCellByRow = ( - rowId: string, - cellElement: HTMLElement, -): void => { +export const untrackCellByRow = (rowId: string, cellElement: HTMLElement): void => { const cellSet = rowCellsMap.get(rowId); if (cellSet) { cellSet.delete(cellElement); @@ -55,15 +56,12 @@ const setRowHoverState = (rowId: string, hovered: boolean): void => { }; // Calculate cell class names based on current state -const calculateBodyCellClasses = ( - cell: AbsoluteBodyCell, - context: CellRenderContext, -): string => { +const calculateBodyCellClasses = (cell: AbsoluteBodyCell, context: CellRenderContext): string => { const { header, rowIndex, colIndex, rowId, depth, isOdd } = cell; - const isSelectionColumn = - header.isSelectionColumn && context.enableRowSelection; - const clickable = Boolean(header?.isEditable) || Boolean(context.onCellClick && !isSelectionColumn); + const isSelectionColumn = header.isSelectionColumn && context.enableRowSelection; + const clickable = + Boolean(header?.isEditable) || Boolean(context.onCellClick && !isSelectionColumn); // Calculate selection states const cellData: CellData = { rowIndex, colIndex, rowId }; @@ -85,24 +83,14 @@ const calculateBodyCellClasses = ( const isLastColumnInSection = (() => { if (!context.columnBorders) return false; - const pinnedLeftColumns = context.headers.filter( - (h) => h.pinned === "left", - ); + const pinnedLeftColumns = context.headers.filter((h) => h.pinned === "left"); const mainColumns = context.headers.filter((h) => !h.pinned); - const pinnedRightColumns = context.headers.filter( - (h) => h.pinned === "right", - ); + const pinnedRightColumns = context.headers.filter((h) => h.pinned === "right"); if (header.pinned === "left") { - return ( - pinnedLeftColumns[pinnedLeftColumns.length - 1]?.accessor === - header.accessor - ); + return pinnedLeftColumns[pinnedLeftColumns.length - 1]?.accessor === header.accessor; } else if (header.pinned === "right") { - return ( - pinnedRightColumns[pinnedRightColumns.length - 1]?.accessor === - header.accessor - ); + return pinnedRightColumns[pinnedRightColumns.length - 1]?.accessor === header.accessor; } else { return mainColumns[mainColumns.length - 1]?.accessor === header.accessor; } @@ -136,20 +124,12 @@ const calculateBodyCellClasses = ( ? "st-cell-warning-flash-first" : "st-cell-warning-flash" : "", - context.useOddColumnBackground - ? colIndex % 2 === 0 - ? "even-column" - : "odd-column" - : "", + context.useOddColumnBackground ? (colIndex % 2 === 0 ? "even-column" : "odd-column") : "", isSelectionColumn ? "st-selection-cell" : "", hasHighlightedCellInRow ? "st-selection-has-highlighted-cell" : "", isLastColumnInSection ? "st-last-column" : "", isSubCell ? "st-sub-cell" : "", - context.useOddEvenRowBackground - ? isOdd - ? "st-cell-even-row" - : "st-cell-odd-row" - : "", + context.useOddEvenRowBackground ? (isOdd ? "st-cell-even-row" : "st-cell-odd-row") : "", context.isRowSelected?.(rowId) ? "st-cell-selected-row" : "", ] .filter(Boolean) @@ -163,8 +143,7 @@ export const createBodyCellElement = ( ): HTMLElement => { const { header, row, rowIndex, colIndex, rowId } = cell; - const isSelectionColumn = - header.isSelectionColumn && context.enableRowSelection; + const isSelectionColumn = header.isSelectionColumn && context.enableRowSelection; // Calculate cell data for state checks const cellData: CellData = { rowIndex, colIndex, rowId }; @@ -175,15 +154,15 @@ export const createBodyCellElement = ( // Create cell element const cellElement = document.createElement("div"); cellElement.className = classNames; - cellElement.id = getCellId({ accessor: header.accessor, rowId }); + cellElement.id = getCellId({ + accessor: header.accessor, + rowId: cell.stableRowKey ?? rowId, + }); cellElement.setAttribute("role", "gridcell"); cellElement.setAttribute("tabindex", isInitialFocused ? "0" : "-1"); // ARIA: 1-based row index in the full grid (matches main: position + maxHeaderDepth + 1) const maxHeaderDepth = context.maxHeaderDepth ?? 1; - cellElement.setAttribute( - "aria-rowindex", - String(cell.tableRow.position + maxHeaderDepth + 1), - ); + cellElement.setAttribute("aria-rowindex", String(cell.tableRow.position + maxHeaderDepth + 1)); cellElement.setAttribute("aria-colindex", String(colIndex + 1)); // Set data attributes for selection manager to query @@ -204,9 +183,7 @@ export const createBodyCellElement = ( // Determine if this column type uses dropdown editing const isEditInDropdown = - header.type === "boolean" || - header.type === "date" || - header.type === "enum"; + header.type === "boolean" || header.type === "date" || header.type === "enum"; const renderCellContent = () => { // For dropdown editors, keep the normal cell content visible @@ -259,15 +236,22 @@ export const createBodyCellElement = ( renderCellContent(); + // Mutable row ref so handlers (and the cell registry's `updateContent`) + // always read the latest row data even when this DOM cell is reused across + // renders (sort, scroll). Set before registering so the registry uses it. + const rowRef = { current: row as Row }; + cellRowRefMap.set(cellElement, rowRef); + // Register cell in registry for direct updates const registerCellInRegistry = () => { if (context.cellRegistry && !isSelectionColumn) { const key = `${rowId}-${header.accessor}`; + cellRegistryKeyMap.set(cellElement, key); context.cellRegistry.set(key, { updateContent: (newValue: CellValue) => { if (!isEditing) { - // Update the row data - setNestedValue(row, header.accessor, newValue); + // Always write to the current row (DOM cell may be reused). + setNestedValue(rowRef.current, header.accessor, newValue); // Re-render cell content renderCellContent(); @@ -275,15 +259,10 @@ export const createBodyCellElement = ( // Add update flash animation if (context.cellUpdateFlash) { cellElement.classList.add( - isInitialFocused - ? "st-cell-updating-first" - : "st-cell-updating", + isInitialFocused ? "st-cell-updating-first" : "st-cell-updating", ); setTimeout(() => { - cellElement.classList.remove( - "st-cell-updating-first", - "st-cell-updating", - ); + cellElement.classList.remove("st-cell-updating-first", "st-cell-updating"); }, 800); } } @@ -321,11 +300,7 @@ export const createBodyCellElement = ( } // Start editing on F2 or Enter - if ( - (keyEvent.key === "F2" || keyEvent.key === "Enter") && - header.isEditable && - !isEditing - ) { + if ((keyEvent.key === "F2" || keyEvent.key === "Enter") && header.isEditable && !isEditing) { keyEvent.preventDefault(); isEditing = true; renderCellContent(); @@ -344,11 +319,6 @@ export const createBodyCellElement = ( addTrackedEventListener(cellElement, "dblclick", handleDoubleClick); - // Mutable row ref so click handler always reads the latest row data - // even when updateBodyCellElement re-uses this DOM element with new rows. - const rowRef = { current: row as Row }; - cellRowRefMap.set(cellElement, rowRef); - // Cell click callback if (context.onCellClick && !isSelectionColumn) { const handleClick = (event: Event) => { @@ -376,26 +346,25 @@ export const createBodyCellElement = ( // Row hover handlers - use efficient Map-based tracking if (context.useHoverRowBackground) { const rowIdKey = String(rowId); - // Track this cell by stable row id (visual rowIndex changes when the viewport slice shifts) + // Track this cell by row id (re-keyed in updateBodyCellElement when the + // DOM cell is reused for a different row across sort/scroll). trackCellByRow(rowIdKey, cellElement); + // The handlers must read the *current* row id from the DOM so they + // reference the correct row when this cell is reused after a sort. const handleMouseEnter = () => { - // Clear previous hovered row if different - if ( - currentHoveredRowId !== null && - currentHoveredRowId !== rowIdKey - ) { + const currentRowId = cellElement.getAttribute("data-row-id") ?? rowIdKey; + if (currentHoveredRowId !== null && currentHoveredRowId !== currentRowId) { setRowHoverState(currentHoveredRowId, false); } - // Set hover state for current row - setRowHoverState(rowIdKey, true); - currentHoveredRowId = rowIdKey; + setRowHoverState(currentRowId, true); + currentHoveredRowId = currentRowId; }; const handleMouseLeave = () => { - // Remove hover state - setRowHoverState(rowIdKey, false); - if (currentHoveredRowId === rowIdKey) { + const currentRowId = cellElement.getAttribute("data-row-id") ?? rowIdKey; + setRowHoverState(currentRowId, false); + if (currentHoveredRowId === currentRowId) { currentHoveredRowId = null; } }; @@ -408,10 +377,7 @@ export const createBodyCellElement = ( }; // Lightweight position-only update for scroll operations -export const updateBodyCellPosition = ( - cellElement: HTMLElement, - cell: AbsoluteBodyCell, -): void => { +export const updateBodyCellPosition = (cellElement: HTMLElement, cell: AbsoluteBodyCell): void => { cellElement.style.left = `${cell.left}px`; cellElement.style.top = `${cell.top}px`; cellElement.style.width = `${cell.width}px`; @@ -444,12 +410,19 @@ export const updateBodyCellElement = ( cellElement.setAttribute("data-row-index", String(rowIndex)); cellElement.setAttribute("data-col-index", String(colIndex)); const maxHeaderDepth = context.maxHeaderDepth ?? 1; - cellElement.setAttribute( - "aria-rowindex", - String(cell.tableRow.position + maxHeaderDepth + 1), - ); + cellElement.setAttribute("aria-rowindex", String(cell.tableRow.position + maxHeaderDepth + 1)); cellElement.setAttribute("aria-colindex", String(colIndex + 1)); - cellElement.setAttribute("data-row-id", String(rowId)); + + // Re-key the row hover map when this cell is reused for a different row + // (happens after a sort because the cell DOM node now survives via + // `stableRowKey`). Without this, hovering would highlight the wrong row. + const previousRowId = cellElement.getAttribute("data-row-id"); + const nextRowId = String(rowId); + if (previousRowId && previousRowId !== nextRowId) { + untrackCellByRow(previousRowId, cellElement); + trackCellByRow(nextRowId, cellElement); + } + cellElement.setAttribute("data-row-id", nextRowId); cellElement.setAttribute("data-accessor", String(cell.header.accessor)); // Keep the mutable row ref current so click handlers read fresh data. @@ -458,13 +431,32 @@ export const updateBodyCellElement = ( existingRowRef.current = cell.row as Row; } + // Re-key the cell registry entry when this DOM cell is reused for a + // different row. The registry maps `${positionalRowId}-${accessor}` → + // updateContent for the cell currently rendering that row, so consumers + // (clipboard paste, programmatic API) can address rows by their current + // position. Without this swap, sort would leave stale entries pointing + // at the wrong rows. + if (context.cellRegistry && !cell.header.isSelectionColumn) { + const previousKey = cellRegistryKeyMap.get(cellElement); + const nextKey = `${cell.rowId}-${cell.header.accessor}`; + if (previousKey !== nextKey) { + if (previousKey) { + const previousEntry = context.cellRegistry.get(previousKey); + if (previousEntry) { + context.cellRegistry.delete(previousKey); + context.cellRegistry.set(nextKey, previousEntry); + } + } + cellRegistryKeyMap.set(cellElement, nextKey); + } + } + // Update cell content (important for sorting/filtering where row data changes). // Skip full content replace for expandable cells so the expand icon DOM node is preserved; // then updateExpandIconState can toggle its class and the CSS transition will run. if (!cell.header.expandable) { - const contentSpan = cellElement.querySelector( - ".st-cell-content", - ) as HTMLElement; + const contentSpan = cellElement.querySelector(".st-cell-content") as HTMLElement; if (contentSpan) { contentSpan.innerHTML = ""; createCellContent(cell, context, contentSpan); diff --git a/packages/core/src/utils/bodyCell/types.ts b/packages/core/src/utils/bodyCell/types.ts index 3afa927ca..fcae72f4d 100644 --- a/packages/core/src/utils/bodyCell/types.ts +++ b/packages/core/src/utils/bodyCell/types.ts @@ -24,6 +24,12 @@ export interface AbsoluteBodyCell { rowIndex: number; colIndex: number; rowId: string; + /** + * Position-independent stable key (mirror of `tableRow.stableRowKey`), + * used to compute the cell DOM `id` and the animation snapshot key when + * `getRowId` is provided. Falls back to `rowId` (positional) when absent. + */ + stableRowKey?: string; displayRowNumber: number; depth: number; isOdd: boolean; diff --git a/packages/core/src/utils/bodyCellRenderer.ts b/packages/core/src/utils/bodyCellRenderer.ts index ef4dc4040..d38a59d40 100644 --- a/packages/core/src/utils/bodyCellRenderer.ts +++ b/packages/core/src/utils/bodyCellRenderer.ts @@ -20,6 +20,7 @@ import { import { calculateSeparatorTopPosition } from "./infiniteScrollUtils"; import { DEFAULT_CUSTOM_THEME } from "../types/CustomTheme"; import type TableRow from "../types/TableRow"; +import type { AnimationCoordinator, CellPosition } from "../managers/AnimationCoordinator"; // Re-export types for backward compatibility export type { @@ -243,6 +244,13 @@ const renderRowSeparators = ( // Main render function. When allRows is provided, separators are built from the full row list (including nested grid rows). // When positionOnly is true (e.g. scroll-driven), only positions are updated; content and separators are skipped for performance. +// +// `fullCellLayout` (when provided) maps every cell id this section knows about +// — including rows currently outside the virtualized band — to its destination +// position in the new state. The animation coordinator uses it so cells that +// exit the visible band on sort can slide to their off-screen `top` before +// being removed. Without it, cells leaving the band would be torn down with +// nowhere to slide to and would simply pop out. export const renderBodyCells = ( container: HTMLElement, cells: AbsoluteBodyCell[], @@ -250,6 +258,8 @@ export const renderBodyCells = ( scrollLeft: number = 0, allRows?: TableRow[], positionOnly?: boolean, + animationCoordinator?: AnimationCoordinator, + fullCellLayout?: Map, ): void => { // Get viewport width: for main section use mainSectionContainerWidth to avoid clientWidth read const viewportWidth = context.pinned @@ -268,26 +278,79 @@ export const renderBodyCells = ( const renderedCells = getRenderedCells(container); const renderedSeparators = getRenderedSeparators(container); - // Build set of cell IDs that should be visible + // Build set of cell IDs that should be visible. + // We prefer `stableRowKey` so the same DOM cell survives a sort (so FLIP + // can animate the cell to its new row position rather than tearing it + // down and creating a fresh node in place). const visibleCellIds = new Set( cellsToRender.map((cell) => - getCellId({ accessor: cell.header.accessor, rowId: cell.rowId }), + getCellId({ + accessor: cell.header.accessor, + rowId: cell.stableRowKey ?? cell.rowId, + }), ), ); + // Layout map covering every cell this section knows about (visible rows + // AND rows currently outside the virtualized band). The animation + // coordinator uses it to find a post-change "destination" for cells + // exiting the band so they can slide out to that off-screen position + // before being removed. + // + // Prefer the caller-provided full layout (computed from the section + // snapshot config so it covers every row); otherwise fall back to a + // band-only layout derived from `cells`. + let newCellLayout: Map | null = null; + if (animationCoordinator) { + if (fullCellLayout) { + newCellLayout = fullCellLayout; + } else { + newCellLayout = new Map(); + for (const cell of cells) { + const cellId = getCellId({ + accessor: cell.header.accessor, + rowId: cell.stableRowKey ?? cell.rowId, + }); + newCellLayout.set(cellId, { + left: cell.left, + top: cell.top, + width: cell.width, + height: cell.height, + }); + } + } + } + // Get unique row indices for separator visibility (use full row list when provided so nested rows get separators) const visibleRowIndices = allRows?.length ? new Set(allRows.map((r) => r.position)) : new Set(cellsToRender.map((cell) => cell.rowIndex)); - // Remove cells that are no longer visible + // Remove cells that are no longer visible. When the coordinator wants to + // animate a cell off-screen, we hand it off instead of removing. renderedCells.forEach((element, cellId) => { if (!visibleCellIds.has(cellId)) { - // Untrack from row hover map before removing (stable row id; visual row index can change on scroll) + // Untrack from row hover map (the live row no longer owns this DOM node) const rowIdAttr = element.getAttribute("data-row-id"); if (rowIdAttr) { untrackCellByRow(rowIdAttr, element); } + + const newPos = newCellLayout?.get(cellId); + if (animationCoordinator && animationCoordinator.shouldRetain(cellId) && newPos) { + // Slide the cell to its new conceptual position (which may be + // off-screen — the body's overflow clip handles the visual cutoff) + // and remove it once the slide completes. + animationCoordinator.retainCell({ + cellId, + element, + container, + newPosition: newPos, + }); + renderedCells.delete(cellId); + return; + } + element.remove(); renderedCells.delete(cellId); } @@ -313,7 +376,7 @@ export const renderBodyCells = ( cellsToRender.forEach((cell) => { const cellId = getCellId({ accessor: cell.header.accessor, - rowId: cell.rowId, + rowId: cell.stableRowKey ?? cell.rowId, }); if (!renderedCells.has(cellId)) { @@ -358,8 +421,13 @@ export const renderBodyCells = ( } }); - // Second pass: batch create new cells + // Second pass: batch create new cells. If the snapshot captured this cell's + // pre-change position (e.g. the row was off-screen pre-sort and is now in + // the band), play() will FLIP it from there — no extra hook needed here. cellsToCreate.forEach(({ cell, cellId }) => { + // If a retained out-animating ghost still occupies this cellId, drop it so + // the new "real" cell can take ownership of the id without DOM duplication. + animationCoordinator?.discardRetainedIfPresent(cellId, container); const cellElement = createBodyCellElement(cell, context); fragment.appendChild(cellElement); renderedCells.set(cellId, cellElement); diff --git a/packages/core/src/utils/rowFlattening.ts b/packages/core/src/utils/rowFlattening.ts index 7b235a303..11d4d6c98 100644 --- a/packages/core/src/utils/rowFlattening.ts +++ b/packages/core/src/utils/rowFlattening.ts @@ -4,6 +4,7 @@ import RowState from "../types/RowState"; import TableRow from "../types/TableRow"; import { generateRowId, + generateStableRowKey, rowIdToString, getNestedRows, isRowExpanded, @@ -69,6 +70,17 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, groupingKey: undefined, }); + const stableRowKey = + generateStableRowKey({ + getRowId, + row, + depth: 0, + index, + rowPath, + rowIndexPath, + groupingKey: undefined, + parentStableKey: null, + }) ?? undefined; return { row, @@ -81,6 +93,7 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, absoluteRowIndex: index, isLastGroupRow: false, + stableRowKey, }; }); @@ -106,7 +119,8 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { currentDepth: number, parentIdPath: (string | number)[] = [], parentIndexPath: number[] = [], - parentIndices: number[] = [] + parentIndices: number[] = [], + parentStableKey: string | null = null ): void => { currentRows.forEach((row, index) => { const currentGroupingKey = rowGrouping[currentDepth]; @@ -124,6 +138,17 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, groupingKey: currentGroupingKey, }); + const stableRowKey = + generateStableRowKey({ + getRowId, + row, + depth: currentDepth, + index, + rowPath, + rowIndexPath, + groupingKey: currentGroupingKey, + parentStableKey, + }) ?? undefined; const currentRowIndex = result.length; @@ -139,6 +164,7 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, absoluteRowIndex: position, parentIndices: parentIndices.length > 0 ? [...parentIndices] : undefined, + stableRowKey, }; result.push(mainRow); paginatableRowsBuilder.push(mainRow); @@ -253,10 +279,14 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { } else if (nestedRows.length > 0) { const nestedIdPath = [...rowPath, currentGroupingKey]; const nestedIndexPath = [...rowIndexPath]; - processRows(nestedRows, currentDepth + 1, nestedIdPath, nestedIndexPath, [ - ...parentIndices, - currentRowIndex, - ]); + processRows( + nestedRows, + currentDepth + 1, + nestedIdPath, + nestedIndexPath, + [...parentIndices, currentRowIndex], + stableRowKey ?? null + ); } } diff --git a/packages/core/src/utils/rowUtils.ts b/packages/core/src/utils/rowUtils.ts index 67c3b2532..5671d2876 100644 --- a/packages/core/src/utils/rowUtils.ts +++ b/packages/core/src/utils/rowUtils.ts @@ -48,7 +48,7 @@ export const calculateNestedGridHeight = ({ /** * Calculate the final wrapper height for a nested grid, accounting for custom heights - * + * * @param calculatedHeight - The default calculated height based on child rows * @param customHeight - Optional custom height from nestedTable config (e.g., "200px" or 200) * @param customTheme - Custom theme configuration for padding @@ -65,9 +65,10 @@ export const calculateFinalNestedGridHeight = ({ }): number => { // If a custom height is specified, add padding to get the wrapper height if (customHeight) { - const heightValue = typeof customHeight === "string" - ? parseFloat(customHeight) // Parse "200px" to 200 - : customHeight; + const heightValue = + typeof customHeight === "string" + ? parseFloat(customHeight) // Parse "200px" to 200 + : customHeight; return heightValue + customTheme.nestedGridPaddingTop + customTheme.nestedGridPaddingBottom; } @@ -78,7 +79,7 @@ export const calculateFinalNestedGridHeight = ({ /** * Calculate the inner height for a nested SimpleTable component * This accounts for the padding that's applied to the wrapper - * + * * @param calculatedHeight - The total height of the nested grid row (from calculateNestedGridHeight) * @param customHeight - Optional custom height from nestedTable config (e.g., "200px") * @param customTheme - Custom theme configuration for padding @@ -99,7 +100,8 @@ export const calculateNestedTableHeight = ({ } // Otherwise, calculate from the parent's calculated height minus padding - const innerHeight = calculatedHeight - customTheme.nestedGridPaddingTop - customTheme.nestedGridPaddingBottom; + const innerHeight = + calculatedHeight - customTheme.nestedGridPaddingTop - customTheme.nestedGridPaddingBottom; return `${innerHeight}px`; }; @@ -289,6 +291,51 @@ export const rowIdToString = (rowId: (string | number)[]): string => { return rowId.join("-"); }; +/** + * Generate a position-independent stable row key when `getRowId` is provided. + * + * Unlike `generateRowId`, the stable key never includes positional indices, so + * it survives sort/filter operations. It is used as the basis for the cell DOM + * `id` and the animation coordinator's snapshot key, allowing the same DOM + * element to be reused for the same logical row across re-orders (enabling + * FLIP-based sort animations). + * + * For nested rows the parent's stable key is included as a prefix so siblings + * of different parents do not collide. + * + * Returns `null` when `getRowId` is not provided (callers fall back to the + * positional rowId string in that case). + */ +export const generateStableRowKey = (params: { + getRowId?: GetRowId; + row: Row; + depth: number; + index: number; + rowPath: (string | number)[]; + rowIndexPath: number[]; + groupingKey?: string; + parentStableKey?: string | null; +}): string | null => { + const { getRowId } = params; + if (!getRowId) return null; + + const customId = getRowId({ + row: params.row, + depth: params.depth, + index: params.index, + rowPath: params.rowPath, + rowIndexPath: params.rowIndexPath, + groupingKey: params.groupingKey, + }); + + const customIdStr = String(customId); + const parts: string[] = []; + if (params.parentStableKey) parts.push(params.parentStableKey); + if (params.groupingKey && params.depth > 0) parts.push(params.groupingKey); + parts.push(customIdStr); + return parts.join("/"); +}; + /** * Get nested rows from a row based on the grouping path */ @@ -390,6 +437,7 @@ export const flattenRowsWithGrouping = ({ parentIdPath: (string | number)[] = [], parentIndexPath: number[] = [], parentIndices: number[] = [], + parentStableKey: string | null = null, ): number => { let position = parentPosition; let displayPosition = parentDisplayPosition; @@ -417,6 +465,18 @@ export const flattenRowsWithGrouping = ({ groupingKey: currentGroupingKey, }); + const stableRowKey = + generateStableRowKey({ + getRowId, + row, + depth: currentDepth, + index, + rowPath, + rowIndexPath, + groupingKey: currentGroupingKey, + parentStableKey, + }) ?? undefined; + // Determine if this is the last row in a group const isLastGroupRow = currentDepth === 0 && index === currentRows.length - 1; @@ -436,6 +496,7 @@ export const flattenRowsWithGrouping = ({ rowIndexPath, absoluteRowIndex: position, parentIndices: parentIndices.length > 0 ? [...parentIndices] : undefined, + stableRowKey, }; result.push(tableRow); @@ -472,7 +533,7 @@ export const flattenRowsWithGrouping = ({ expandableHeader.nestedTable.customTheme?.rowHeight || rowHeight; const nestedGridHeaderHeight = expandableHeader.nestedTable.customTheme?.headerHeight || headerHeight; - + // First calculate the default height based on child rows const calculatedHeight = calculateNestedGridHeight({ childRowCount: nestedRows.length, @@ -575,6 +636,7 @@ export const flattenRowsWithGrouping = ({ nestedIdPath, nestedIndexPath, [...parentIndices, currentRowIndex], + stableRowKey ?? null, ); } } diff --git a/packages/core/src/utils/stickyParentsRenderer.ts b/packages/core/src/utils/stickyParentsRenderer.ts index c1eebe551..d458cc0dd 100644 --- a/packages/core/src/utils/stickyParentsRenderer.ts +++ b/packages/core/src/utils/stickyParentsRenderer.ts @@ -287,6 +287,7 @@ const createStickySection = (params: StickySectionParams): HTMLElement => { rowIndex: tableRow.position, colIndex, rowId: rowIdToString(tableRow.rowId), + stableRowKey: tableRow.stableRowKey, displayRowNumber: tableRow.displayPosition, depth: tableRow.depth, isOdd: tableRow.position % 2 === 1, diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index 116caf250..fe4dcb25d 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -9,6 +9,7 @@ import { SALES_HEADERS } from "./sales-headers"; import salesData from "./sales-data.json"; export const salesExampleDefaults = { + animations: true, columnResizing: true, columnReordering: true, selectableCells: true, diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts new file mode 100644 index 000000000..7e84e7f3d --- /dev/null +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -0,0 +1,704 @@ +/** + * CELL ANIMATIONS TESTS + * + * Covers FLIP-style animations on: + * - Programmatic column reorder via the API (cells shift `left`). + * - Sort change (rows shift `top`) when `getRowId` is provided so cell + * identity is row-stable. + * + * Cells that newly enter the visible band slide in from their actual pre- + * change off-screen position; cells that leave the visible band slide out + * to their actual post-change off-screen position. The body container's + * overflow clip turns those long off-screen translates into "appears to + * slide in from the viewport edge" visually. + * + * Animations default to `true`. Live drag reorder is intentionally not + * animated (we don't want to fight the user's pointer mid-drag). + */ + +import { HeaderObject, Row, SimpleTableVanilla } from "../../src/index"; +import { expect } from "@storybook/test"; +import { waitForTable } from "./testUtils"; +import { renderVanillaTable, addParagraph, addControlPanel } from "../utils"; +import type { Meta } from "@storybook/html"; + +const meta: Meta = { + title: "Tests/41 - Cell Animations", + tags: ["animations"], + parameters: { + layout: "fullscreen", + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "FLIP-style animations on programmatic column reorder and sort. Opt-in via the `animations` prop. Honors `prefers-reduced-motion`.", + }, + }, + }, +}; + +export default meta; + +type TableInstance = InstanceType; +const TABLE_REF_KEY = "__storybook_animations_table_ref"; + +const setTable = (table: TableInstance): void => { + (globalThis as unknown as Record)[TABLE_REF_KEY] = table; +}; +const getTable = (): TableInstance => { + const t = (globalThis as unknown as Record)[TABLE_REF_KEY]; + if (!t) throw new Error("Table ref not set (run render first)"); + return t; +}; + +interface AnimRow { + id: number; + name: string; + age: number; + revenue: number; + city: string; +} + +const createData = (): AnimRow[] => [ + { id: 1, name: "Charlie", age: 35, revenue: 50000, city: "Boston" }, + { id: 2, name: "Alice", age: 28, revenue: 75000, city: "Austin" }, + { id: 3, name: "Bob", age: 42, revenue: 60000, city: "Brooklyn" }, + { id: 4, name: "Diana", age: 31, revenue: 90000, city: "Denver" }, + { id: 5, name: "Eve", age: 25, revenue: 45000, city: "Eugene" }, + { id: 6, name: "Frank", age: 38, revenue: 82000, city: "Fresno" }, + { id: 7, name: "Grace", age: 29, revenue: 68000, city: "Greenville" }, + { id: 8, name: "Henry", age: 45, revenue: 95000, city: "Houston" }, +]; + +const createHeaders = (): HeaderObject[] => [ + { accessor: "id", label: "ID", width: 80, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", width: 160, isSortable: true }, + { accessor: "age", label: "Age", width: 100, isSortable: true, type: "number" }, + { accessor: "revenue", label: "Revenue", width: 150, isSortable: true, type: "number" }, + { accessor: "city", label: "City", width: 160, isSortable: true }, +]; + +const findCellByRowAndAccessor = ( + canvasElement: HTMLElement, + rowIndex: number, + accessor: string, +): HTMLElement | null => { + return canvasElement.querySelector( + `.st-body-main [data-row-index="${rowIndex}"][data-accessor="${accessor}"]`, + ) as HTMLElement | null; +}; + +/** Slow on purpose so each step is easy to follow when watching in Storybook. */ +const SLOW_DURATION = 1500; +/** A pause that comfortably outlasts SLOW_DURATION so the animation finishes. */ +const SETTLE_PAUSE = SLOW_DURATION + 400; +/** A short pause to let the user appreciate a new state before the next step. */ +const BEAT = 600; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const tickFrames = async (count: number): Promise => { + for (let i = 0; i < count; i++) { + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + } +}; + +// ============================================================================ +// STORIES +// ============================================================================ + +export const ProgrammaticReorderAnimation = { + render: () => { + const originalHeaders = createHeaders(); + const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { + height: "400px", + animations: true, + animationDuration: SLOW_DURATION, + }); + setTable(result.table); + result.h2.textContent = `Programmatic column reorder · ${SLOW_DURATION}ms per step`; + + addControlPanel( + result.wrapper, + [ + { + heading: "Column order", + buttons: [ + { + label: "Reverse columns", + onClick: () => { + const api = result.table.getAPI(); + const reversed = [...api.getHeaders()].reverse(); + result.table.update({ defaultHeaders: reversed }); + }, + }, + { + label: "Swap Name ↔ City", + onClick: () => { + const api = result.table.getAPI(); + const headers = [...api.getHeaders()]; + const a = headers.findIndex((h) => h.accessor === "name"); + const b = headers.findIndex((h) => h.accessor === "city"); + if (a !== -1 && b !== -1) { + [headers[a], headers[b]] = [headers[b], headers[a]]; + result.table.update({ defaultHeaders: headers }); + } + }, + }, + { + label: "Reset", + onClick: () => { + result.table.update({ defaultHeaders: originalHeaders }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + "Watch each reorder slide cells horizontally to their new positions over " + + `${SLOW_DURATION}ms. The play function reverses, resets, swaps, then resets ` + + "again so you can watch four FLIP runs in a row.", + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + const table = getTable(); + const api = table.getAPI(); + const original = api.getHeaders(); + + // Step 1: capture initial position of "name" cell at row 0. + const cellBefore = findCellByRowAndAccessor(canvasElement, 0, "name"); + expect(cellBefore).toBeTruthy(); + const leftBefore = parseFloat(cellBefore!.style.left || "0"); + + // Step 2: reverse — assert mid-flight transition then settle. + table.update({ defaultHeaders: [...original].reverse() }); + await tickFrames(2); + + const cellMid = findCellByRowAndAccessor(canvasElement, 0, "name"); + expect(cellMid).toBe(cellBefore); + expect(cellMid!.style.transition).toContain("transform"); + expect(cellMid!.style.transform).toContain("translate"); + + await sleep(SETTLE_PAUSE); + + const cellAfter = findCellByRowAndAccessor(canvasElement, 0, "name"); + const leftAfter = parseFloat(cellAfter!.style.left || "0"); + expect(leftAfter).not.toBe(leftBefore); + expect(cellAfter!.style.transform === "" || cellAfter!.style.transform === "none").toBe(true); + expect(cellAfter!.style.transition === "" || cellAfter!.style.transition === "none").toBe(true); + + // Step 3: reset back to original. + await sleep(BEAT); + table.update({ defaultHeaders: original }); + await tickFrames(2); + const cellResetMid = findCellByRowAndAccessor(canvasElement, 0, "name"); + expect(cellResetMid!.style.transition).toContain("transform"); + await sleep(SETTLE_PAUSE); + + // Step 4: swap Name ↔ City — only those two columns animate. + await sleep(BEAT); + const swapped = [...original]; + const a = swapped.findIndex((h) => h.accessor === "name"); + const b = swapped.findIndex((h) => h.accessor === "city"); + [swapped[a], swapped[b]] = [swapped[b], swapped[a]]; + table.update({ defaultHeaders: swapped }); + await tickFrames(2); + const animating = Array.from( + canvasElement.querySelectorAll(".st-body-main .st-cell"), + ).filter((el) => el.style.transition.includes("transform")); + expect(animating.length).toBeGreaterThan(0); + await sleep(SETTLE_PAUSE); + + // Step 5: final reset. + await sleep(BEAT); + table.update({ defaultHeaders: original }); + await sleep(SETTLE_PAUSE); + + const cellFinal = findCellByRowAndAccessor(canvasElement, 0, "name"); + expect(parseFloat(cellFinal!.style.left || "0")).toBe(leftBefore); + }, +}; + +export const ReorderWithoutAnimations = { + render: () => { + const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { + height: "400px", + animations: false, + }); + setTable(result.table); + result.h2.textContent = "Programmatic column reorder (animations: off, default)"; + + addControlPanel( + result.wrapper, + [ + { + heading: "Column order", + buttons: [ + { + label: "Reverse columns", + onClick: () => { + const api = result.table.getAPI(); + const reversed = [...api.getHeaders()].reverse(); + result.table.update({ defaultHeaders: reversed }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + "Click 'Reverse columns'. Cells teleport to their new positions (no animation).", + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const table = getTable(); + + const api = table.getAPI(); + const reversed = [...api.getHeaders()].reverse(); + table.update({ defaultHeaders: reversed }); + + await new Promise((r) => setTimeout(r, 50)); + const cells = canvasElement.querySelectorAll(".st-body-main .st-cell"); + expect(cells.length).toBeGreaterThan(0); + cells.forEach((cell) => { + const t = cell.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + }, +}; + +export const SortAnimationDemo = { + render: () => { + const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { + height: "400px", + animations: true, + animationDuration: SLOW_DURATION, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Sort with animations on · ${SLOW_DURATION}ms per step`; + addParagraph( + result.wrapper, + "Click a sortable header — rows slide vertically to their new positions. " + + "The play function toggles sort across several columns and asserts that " + + "the SAME DOM cell follows each row to its new position, with no " + + "leftover transforms or ghost nodes after the animation settles.", + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + // Find Charlie's name cell (id=1) and Alice's name cell (id=2). They start + // unsorted with Charlie above Alice. Sorting Name asc will swap them; we + // assert the SAME DOM nodes survive the swap (proving stable cellId). + const findNameCellByText = (text: string): HTMLElement | null => { + const cells = canvasElement.querySelectorAll( + '.st-body-main [data-accessor="name"]', + ); + for (const cell of Array.from(cells)) { + if (cell.textContent?.trim() === text) return cell; + } + return null; + }; + + const charlieCellBefore = findNameCellByText("Charlie"); + const aliceCellBefore = findNameCellByText("Alice"); + expect(charlieCellBefore).toBeTruthy(); + expect(aliceCellBefore).toBeTruthy(); + const charlieTopBefore = parseFloat(charlieCellBefore!.style.top || "0"); + const aliceTopBefore = parseFloat(aliceCellBefore!.style.top || "0"); + expect(charlieTopBefore).toBeLessThan(aliceTopBefore); + + // CRITICAL: trigger the sort synchronously and read the FLIP "First" + // frame *immediately* (no awaits, no RAFs). The coordinator sets + // `transform: translate3d(0, dy, 0)` synchronously inside play() then + // schedules a RAF that resets it to translate3d(0,0,0) before the + // visible transition starts. Awaiting any RAF here would only ever + // surface the destination state. + const table = getTable(); + void table.getAPI().applySortState({ accessor: "name", direction: "asc" }); + + const charlieMid = findNameCellByText("Charlie"); + const aliceMid = findNameCellByText("Alice"); + expect(charlieMid).toBe(charlieCellBefore); + expect(aliceMid).toBe(aliceCellBefore); + const charlieDy = parseTranslateY(charlieMid!.style.transform); + const aliceDy = parseTranslateY(aliceMid!.style.transform); + expect(Math.abs(charlieDy)).toBeGreaterThan(0.5); + expect(Math.abs(aliceDy)).toBeGreaterThan(0.5); + expect(Math.sign(charlieDy)).not.toBe(Math.sign(aliceDy)); + + // Once the FLIP "Play" RAF has fired, both cells should have the + // transform transition CSS applied so the slide actually animates. + await tickFrames(2); + expect(charlieMid!.style.transition).toContain("transform"); + expect(aliceMid!.style.transition).toContain("transform"); + + await sleep(SETTLE_PAUSE); + + // After settle: same DOM nodes, swapped tops, no leftover transforms. + const charlieAfter = findNameCellByText("Charlie"); + const aliceAfter = findNameCellByText("Alice"); + expect(charlieAfter).toBe(charlieCellBefore); + expect(aliceAfter).toBe(aliceCellBefore); + expect(parseFloat(charlieAfter!.style.top || "0")).toBeGreaterThan( + parseFloat(aliceAfter!.style.top || "0"), + ); + expect(charlieAfter!.style.transform === "" || charlieAfter!.style.transform === "none").toBe( + true, + ); + expect(aliceAfter!.style.transform === "" || aliceAfter!.style.transform === "none").toBe(true); + + // Cycle through more sort columns, asserting clean state between steps. + const sortSequence: Array<{ accessor: string; direction: "asc" | "desc" }> = [ + { accessor: "age", direction: "asc" }, + { accessor: "revenue", direction: "desc" }, + { accessor: "city", direction: "asc" }, + { accessor: "name", direction: "desc" }, + ]; + for (const sort of sortSequence) { + await sleep(BEAT); + void table.getAPI().applySortState(sort); + await sleep(SETTLE_PAUSE); + + const cells = canvasElement.querySelectorAll(".st-body-main .st-cell"); + expect(cells.length).toBeGreaterThan(0); + cells.forEach((cell) => { + const t = cell.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + const ghosts = canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`); + expect(ghosts.length).toBe(0); + } + }, +}; + +export const AnimationsPropWiring = { + render: () => { + const { wrapper, h2 } = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { + height: "400px", + animations: true, + }); + h2.textContent = "Smoke: animations prop accepted"; + addParagraph(wrapper, "Verifies the animations prop is plumbed through without error."); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const root = canvasElement.querySelector(".simple-table-root"); + expect(root).toBeTruthy(); + }, +}; + +/** + * Cells should animate from THEIR PREVIOUS position, not all from the same + * direction. This story snapshots every cell's pre-reorder geometry and then + * verifies, mid-flight, that: + * + * 1. Cells that move RIGHT (new left > old left) start with a NEGATIVE + * transform-X (so they appear at their previous-left and slide rightward). + * 2. Cells that move LEFT (new left < old left) start with a POSITIVE + * transform-X (so they appear at their previous-right and slide leftward). + * 3. Both directions appear in the same animation tick — no "everyone slides + * from the same edge" regression. + * 4. The translate-X delta for each cell exactly equals (oldLeft - newLeft), + * so the FLIP "First" frame really lands at the previous on-screen pixel + * position rather than at some shared anchor. + */ +export const ReorderAnimatesFromPreviousPositionPerCell = { + render: () => { + const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { + height: "400px", + animations: true, + animationDuration: SLOW_DURATION, + }); + setTable(result.table); + result.h2.textContent = "Reorder animates from each cell's previous position"; + addParagraph( + result.wrapper, + "Catches a regression where every cell would animate from the same edge " + + "instead of from its actual previous position.", + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const accessors = ["id", "name", "age", "revenue", "city"]; + const ROW_INDEX = 0; + + const beforeLefts = new Map(); + for (const accessor of accessors) { + const cell = findCellByRowAndAccessor(canvasElement, ROW_INDEX, accessor); + expect(cell).toBeTruthy(); + beforeLefts.set(accessor, parseFloat(cell!.style.left || "0")); + } + const distinctBefore = new Set(beforeLefts.values()); + expect(distinctBefore.size).toBe(accessors.length); + + const table = getTable(); + const original = table.getAPI().getHeaders(); + table.update({ defaultHeaders: [...original].reverse() }); + + // CRITICAL: read the FLIP "First" frame *synchronously* — the coordinator + // sets `transform: translate3d(dx, dy, 0)` inside play() then schedules a + // RAF to reset to translate3d(0,0,0). Waiting any RAF would only ever + // surface the destination (0,0,0). + const samples: Array<{ + accessor: string; + oldLeft: number; + newLeft: number; + txX: number; + direction: "right" | "left" | "still"; + }> = []; + + for (const accessor of accessors) { + const cell = findCellByRowAndAccessor(canvasElement, ROW_INDEX, accessor); + expect(cell).toBeTruthy(); + + const newLeft = parseFloat(cell!.style.left || "0"); + const oldLeft = beforeLefts.get(accessor)!; + const txX = parseTranslateX(cell!.style.transform); + + let direction: "right" | "left" | "still" = "still"; + if (newLeft - oldLeft > 0.5) direction = "right"; + else if (newLeft - oldLeft < -0.5) direction = "left"; + + samples.push({ accessor, oldLeft, newLeft, txX, direction }); + } + + const expectedDx = (s: { oldLeft: number; newLeft: number }) => s.oldLeft - s.newLeft; + const summary = samples + .map( + (s) => + `${s.accessor}: old=${s.oldLeft} new=${s.newLeft} ` + + `expectedDx=${expectedDx(s)} actualDx=${s.txX} dir=${s.direction}`, + ) + .join(" | "); + for (const s of samples) { + const expected = expectedDx(s); + const diff = Math.abs(s.txX - expected); + if (diff >= 1.5) { + throw new Error(`FLIP dx mismatch for "${s.accessor}". ${summary}`); + } + } + + const movedRight = samples.filter((s) => s.direction === "right"); + const movedLeft = samples.filter((s) => s.direction === "left"); + expect(movedRight.length).toBeGreaterThan(0); + expect(movedLeft.length).toBeGreaterThan(0); + + for (const s of movedRight) { + expect(s.txX).toBeLessThan(-0.5); + } + for (const s of movedLeft) { + expect(s.txX).toBeGreaterThan(0.5); + } + + const txValues = samples.map((s) => Math.round(s.txX)); + const distinctTx = new Set(txValues); + expect(distinctTx.size).toBeGreaterThan(1); + + // Once the FLIP "Play" RAF has fired, every cell should report the + // transition CSS so the animation is actually running. + await tickFrames(2); + for (const accessor of accessors) { + const cell = findCellByRowAndAccessor(canvasElement, ROW_INDEX, accessor); + expect(cell!.style.transition).toContain("transform"); + } + + await sleep(SETTLE_PAUSE); + + for (const accessor of accessors) { + const cell = findCellByRowAndAccessor(canvasElement, ROW_INDEX, accessor); + expect(cell!.style.transform === "" || cell!.style.transform === "none").toBe(true); + } + }, +}; + +/** + * Off-screen rows still slide. When the viewport only renders a slice of + * all rows, sorting causes some cells to enter the visible band (rows that + * were below the fold pre-sort) and others to leave it (rows that were on + * screen pre-sort). The coordinator captures positions for ALL rows in the + * dataset, so: + * + * - Incoming cells get created at their new (visible) position and FLIP + * in from their actual pre-change off-screen `top`. Visually the cell + * appears to slide in from the viewport edge. + * - Outgoing cells get retained at their pre-change visible position and + * slide to their actual post-change off-screen `top`, then are removed. + * Visually the cell appears to slide out past the viewport edge. + * + * Both kinds of slides use `transform: translate3d` (no opacity). + */ +export const SortSlidesRowsCrossingTheViewportBoundary = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 80, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", width: 200, isSortable: true }, + { accessor: "value", label: "Value", width: 150, isSortable: true, type: "number" }, + ]; + const rows: Row[] = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + name: `Row ${i + 1}`, + value: (i * 37) % 1000, + })); + const result = renderVanillaTable(headers, rows, { + height: "300px", + animations: true, + animationDuration: SLOW_DURATION, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Sort slides rows across the viewport boundary · ${SLOW_DURATION}ms`; + addControlPanel( + result.wrapper, + [ + { + heading: "Sort", + buttons: [ + { + label: "Sort by id (desc)", + onClick: () => { + void result.table.getAPI().applySortState({ accessor: "id", direction: "desc" }); + }, + }, + { + label: "Sort by id (asc)", + onClick: () => { + void result.table.getAPI().applySortState({ accessor: "id", direction: "asc" }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + addParagraph( + result.wrapper, + "Only ~10 rows fit in the viewport. Sorting flips the order entirely so " + + "every visible row is replaced. Incoming rows slide in from below the " + + "viewport, outgoing rows slide down past the viewport edge. Pure " + + "transform slides; no opacity tricks.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const beforeIds = new Set( + Array.from( + canvasElement.querySelectorAll('.st-body-main [data-accessor="id"]'), + ).map((c) => (c.textContent ?? "").trim()), + ); + expect(beforeIds.size).toBeGreaterThan(0); + expect(beforeIds.has("1")).toBe(true); + expect(beforeIds.has("100")).toBe(false); + + const table = getTable(); + // Trigger sort synchronously and inspect the FLIP "First" frame *before* + // any RAFs run. play() sets `transform: translate3d(...)` synchronously + // then schedules a RAF that resets it to translate3d(0,0,0) and applies + // the transition CSS. + void table.getAPI().applySortState({ accessor: "id", direction: "desc" }); + + // Outgoing cells should be retained as ghosts (with non-zero translate) + // sliding to their new off-screen positions. + const ghostsImmediately = canvasElement.querySelectorAll( + `[data-animating-out="true"]`, + ); + expect(ghostsImmediately.length).toBeGreaterThan(0); + ghostsImmediately.forEach((el) => { + expect(el.style.transform).toContain("translate"); + // No fades. We only ever touched transform. + expect(el.style.opacity === "" || el.style.opacity === "1").toBe(true); + }); + + // Incoming cells (new ids that weren't in the DOM pre-sort) should also + // carry a non-zero FLIP "First" translate, sliding in from off-screen. + const incomingImmediately = Array.from( + canvasElement.querySelectorAll('.st-body-main [data-accessor="id"]'), + ).filter((el) => !beforeIds.has((el.textContent ?? "").trim())); + expect(incomingImmediately.length).toBeGreaterThan(0); + for (const el of incomingImmediately) { + expect(el.style.transform).toContain("translate"); + expect(el.style.opacity === "" || el.style.opacity === "1").toBe(true); + } + + // After play()'s RAF fires, transitions are applied — and only on + // transform, never on opacity. + await tickFrames(2); + canvasElement + .querySelectorAll(`[data-animating-out="true"]`) + .forEach((el) => { + expect(el.style.transition).toContain("transform"); + expect(el.style.transition).not.toContain("opacity"); + }); + + await sleep(SETTLE_PAUSE); + + const afterIds = new Set( + Array.from( + canvasElement.querySelectorAll('.st-body-main [data-accessor="id"]'), + ).map((c) => (c.textContent ?? "").trim()), + ); + expect(afterIds.size).toBeGreaterThan(0); + expect(afterIds.has("100")).toBe(true); + expect(afterIds.has("1")).toBe(false); + + // No leftover ghosts, transforms, or transitions on settled cells. + const leftoverGhosts = canvasElement.querySelectorAll( + `[data-animating-out="true"]`, + ); + expect(leftoverGhosts.length).toBe(0); + canvasElement.querySelectorAll(".st-body-main .st-cell").forEach((cell) => { + const t = cell.style.transform; + expect(t === "" || t === "none").toBe(true); + const o = cell.style.opacity; + expect(o === "" || o === "1").toBe(true); + }); + }, +}; + +const parseTranslateX = (transform: string): number => { + if (!transform || transform === "none") return 0; + const match = + transform.match(/translate3d\(\s*(-?\d+(?:\.\d+)?)px/) ?? + transform.match(/translate\(\s*(-?\d+(?:\.\d+)?)px/) ?? + transform.match(/matrix\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*(-?\d+(?:\.\d+)?)/); + return match ? parseFloat(match[1]) : 0; +}; + +const parseTranslateY = (transform: string): number => { + if (!transform || transform === "none") return 0; + const t3d = transform.match(/translate3d\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/); + if (t3d) return parseFloat(t3d[1]); + const t2 = transform.match(/translate\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/); + if (t2) return parseFloat(t2[1]); + const matrix = transform.match( + /matrix\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*(-?\d+(?:\.\d+)?)/, + ); + return matrix ? parseFloat(matrix[1]) : 0; +}; diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts new file mode 100644 index 000000000..8e7caa79b --- /dev/null +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -0,0 +1,808 @@ +/** + * CELL ANIMATIONS — VIRTUALIZATION & SCALE TESTS (slow & visible) + * + * Stress-tests the FLIP animation coordinator at scale (500 rows × 30 cols + * in a constrained viewport) with `animationDuration` cranked up so the + * play function is *visible* when watched in Storybook. Each play function + * runs many sequential interactions with explicit pauses between them so + * you can see each animation phase fire. + * + * Slides apply both within and across the visible band: + * - Persistent cells (visible before AND after) slide from old → new. + * - Incoming cells (off-screen before, in DOM after) FLIP in from their + * true pre-change off-screen position, clipped by the body's overflow. + * - Outgoing cells (in DOM before, off-screen after) are retained and + * slide to their true post-change off-screen position before being + * removed — visually they appear to slide out past the viewport edge. + * + * Note (v1): horizontal "virtualization" via `getVisibleBodyCells` is + * effectively a no-op because `mainSectionContainerWidth` is the sum of + * main column widths (content), not the visible viewport. All non-pinned + * columns within the row band stay in the DOM. + */ + +import { HeaderObject, SimpleTableVanilla, Row } from "../../src/index"; +import { expect } from "@storybook/test"; +import { waitForTable } from "./testUtils"; +import { addParagraph, addControlPanel } from "../utils"; +import type { Meta } from "@storybook/html"; + +const meta: Meta = { + title: "Tests/42 - Cell Animations Virtualization", + tags: ["animations"], + parameters: { + layout: "fullscreen", + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Scale + virtualization stress-tests for the FLIP animation coordinator, " + + "with deliberately slow animations so each step is visible.", + }, + }, + }, +}; + +export default meta; + +type TableInstance = InstanceType; +const TABLE_REF_KEY = "__storybook_animations_v_table_ref"; + +const setTable = (table: TableInstance): void => { + (globalThis as unknown as Record)[TABLE_REF_KEY] = table; +}; +const getTable = (): TableInstance => { + const t = (globalThis as unknown as Record)[TABLE_REF_KEY]; + if (!t) throw new Error("Table ref not set (run render first)"); + return t; +}; + +interface BigRow { + id: string; + [accessor: string]: string | number; +} + +const COLUMN_COUNT = 30; +const ROW_COUNT = 500; +const COLUMN_WIDTH = 220; +const VIEWPORT_WIDTH = 800; +const VIEWPORT_HEIGHT = 500; +/** Slow on purpose so each animation is easy to follow when watching. */ +const SLOW_DURATION = 1500; +/** A pause that comfortably outlasts SLOW_DURATION so the animation finishes. */ +const SETTLE_PAUSE = SLOW_DURATION + 400; +/** A short pause to let the user appreciate a new state before the next step. */ +const BEAT = 600; + +interface RenderResult { + wrapper: HTMLElement; + h2: HTMLHeadingElement; + status: HTMLElement; + tableContainer: HTMLElement; + table: TableInstance; +} + +const renderConstrainedTable = ( + headers: HeaderObject[], + data: Row[], + options: Record, +): RenderResult => { + const wrapper = document.createElement("div"); + wrapper.style.padding = "2rem"; + + const h2 = document.createElement("h2"); + h2.style.marginBottom = "0.5rem"; + wrapper.appendChild(h2); + + const status = document.createElement("div"); + status.style.cssText = + "margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: #f4f6fb; " + + "border: 1px solid #d6dbe7; border-radius: 6px; font-family: system-ui, sans-serif; " + + "font-size: 0.85rem; color: #31374a; min-height: 1.2em;"; + status.textContent = "Idle."; + wrapper.appendChild(status); + + const tableContainer = document.createElement("div"); + tableContainer.style.width = `${VIEWPORT_WIDTH}px`; + tableContainer.style.maxWidth = `${VIEWPORT_WIDTH}px`; + wrapper.appendChild(tableContainer); + + const table = new SimpleTableVanilla(tableContainer, { + defaultHeaders: headers, + rows: data, + height: `${VIEWPORT_HEIGHT}px`, + animations: true, + animationDuration: SLOW_DURATION, + ...options, + }); + table.mount(); + + return { wrapper, h2, status, tableContainer, table }; +}; + +const createHeaders = (): HeaderObject[] => { + const headers: HeaderObject[] = [{ accessor: "id", label: "ID", width: 100, isSortable: true }]; + for (let i = 0; i < COLUMN_COUNT; i++) { + headers.push({ + accessor: `col_${i}`, + label: `Col ${i}`, + width: COLUMN_WIDTH, + isSortable: true, + type: "number", + }); + } + return headers; +}; + +const createData = (): BigRow[] => { + const rows: BigRow[] = []; + for (let r = 0; r < ROW_COUNT; r++) { + const row: BigRow = { id: `row-${r}` }; + for (let c = 0; c < COLUMN_COUNT; c++) { + row[`col_${c}`] = r * 100 + c; + } + rows.push(row); + } + return rows; +}; + +const findScroller = (canvasElement: HTMLElement): HTMLElement | null => { + return ( + canvasElement.querySelector(".st-body-container") ?? + canvasElement.querySelector(".st-body-main") + ); +}; + +const tickFrames = async (count: number): Promise => { + for (let i = 0; i < count; i++) { + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + } +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const announce = (status: HTMLElement, msg: string): void => { + status.textContent = msg; +}; + +const countAnimating = (canvasElement: HTMLElement): number => { + return Array.from(canvasElement.querySelectorAll(`.st-body-main .st-cell`)).filter( + (el) => el.style.transition.includes("transform") && el.style.transform.includes("translate"), + ).length; +}; + +const countGhosts = (canvasElement: HTMLElement): number => { + return canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`).length; +}; + +const findCellByRowIndexAndAccessor = ( + canvasElement: HTMLElement, + rowIndex: number, + accessor: string, +): HTMLElement | null => { + return canvasElement.querySelector( + `.st-body-main [data-row-index="${rowIndex}"][data-accessor="${accessor}"]`, + ) as HTMLElement | null; +}; + +const parseTranslateX = (transform: string): number => { + if (!transform || transform === "none") return 0; + const match = + transform.match(/translate3d\(\s*(-?\d+(?:\.\d+)?)px/) ?? + transform.match(/translate\(\s*(-?\d+(?:\.\d+)?)px/) ?? + transform.match(/matrix\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*(-?\d+(?:\.\d+)?)/); + return match ? parseFloat(match[1]) : 0; +}; + +// ============================================================================ +// STORIES +// ============================================================================ + +/** + * Slow column reorder marathon: reverse → reset → swap pair → reset, with a + * BEAT pause between each step so each animation phase is clearly visible. + * Asserts that >50 cells get a `transform` transition mid-flight after each + * reorder, and that everything settles cleanly between steps. + */ +export const SlowColumnReorderMarathon = { + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Slow column reorder marathon — ${COLUMN_COUNT} cols × ${ROW_COUNT} rows · ${SLOW_DURATION}ms per step`; + + addControlPanel( + result.wrapper, + [ + { + heading: "Column order", + buttons: [ + { + label: "Reverse", + onClick: () => { + const api = result.table.getAPI(); + const reversed = [...api.getHeaders()].reverse(); + result.table.update({ defaultHeaders: reversed }); + }, + }, + { + label: "Swap col_0 ↔ col_5", + onClick: () => { + const api = result.table.getAPI(); + const headers = [...api.getHeaders()]; + const a = headers.findIndex((h) => h.accessor === "col_0"); + const b = headers.findIndex((h) => h.accessor === "col_5"); + if (a !== -1 && b !== -1) { + [headers[a], headers[b]] = [headers[b], headers[a]]; + result.table.update({ defaultHeaders: headers }); + } + }, + }, + { + label: "Shuffle", + onClick: () => { + const api = result.table.getAPI(); + const headers = [...api.getHeaders()]; + for (let i = headers.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [headers[i], headers[j]] = [headers[j], headers[i]]; + } + result.table.update({ defaultHeaders: headers }); + }, + }, + { + label: "Reset", + onClick: () => { + result.table.update({ defaultHeaders: createHeaders() }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + `Each reorder animates over ${SLOW_DURATION}ms. The status banner above the table ` + + `narrates the play function so you can watch each step.`, + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + const api = table.getAPI(); + const original = api.getHeaders(); + + announce(status, "Step 1/5 · Reversing all columns…"); + table.update({ defaultHeaders: [...original].reverse() }); + await tickFrames(2); + expect(countAnimating(canvasElement)).toBeGreaterThan(50); + await sleep(SETTLE_PAUSE); + expect(countGhosts(canvasElement)).toBe(0); + + announce(status, "Step 2/5 · Resetting to original order…"); + await sleep(BEAT); + table.update({ defaultHeaders: original }); + await tickFrames(2); + expect(countAnimating(canvasElement)).toBeGreaterThan(50); + await sleep(SETTLE_PAUSE); + + // STRICT step: swap two cells that are BOTH visible in the viewport so we + // can synchronously assert the FLIP "First" frame's transform-X exactly + // equals (oldLeft - newLeft) — catches regressions where cells animate + // from a wrong anchor. + announce(status, "Step 3/5 · Strict contained swap col_0 ↔ col_2…"); + await sleep(BEAT); + const cellA = findCellByRowIndexAndAccessor(canvasElement, 0, "col_0"); + const cellB = findCellByRowIndexAndAccessor(canvasElement, 0, "col_2"); + expect(cellA).toBeTruthy(); + expect(cellB).toBeTruthy(); + const aOldLeft = parseFloat(cellA!.style.left || "0"); + const bOldLeft = parseFloat(cellB!.style.left || "0"); + expect(aOldLeft).not.toBe(bOldLeft); + + const swapped = [...original]; + const ai = swapped.findIndex((h) => h.accessor === "col_0"); + const bi = swapped.findIndex((h) => h.accessor === "col_2"); + [swapped[ai], swapped[bi]] = [swapped[bi], swapped[ai]]; + table.update({ defaultHeaders: swapped }); + + const cellAAfter = findCellByRowIndexAndAccessor(canvasElement, 0, "col_0"); + const cellBAfter = findCellByRowIndexAndAccessor(canvasElement, 0, "col_2"); + const aNewLeft = parseFloat(cellAAfter!.style.left || "0"); + const bNewLeft = parseFloat(cellBAfter!.style.left || "0"); + expect(aNewLeft).toBeCloseTo(bOldLeft, 0); + expect(bNewLeft).toBeCloseTo(aOldLeft, 0); + + const aTx = parseTranslateX(cellAAfter!.style.transform); + const bTx = parseTranslateX(cellBAfter!.style.transform); + expect(Math.abs(aTx - (aOldLeft - aNewLeft))).toBeLessThan(1.5); + expect(Math.abs(bTx - (bOldLeft - bNewLeft))).toBeLessThan(1.5); + expect(Math.sign(aTx)).not.toBe(Math.sign(bTx)); + expect(aTx).not.toBe(0); + expect(bTx).not.toBe(0); + await sleep(SETTLE_PAUSE); + + announce(status, "Step 4/5 · Reset after contained swap…"); + await sleep(BEAT); + table.update({ defaultHeaders: original }); + await sleep(SETTLE_PAUSE); + + announce(status, "Step 5/5 · Final reset (no-op)…"); + await sleep(BEAT); + table.update({ defaultHeaders: original }); + await sleep(SETTLE_PAUSE); + + announce(status, "Done."); + expect(countGhosts(canvasElement)).toBe(0); + }, +}; + +/** + * Visually obvious "two cells trading places" demo. Uses neighbour columns + * that are both fully inside the viewport (so the FLIP motion is contained + * on-screen, no off-screen sweeps). The play function: + * + * 1. Captures both cells' pre-swap left positions. + * 2. Calls `table.update({ defaultHeaders: swapped })`. + * 3. Synchronously asserts each cell's FLIP transform-X equals + * (oldLeft - newLeft) — i.e. cell A starts where B was, cell B starts + * where A was, and they slide toward each other in opposite directions. + * + * If a future change causes cells to animate from a single shared anchor + * (e.g. the right edge), this story fails immediately. + */ +export const ContainedNeighborSwapAnimation = { + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = "Two cells trading places · contained neighbour swap"; + + addControlPanel( + result.wrapper, + [ + { + heading: "Swap visible neighbours", + buttons: [ + { + label: "Swap col_0 ↔ col_2", + onClick: () => { + const headers = [...result.table.getAPI().getHeaders()]; + const a = headers.findIndex((h) => h.accessor === "col_0"); + const b = headers.findIndex((h) => h.accessor === "col_2"); + if (a !== -1 && b !== -1) { + [headers[a], headers[b]] = [headers[b], headers[a]]; + result.table.update({ defaultHeaders: headers }); + } + }, + }, + { + label: "Reset", + onClick: () => { + result.table.update({ defaultHeaders: createHeaders() }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + "col_0 and col_2 are both inside the 800px viewport. Swapping them shows " + + "two cells sliding toward each other — proof that each cell starts at the " + + "OTHER cell's previous position rather than at a shared edge.", + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + + const table = getTable(); + const original = table.getAPI().getHeaders(); + + const ROW_INDEX = 0; + const ACC_A = "col_0"; + const ACC_B = "col_2"; + + const before = (acc: string): number => { + const c = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, acc); + expect(c).toBeTruthy(); + return parseFloat(c!.style.left || "0"); + }; + const aOldLeft = before(ACC_A); + const bOldLeft = before(ACC_B); + expect(aOldLeft).not.toBe(bOldLeft); + expect(Math.abs(bOldLeft - aOldLeft)).toBeGreaterThan(50); + + announce(status, `Swapping ${ACC_A} ↔ ${ACC_B}…`); + const swapped = [...original]; + const ai = swapped.findIndex((h) => h.accessor === ACC_A); + const bi = swapped.findIndex((h) => h.accessor === ACC_B); + [swapped[ai], swapped[bi]] = [swapped[bi], swapped[ai]]; + table.update({ defaultHeaders: swapped }); + + const cellA = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, ACC_A)!; + const cellB = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, ACC_B)!; + const aNewLeft = parseFloat(cellA.style.left || "0"); + const bNewLeft = parseFloat(cellB.style.left || "0"); + + expect(aNewLeft).toBeCloseTo(bOldLeft, 0); + expect(bNewLeft).toBeCloseTo(aOldLeft, 0); + + const aTx = parseTranslateX(cellA.style.transform); + const bTx = parseTranslateX(cellB.style.transform); + + const expectedATx = aOldLeft - aNewLeft; + const expectedBTx = bOldLeft - bNewLeft; + if (Math.abs(aTx - expectedATx) >= 1.5 || Math.abs(bTx - expectedBTx) >= 1.5) { + throw new Error( + `Swap FLIP transforms wrong. ` + + `${ACC_A}: oldLeft=${aOldLeft} newLeft=${aNewLeft} expectedTx=${expectedATx} actualTx=${aTx}. ` + + `${ACC_B}: oldLeft=${bOldLeft} newLeft=${bNewLeft} expectedTx=${expectedBTx} actualTx=${bTx}.`, + ); + } + + expect(Math.sign(aTx)).not.toBe(Math.sign(bTx)); + expect(Math.abs(aTx)).toBeGreaterThan(50); + expect(Math.abs(bTx)).toBeGreaterThan(50); + + await sleep(SETTLE_PAUSE); + announce(status, "Done."); + expect(cellA.style.transform === "" || cellA.style.transform === "none").toBe(true); + expect(cellB.style.transform === "" || cellB.style.transform === "none").toBe(true); + }, +}; + +/** + * Vertical scroll → reorder → vertical scroll → reorder, demonstrating that + * the snapshot reflects the *currently visible* row band each time. + */ +export const ReorderAtMultipleScrollPositions = { + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Reorder at multiple scroll positions · ${SLOW_DURATION}ms per step`; + + addControlPanel( + result.wrapper, + [ + { + heading: "Step", + buttons: [ + { + label: "Scroll → top", + onClick: () => { + const sc = findScroller(result.tableContainer); + if (sc) sc.scrollTop = 0; + }, + }, + { + label: "Scroll → mid (4000px)", + onClick: () => { + const sc = findScroller(result.tableContainer); + if (sc) sc.scrollTop = 4000; + }, + }, + { + label: "Scroll → bottom", + onClick: () => { + const sc = findScroller(result.tableContainer); + if (sc) sc.scrollTop = sc.scrollHeight; + }, + }, + { + label: "Reverse columns", + onClick: () => { + const api = result.table.getAPI(); + const reversed = [...api.getHeaders()].reverse(); + result.table.update({ defaultHeaders: reversed }); + }, + }, + { + label: "Reset all", + onClick: () => { + const sc = findScroller(result.tableContainer); + if (sc) sc.scrollTop = 0; + result.table.update({ defaultHeaders: createHeaders() }); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + "Scrolls to several positions, reordering at each. Each animation runs at " + + `${SLOW_DURATION}ms so the FLIP transitions are obvious.`, + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + const api = table.getAPI(); + const original = api.getHeaders(); + const scroller = findScroller(canvasElement); + expect(scroller).toBeTruthy(); + + const positions: Array<{ label: string; top: number }> = [ + { label: "top", top: 0 }, + { label: "mid (4000px)", top: 4000 }, + { label: "deep (10000px)", top: 10000 }, + ]; + + let order = original; + for (let i = 0; i < positions.length; i++) { + const { label, top } = positions[i]; + announce(status, `Step ${i + 1}/${positions.length} · Scrolling to ${label}…`); + scroller!.scrollTop = top; + await sleep(400); + + announce(status, `Step ${i + 1}/${positions.length} · Reversing columns at ${label}…`); + order = [...order].reverse(); + table.update({ defaultHeaders: order }); + await tickFrames(2); + expect(countAnimating(canvasElement)).toBeGreaterThan(50); + await sleep(SETTLE_PAUSE); + expect(countGhosts(canvasElement)).toBe(0); + } + + announce(status, "Restoring scroll & order…"); + scroller!.scrollTop = 0; + table.update({ defaultHeaders: original }); + await sleep(SETTLE_PAUSE); + announce(status, "Done."); + expect(countGhosts(canvasElement)).toBe(0); + }, +}; + +/** + * Strict per-cell FLIP correctness check at scale: when reversing 30 columns, + * every sampled cell's transform on the synchronously-set "First" frame must + * exactly equal `oldLeft - newLeft`. Catches regressions where the snapshot + * is captured against the post-mutation layout, where preLayouts overwrites + * live DOM positions, or where some cells get skipped from the FLIP pass. + */ +export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Strict FLIP correctness · ${COLUMN_COUNT} cols × ${ROW_COUNT} rows`; + addParagraph( + result.wrapper, + "Reverses the columns, then synchronously reads each sampled cell's transform " + + "and asserts it equals (oldLeft - newLeft). Cells that move in different " + + "directions must produce transforms with opposite signs.", + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + + const sampledAccessors = [ + "id", + "col_0", + "col_3", + "col_10", + "col_15", + "col_20", + "col_25", + "col_29", + ]; + const ROW_INDEX = 0; + + const beforeLefts = new Map(); + for (const accessor of sampledAccessors) { + const cell = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, accessor); + if (!cell) continue; + beforeLefts.set(accessor, parseFloat(cell.style.left || "0")); + } + if (beforeLefts.size < 2) { + throw new Error( + `Need at least 2 sampled cells in DOM before reorder, got ${beforeLefts.size}. ` + + `(If column virtualization starts culling, this test needs to refocus on visible cells.)`, + ); + } + + announce(status, "Reversing all columns and reading FLIP first-frame transforms…"); + const table = getTable(); + const original = table.getAPI().getHeaders(); + table.update({ defaultHeaders: [...original].reverse() }); + + const samples: Array<{ + accessor: string; + oldLeft: number; + newLeft: number; + txX: number; + direction: "right" | "left" | "still"; + }> = []; + + for (const accessor of sampledAccessors) { + if (!beforeLefts.has(accessor)) continue; + const cell = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, accessor); + if (!cell) continue; + const oldLeft = beforeLefts.get(accessor)!; + const newLeft = parseFloat(cell.style.left || "0"); + const txX = parseTranslateX(cell.style.transform); + let direction: "right" | "left" | "still" = "still"; + if (newLeft - oldLeft > 0.5) direction = "right"; + else if (newLeft - oldLeft < -0.5) direction = "left"; + samples.push({ accessor, oldLeft, newLeft, txX, direction }); + } + + const summary = samples + .map( + (s) => + `${s.accessor}: old=${s.oldLeft} new=${s.newLeft} ` + + `expectedDx=${s.oldLeft - s.newLeft} actualDx=${s.txX} dir=${s.direction}`, + ) + .join(" | "); + + for (const s of samples) { + const expected = s.oldLeft - s.newLeft; + if (Math.abs(s.txX - expected) >= 1.5) { + throw new Error( + `FLIP dx mismatch for "${s.accessor}" (expected ${expected}, got ${s.txX}). ${summary}`, + ); + } + } + + const movedRight = samples.filter((s) => s.direction === "right"); + const movedLeft = samples.filter((s) => s.direction === "left"); + if (movedRight.length === 0) { + throw new Error(`Reverse should move some cells right; samples: ${summary}`); + } + if (movedLeft.length === 0) { + throw new Error(`Reverse should move some cells left; samples: ${summary}`); + } + for (const s of movedRight) { + if (s.txX >= 0) { + throw new Error( + `Cell "${s.accessor}" moves right (old=${s.oldLeft} → new=${s.newLeft}) so its ` + + `FLIP transform-X must be negative, got ${s.txX}. ${summary}`, + ); + } + } + for (const s of movedLeft) { + if (s.txX <= 0) { + throw new Error( + `Cell "${s.accessor}" moves left (old=${s.oldLeft} → new=${s.newLeft}) so its ` + + `FLIP transform-X must be positive, got ${s.txX}. ${summary}`, + ); + } + } + + await sleep(SETTLE_PAUSE); + announce(status, "Done."); + expect(countGhosts(canvasElement)).toBe(0); + }, +}; + +/** + * Sort marathon. With `getRowId` providing stable identities, sort now + * triggers FLIP slides: persistent rows slide to their new tops, rows + * sorting out of the visible band slide out past the viewport edge as + * retained ghosts, rows sorting into the band slide in from below/above. + * + * After each sort settles (we wait past SLOW_DURATION) there must be no + * leftover ghosts, transforms, or stuck transitions. + */ +export const SortMarathon = { + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Sort marathon · ${ROW_COUNT} rows × ${COLUMN_COUNT} cols`; + + addControlPanel( + result.wrapper, + [ + { + heading: "Sort col_0", + buttons: [ + { + label: "Desc", + onClick: () => { + void result.table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); + }, + }, + { + label: "Asc", + onClick: () => { + void result.table.getAPI().applySortState({ accessor: "col_0", direction: "asc" }); + }, + }, + { + label: "Clear", + onClick: () => { + void result.table.getAPI().applySortState(); + }, + }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + `Each sort animates over ${SLOW_DURATION}ms. Watch rows slide vertically — ` + + "rows sorting out of view slide past the bottom edge as retained ghosts and " + + "are then removed; rows sorting into view slide in from off-screen.", + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + + const sequence: Array<{ + label: string; + sort: { accessor: string; direction: "asc" | "desc" } | undefined; + }> = [ + { label: "col_0 desc", sort: { accessor: "col_0", direction: "desc" } }, + { label: "col_0 asc", sort: { accessor: "col_0", direction: "asc" } }, + { label: "col_5 desc", sort: { accessor: "col_5", direction: "desc" } }, + { label: "cleared", sort: undefined }, + ]; + + for (let i = 0; i < sequence.length; i++) { + const { label, sort } = sequence[i]; + announce(status, `Step ${i + 1}/${sequence.length} · Sorting ${label}…`); + void table.getAPI().applySortState(sort); + // SETTLE_PAUSE outlasts the slide so retained ghosts are torn down. + await sleep(SETTLE_PAUSE); + expect(countGhosts(canvasElement)).toBe(0); + const cells = canvasElement.querySelectorAll(`.st-body-main .st-cell`); + expect(cells.length).toBeGreaterThan(0); + cells.forEach((cell) => { + const t = cell.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + } + + announce(status, "Done."); + }, +}; From 0461ea34284c3d560e89e73d699bd1394c17de37 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:59:32 -0500 Subject: [PATCH 02/20] Re order animations working --- packages/core/src/core/SimpleTableVanilla.ts | 44 +- .../tests/41-CellAnimationsTests.stories.ts | 510 ++++++++++++++++++ 2 files changed, 535 insertions(+), 19 deletions(-) diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 6f11aec60..6653fe115 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -161,7 +161,15 @@ export class SimpleTableVanilla { /** * Capture pre-change cell positions for the FLIP animation, including * conceptual positions for cells outside the virtualization viewport so - * incoming cells can animate from off-screen on column reorder/sort. + * incoming cells can animate from off-screen on column reorder/sort. The + * `play` step that runs at the end of the next render consumes this + * snapshot to inverse-transform cells from their old visual positions and + * tween them to their new ones. + * + * Called on every layout-affecting state change — including the chain of + * mid-drag `setHeaders` calls that fire on each `dragover` swap — so that + * displaced columns slide smoothly out of the dragged column's way rather + * than snapping into place. */ private captureAnimationSnapshot(): void { this.animationCoordinator.captureSnapshot({ @@ -535,12 +543,10 @@ export class SimpleTableVanilla { } }, setHeaders: (headers: HeaderObject[]) => { - // Skip animation snapshot during a live header drag — the cells should - // follow the pointer immediately rather than tween between drag steps. - const isLiveDrag = this.draggedHeaderRef.current !== null; - if (!isLiveDrag) { - this.captureAnimationSnapshot(); - } + // Snapshot on every header change — including the chain of `setHeaders` + // calls that fire while a header is being dragged — so each `dragover` + // swap FLIP-animates the displaced columns smoothly out of the way. + this.captureAnimationSnapshot(); this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, @@ -622,6 +628,11 @@ export class SimpleTableVanilla { ); // FLIP play step. No-op when no snapshot is armed or when scroll-driven. + // Position-only scroll renders deliberately skip play so out-going / + // in-coming cells aren't FLIP-tweened during vertical scrolls. Every + // other render — including the chain of mid-drag `setHeaders` renders + // that fire on each `dragover` swap — runs play so columns being + // displaced by the drag slide smoothly to their new slots. if (source !== "scroll-raf") { this.animationCoordinator.play({ containers: this.getBodyContainers() }); } @@ -656,12 +667,10 @@ export class SimpleTableVanilla { if (config.defaultHeaders !== undefined) { // Snapshot before mutating headers so the FLIP `play` at the end of the - // ensuing render can inverse-transform from the old layout. Skipped during - // a live header drag (drag handles its own positioning). - const isLiveDrag = this.draggedHeaderRef.current !== null; - if (!isLiveDrag) { - this.captureAnimationSnapshot(); - } + // ensuing render can inverse-transform from the old layout to the new + // one — works the same whether the caller is reordering programmatically + // or via an in-flight header drag. + this.captureAnimationSnapshot(); this.headers = [...config.defaultHeaders]; this.essentialAccessors = TableInitializer.buildEssentialAccessors(this.headers); @@ -813,12 +822,9 @@ export class SimpleTableVanilla { filterManager: this.filterManager, onRender: () => this.render("columnEditor-onRender"), setHeaders: (headers: HeaderObject[]) => { - // Skip animation snapshot during a live header drag — the cells should - // follow the pointer immediately rather than tween between drag steps. - const isLiveDrag = this.draggedHeaderRef.current !== null; - if (!isLiveDrag) { - this.captureAnimationSnapshot(); - } + // Snapshot on every header change so column visibility / reordering + // from the column editor smoothly FLIPs into place. + this.captureAnimationSnapshot(); this.headers = deepClone(headers); this.renderOrchestrator.invalidateCache("header"); }, diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index 7e84e7f3d..a5aa438fa 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -228,6 +228,259 @@ export const ProgrammaticReorderAnimation = { }, }; +/** + * Simplest possible reorder animation test: a 3 columns × 3 rows table. + * + * Step 1 is the straightforward case — move the center column (B) to the + * rightmost position by swapping B and C via + * `table.update({ defaultHeaders: [...] })`. After that animation settles, + * the play function chains four more reorders (5 total) so we exercise: + * + * - Pairwise neighbour swaps (B↔C, A↔B). + * - Long-distance swaps (first ↔ last column). + * - A "rotate back to original" reset. + * + * For every step we synchronously read the FLIP "First" frame and assert + * that each cell's inverse `transform-X` exactly matches `oldLeft − newLeft` + * (≤ 1.5px tolerance), then wait past `SLOW_DURATION` and assert no leftover + * transforms and no retained ghosts before moving on to the next step. + * + * Expected FLIP behaviour for each reorder: + * - Cells whose `left` increases slide RIGHT, so their inverse transform-X + * is NEGATIVE (`oldLeft − newLeft < 0`). + * - Cells whose `left` decreases slide LEFT, so their inverse transform-X + * is POSITIVE (`oldLeft − newLeft > 0`). + * - Cells whose column index is unchanged have no transform. + */ +export const SimpleThreeByThreeCenterToRightSwap = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "a", label: "A", width: 120 }, + { accessor: "b", label: "B", width: 120 }, + { accessor: "c", label: "C", width: 120 }, + ]; + const rows: Row[] = [ + { id: 1, a: "A1", b: "B1", c: "C1" }, + { id: 2, a: "A2", b: "B2", c: "C2" }, + { id: 3, a: "A3", b: "B3", c: "C3" }, + ]; + const result = renderVanillaTable(headers, rows, { + height: "240px", + animations: true, + animationDuration: SLOW_DURATION, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Simplest 3×3 column swap · move center → right · ${SLOW_DURATION}ms`; + + const findByAccessor = (accessor: string): HeaderObject => { + const h = result.table.getAPI().getHeaders().find((x) => x.accessor === accessor); + if (!h) throw new Error(`Missing header "${accessor}"`); + return h; + }; + const setOrder = (accessors: string[]): void => { + result.table.update({ defaultHeaders: accessors.map(findByAccessor) }); + }; + + addControlPanel( + result.wrapper, + [ + { + heading: "Reorder steps", + buttons: [ + { label: "1 · A · C · B (swap B ↔ C)", onClick: () => setOrder(["a", "c", "b"]) }, + { label: "2 · C · A · B (swap A ↔ C)", onClick: () => setOrder(["c", "a", "b"]) }, + { label: "3 · B · A · C (swap C ↔ B)", onClick: () => setOrder(["b", "a", "c"]) }, + { label: "4 · B · C · A (swap A ↔ C)", onClick: () => setOrder(["b", "c", "a"]) }, + { label: "5 · A · B · C (reset)", onClick: () => setOrder(["a", "b", "c"]) }, + ], + }, + ], + result.tableContainer, + ); + + addParagraph( + result.wrapper, + "Three rows, three columns. The play function runs five sequential reorders, " + + "each waiting past the previous animation before the next begins. Watch the " + + "cells slide horizontally to their new positions — every cell starts at its " + + "previous on-screen pixel position rather than from a shared edge.", + result.tableContainer, + ); + + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const ROW_INDICES = [0, 1, 2]; + const ACCESSORS = ["a", "b", "c"]; + const table = getTable(); + + /** + * Capture pre-update lefts, apply the new column order, synchronously + * read each cell's FLIP "First" frame transform, and assert it exactly + * matches `oldLeft − newLeft`. Then wait past the slide and assert + * everything settled cleanly. Verifies that columns whose index didn't + * change have no transform, and that columns that swap have opposite- + * sign transforms (one slid left, the other slid right). + */ + const runReorderStep = async ( + stepLabel: string, + nextAccessors: string[], + ): Promise => { + const beforeLefts = new Map(); + const beforeIndexByAccessor = new Map(); + const currentHeaders = table.getAPI().getHeaders(); + currentHeaders.forEach((h, i) => beforeIndexByAccessor.set(h.accessor as string, i)); + + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + expect(cell, `[${stepLabel}] missing pre cell r${row}.${accessor}`).toBeTruthy(); + beforeLefts.set(`${row}:${accessor}`, parseFloat(cell!.style.left || "0")); + } + } + + const headersByAccessor = new Map(currentHeaders.map((h) => [h.accessor as string, h])); + const nextHeaders = nextAccessors.map((acc) => { + const h = headersByAccessor.get(acc); + if (!h) throw new Error(`[${stepLabel}] header "${acc}" missing`); + return h; + }); + const nextIndexByAccessor = new Map( + nextAccessors.map((acc, i) => [acc, i]), + ); + + // CRITICAL: trigger the update and read the FLIP "First" frame + // *synchronously*. play() sets `transform: translate3d(dx, dy, 0)` + // synchronously then schedules a RAF that resets to translate3d(0,0,0) + // and applies the transition CSS. Awaiting any RAF here would only + // ever surface the destination (0,0,0). + table.update({ defaultHeaders: nextHeaders }); + + interface Sample { + row: number; + accessor: string; + oldLeft: number; + newLeft: number; + txX: number; + moved: boolean; + } + const samples: Sample[] = []; + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + expect(cell, `[${stepLabel}] missing post cell r${row}.${accessor}`).toBeTruthy(); + const newLeft = parseFloat(cell!.style.left || "0"); + const txX = parseTranslateX(cell!.style.transform); + samples.push({ + row, + accessor, + oldLeft: beforeLefts.get(`${row}:${accessor}`)!, + newLeft, + txX, + moved: + beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), + }); + } + } + + const summary = samples + .map( + (s) => + `[r${s.row}.${s.accessor}] old=${s.oldLeft} new=${s.newLeft} ` + + `expectedDx=${s.oldLeft - s.newLeft} actualDx=${s.txX}`, + ) + .join(" | "); + + for (const s of samples) { + const expected = s.oldLeft - s.newLeft; + if (Math.abs(s.txX - expected) >= 1.5) { + throw new Error(`[${stepLabel}] FLIP dx mismatch. ${summary}`); + } + } + + const stillSamples = samples.filter((s) => !s.moved); + for (const s of stillSamples) { + expect(s.newLeft, `[${stepLabel}] still col ${s.accessor} should not move`).toBe( + s.oldLeft, + ); + expect(s.txX, `[${stepLabel}] still col ${s.accessor} should have no tx`).toBe(0); + } + + const movedSamples = samples.filter((s) => s.moved); + expect( + movedSamples.length, + `[${stepLabel}] expected at least 2 columns to move`, + ).toBeGreaterThanOrEqual(6); // 2 cols × 3 rows + + const movedAccessors = new Set(movedSamples.map((s) => s.accessor)); + const movedDirections = new Set( + movedSamples.map((s) => Math.sign(s.txX)).filter((v) => v !== 0), + ); + expect( + movedDirections.size, + `[${stepLabel}] swapped cols should have opposite-sign transforms`, + ).toBe(2); + + // Once play()'s RAF has fired, the moving cells should report a + // transform transition (still cells don't need one). + await tickFrames(2); + for (const accessor of movedAccessors) { + for (const row of ROW_INDICES) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + expect( + cell!.style.transition, + `[${stepLabel}] r${row}.${accessor} should be transitioning transform`, + ).toContain("transform"); + } + } + + await sleep(SETTLE_PAUSE); + + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + const t = cell!.style.transform; + expect(t === "" || t === "none", `[${stepLabel}] r${row}.${accessor} stuck`).toBe(true); + } + } + + const ghosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); + expect(ghosts.length, `[${stepLabel}] retained ghosts leaked`).toBe(0); + }; + + // Sanity check the starting layout: A | B | C in increasing left order. + const aL = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "a")!.style.left || "0"); + const bL = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "b")!.style.left || "0"); + const cL = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "c")!.style.left || "0"); + expect(aL).toBeLessThan(bL); + expect(bL).toBeLessThan(cL); + + // Five chained reorders. Each step waits past the previous animation + // (SETTLE_PAUSE inside runReorderStep) before the next one begins. + await runReorderStep("1/5 swap B↔C → A · C · B", ["a", "c", "b"]); + await sleep(BEAT); + await runReorderStep("2/5 swap A↔C → C · A · B", ["c", "a", "b"]); + await sleep(BEAT); + await runReorderStep("3/5 swap C↔B → B · A · C", ["b", "a", "c"]); + await sleep(BEAT); + await runReorderStep("4/5 swap A↔C → B · C · A", ["b", "c", "a"]); + await sleep(BEAT); + await runReorderStep("5/5 reset → A · B · C", ["a", "b", "c"]); + + // Final state should match the original layout exactly. + const aLAfter = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "a")!.style.left || "0"); + const bLAfter = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "b")!.style.left || "0"); + const cLAfter = parseFloat(findCellByRowAndAccessor(canvasElement, 0, "c")!.style.left || "0"); + expect(aLAfter).toBe(aL); + expect(bLAfter).toBe(bL); + expect(cLAfter).toBe(cL); + }, +}; + export const ReorderWithoutAnimations = { render: () => { const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { @@ -682,6 +935,263 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { }, }; +/** + * Drag-and-drop column reorder must FLIP-animate the displaced body cells + * (and header cells) on EVERY `dragover` swap — not just on the final + * `dragend`. Visually: while the user drags column B sideways, column C + * should glide left to make room as soon as B's center crosses C's center, + * the same way a programmatic `table.update({ defaultHeaders })` swap + * animates. + * + * How the host pulls this off: + * - The drag handler (`headerCell/dragging.ts`) calls + * `context.onTableHeaderDragEnd(newHeaders)` from inside `dragover` once + * the cursor has moved enough to trigger a swap. That callback resolves + * to `setHeaders(newHeaders)` + `onRender()`. + * - `setHeaders` first calls `captureAnimationSnapshot()` (no live-drag + * skip), then mutates the headers. The next render commits cells to + * their new absolute positions. + * - `render()`'s final `play()` consumes the snapshot, computes the + * pre→post deltas, and FLIPs every cell that moved — including the one + * in the column the user is dragging. + * + * This test renders a 3 cols × 3 rows table and dispatches the drag + * sequence inline so it can poll the cells between `dragover` events and + * assert that a FLIP transform or `transition: transform` was observed + * BEFORE `dragend` ever fires. Asserting only "saw FLIP within N ms + * after dragend" wouldn't distinguish the desired behaviour from a + * single settle animation that runs only on drop. + */ +export const DragAndDropColumnReorderShouldAnimate = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "a", label: "A", width: 120 }, + { accessor: "b", label: "B", width: 120 }, + { accessor: "c", label: "C", width: 120 }, + ]; + const rows: Row[] = [ + { id: 1, a: "A1", b: "B1", c: "C1" }, + { id: 2, a: "A2", b: "B2", c: "C2" }, + { id: 3, a: "A3", b: "B3", c: "C3" }, + ]; + const result = renderVanillaTable(headers, rows, { + height: "240px", + animations: true, + animationDuration: SLOW_DURATION, + columnReordering: true, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Drag-and-drop column reorder should animate on every dragover · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Drag the B header onto the C header in this 3×3 table. As soon as B " + + "crosses C, the B and C columns should smoothly slide past each " + + "other — not snap into place when you release.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const ROW_INDICES = [0, 1, 2]; + const ACCESSORS = ["a", "b", "c"]; + + const findHeaderLabel = (accessor: string): HTMLElement => { + const cell = canvasElement.querySelector( + `.st-header-cell[data-accessor="${accessor}"], .st-header-cell #header-${accessor}`, + ); + const labelHost = + cell?.closest(".st-header-cell") ?? + canvasElement.querySelector(`#header-${accessor}`)?.closest(".st-header-cell"); + const label = labelHost?.querySelector(".st-header-label"); + if (!label) { + const headers = canvasElement.querySelectorAll(".st-header-cell"); + for (const h of Array.from(headers)) { + const text = h.querySelector(".st-header-label-text")?.textContent?.trim() ?? ""; + if (text.toLowerCase() === accessor.toLowerCase()) { + const l = h.querySelector(".st-header-label"); + if (l) return l; + } + } + throw new Error(`Header label for "${accessor}" not found`); + } + return label; + }; + + const beforeLefts = new Map(); + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + expect(cell, `missing pre cell r${row}.${accessor}`).toBeTruthy(); + beforeLefts.set(`${row}:${accessor}`, parseFloat(cell!.style.left || "0")); + } + } + const aLBefore = beforeLefts.get("0:a")!; + const bLBefore = beforeLefts.get("0:b")!; + const cLBefore = beforeLefts.get("0:c")!; + expect(aLBefore).toBeLessThan(bLBefore); + expect(bLBefore).toBeLessThan(cLBefore); + + const sourceLabel = findHeaderLabel("b"); + const targetLabel = findHeaderLabel("c"); + const targetCell = targetLabel.closest(".st-header-cell") ?? targetLabel; + + /** + * Watch every body cell for a FLIP "First" frame: a non-zero translate + * `transform` and/or a `transition: transform …` set on the inline + * style. Latches `true` on the first hit so callers can poll cheaply. + */ + let sawFlipDuringDrag = false; + const sampleAllCells = () => { + if (sawFlipDuringDrag) return; + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + if (!cell) continue; + const tx = parseTranslateX(cell.style.transform); + if (Math.abs(tx) > 0.5) { + sawFlipDuringDrag = true; + return; + } + if (cell.style.transition.includes("transform")) { + sawFlipDuringDrag = true; + return; + } + } + } + }; + + const sourceRect = sourceLabel.getBoundingClientRect(); + const targetRect = targetLabel.getBoundingClientRect(); + const startX = sourceRect.left + sourceRect.width / 2; + const startY = sourceRect.top + sourceRect.height / 2; + const endX = targetRect.left + targetRect.width / 2; + const endY = targetRect.top + targetRect.height / 2; + + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "column-drag"); + dataTransfer.effectAllowed = "move"; + + sourceLabel.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + clientX: startX, + clientY: startY, + dataTransfer, + }), + ); + + // Sweep the cursor from B's center to C's center across N steps. After + // each `dragover` we wait a few animation frames (long enough to + // outlast the drag throttle and let the FLIP "First" frame paint) and + // sample the cells. We deliberately stop sampling BEFORE dispatching + // `dragend` so the assertion can't be satisfied by a post-drop FLIP. + const steps = 8; + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const x = startX + (endX - startX) * progress; + const y = startY + (endY - startY) * progress; + targetCell.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + dataTransfer, + }), + ); + // The drag handler throttles swaps at DRAG_THROTTLE_LIMIT (50ms). + // Wait a few RAFs (~64ms at 60fps) so a throttled swap can fire and + // the resulting FLIP can paint its first frame before we sample. + for (let frame = 0; frame < 4; frame++) { + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + sampleAllCells(); + if (sawFlipDuringDrag) break; + } + } + + // Snapshot the assertion BEFORE dragend so a post-drop FLIP can't sneak + // in and falsely satisfy "saw a FLIP". + const sawFlipBeforeDragEnd = sawFlipDuringDrag; + + targetCell.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + clientX: endX, + clientY: endY, + dataTransfer, + }), + ); + sourceLabel.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + clientX: endX, + clientY: endY, + dataTransfer, + }), + ); + + // Give the post-dragend setTimeout(10) + render + any final FLIP a + // chance to fire so the after-state we measure below is settled. + await sleep(120); + + const afterLefts = new Map(); + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + expect(cell, `missing post cell r${row}.${accessor}`).toBeTruthy(); + afterLefts.set(`${row}:${accessor}`, parseFloat(cell!.style.left || "0")); + } + } + + const aLAfter = afterLefts.get("0:a")!; + const bLAfter = afterLefts.get("0:b")!; + const cLAfter = afterLefts.get("0:c")!; + + // Sanity check: B and C actually swapped (the drag did its job). + if (Math.abs(bLAfter - cLBefore) >= 1.5 || Math.abs(cLAfter - bLBefore) >= 1.5) { + throw new Error( + `Expected drag to swap B↔C. ` + + `B left: ${bLBefore} → ${bLAfter} (expected ~${cLBefore}). ` + + `C left: ${cLBefore} → ${cLAfter} (expected ~${bLBefore}). ` + + `A left: ${aLBefore} → ${aLAfter} (expected unchanged).`, + ); + } + + // The regression assertion: a FLIP transform / transition must have + // been observed on at least one body cell while `dragover` events were + // still firing — i.e. before `dragend` was dispatched. A "settle on + // drop" implementation would fail this because no cell would carry a + // non-zero transform until `dragend` triggers the final play. + expect( + sawFlipBeforeDragEnd, + "Drag-and-drop column reorder should FLIP-animate body cells on " + + "every dragover swap (no transform / transition was observed " + + "between dragstart and dragend).", + ).toBe(true); + + // After SETTLE_PAUSE, all animations (if any) should have cleaned up. + await sleep(SETTLE_PAUSE); + for (const row of ROW_INDICES) { + for (const accessor of ACCESSORS) { + const cell = findCellByRowAndAccessor(canvasElement, row, accessor); + const t = cell!.style.transform; + expect(t === "" || t === "none", `r${row}.${accessor} stuck transform`).toBe(true); + } + } + const ghosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); + expect(ghosts.length).toBe(0); + }, +}; + const parseTranslateX = (transform: string): number => { if (!transform || transform === "none") return 0; const match = From b1f04108f233cc506a8b85cdfa43a6e761f7ed67 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:11:50 -0500 Subject: [PATCH 03/20] Re order animation bug fixes --- packages/core/src/core/SimpleTableVanilla.ts | 23 +- .../core/src/managers/AnimationCoordinator.ts | 39 +- .../tests/41-CellAnimationsTests.stories.ts | 641 ++++++++++++++++++ 3 files changed, 698 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 6653fe115..967d865af 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -158,6 +158,25 @@ export class SimpleTableVanilla { ].filter((el): el is HTMLDivElement => el !== null); } + private getHeaderContainers(): HTMLElement[] { + const refs = this.domManager.getRefs(); + return [ + refs.mainHeaderRef.current, + refs.pinnedLeftHeaderRef.current, + refs.pinnedRightHeaderRef.current, + ].filter((el): el is HTMLDivElement => el !== null); + } + + /** + * All cell-bearing containers — body sections AND header sections — that the + * animation coordinator needs to inspect. Headers participate in FLIP for + * column reorder so their cells slide to their new slot rather than + * teleporting. + */ + private getAnimatableContainers(): HTMLElement[] { + return [...this.getBodyContainers(), ...this.getHeaderContainers()]; + } + /** * Capture pre-change cell positions for the FLIP animation, including * conceptual positions for cells outside the virtualization viewport so @@ -173,7 +192,7 @@ export class SimpleTableVanilla { */ private captureAnimationSnapshot(): void { this.animationCoordinator.captureSnapshot({ - containers: this.getBodyContainers(), + containers: this.getAnimatableContainers(), preLayouts: this.renderOrchestrator.getCurrentBodyLayouts(), }); } @@ -634,7 +653,7 @@ export class SimpleTableVanilla { // that fire on each `dragover` swap — runs play so columns being // displaced by the drag slide smoothly to their new slots. if (source !== "scroll-raf") { - this.animationCoordinator.play({ containers: this.getBodyContainers() }); + this.animationCoordinator.play({ containers: this.getAnimatableContainers() }); } } diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index c61d4403e..eb13bd586 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -1,4 +1,24 @@ -import { getRenderedCells } from "../utils/bodyCell/eventTracking"; +import { getRenderedCells as getBodyRenderedCells } from "../utils/bodyCell/eventTracking"; +import { getRenderedCells as getHeaderRenderedCells } from "../utils/headerCell/eventTracking"; + +/** + * The renderer keeps two independent per-container WeakMaps of rendered cells — + * one for body sections, one for header sections — because the two render + * pipelines are otherwise unrelated. The animation coordinator just wants + * "every cell currently mounted in this container", so we transparently merge + * both registries here. A given container only ever appears in one registry + * (body or header), so the merge is effectively a single lookup that picks the + * non-empty side. + */ +const collectRenderedCells = (container: HTMLElement): Map => { + const body = getBodyRenderedCells(container); + const header = getHeaderRenderedCells(container); + if (body.size === 0) return header; + if (header.size === 0) return body; + const merged = new Map(body); + header.forEach((el, id) => merged.set(id, el)); + return merged; +}; export interface AnimationCoordinatorOptions { duration?: number; @@ -119,7 +139,7 @@ export class AnimationCoordinator { if (!container) continue; // 1. DOM-rendered cells: read live position (handles in-flight transforms). - const cells = getRenderedCells(container); + const cells = collectRenderedCells(container); cells.forEach((element, cellId) => { if (!next.has(cellId)) { next.set(cellId, this.readPosition(cellId, element)); @@ -285,7 +305,7 @@ export class AnimationCoordinator { } // Active cells: incoming + persistent. - const cells = getRenderedCells(container); + const cells = collectRenderedCells(container); cells.forEach((element, cellId) => consider(element, cellId, false)); } @@ -358,6 +378,16 @@ export class AnimationCoordinator { private startTransition(cellId: string, element: HTMLElement, isRetained: boolean): void { element.style.transition = `transform ${this.duration}ms ${this.easing}`; element.style.transform = "translate3d(0, 0, 0)"; + // Suppress hit-testing on cells that are mid-slide. Without this, an + // animating header sliding under a dragging cursor will keep firing + // dragover events on whichever animating cell the cursor is currently + // intersecting, causing rapid back-and-forth swaps (visible flicker + // during drag-and-drop reorder). Restored in finishElement once the + // transition resolves. Retained (outgoing) cells already had pointer + // events suppressed in retainCell. + if (!isRetained) { + element.style.pointerEvents = "none"; + } const transitionEndHandler = (event: TransitionEvent) => { if (event.propertyName !== "transform") return; @@ -407,6 +437,9 @@ export class AnimationCoordinator { element.style.transition = ""; element.style.transform = ""; element.style.willChange = ""; + // Re-enable hit-testing now that the cell has settled. See + // startTransition for the rationale. + element.style.pointerEvents = ""; } private isCellRetained(element: HTMLElement): boolean { diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index a5aa438fa..3ce1a7508 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -481,6 +481,647 @@ export const SimpleThreeByThreeCenterToRightSwap = { }, }; +/** + * Regression test: HEADER cells must FLIP-animate during a column reorder + * the same way body cells do. Header cells live in their own per-section + * tracking registry (separate WeakMap from body cells) and used to be + * invisible to the AnimationCoordinator, so on a reorder they would teleport + * while the body cells underneath them slid into place. + * + * For each of five chained programmatic reorders the test: + * 1. Snapshots every header cell's `style.left` BEFORE the update. + * 2. Calls `table.update({ defaultHeaders })` then synchronously reads + * the FLIP "First" frame transform on every header. + * 3. Asserts the inverse `transform-X` equals `oldLeft − newLeft` (±1.5px), + * that headers whose index didn't change have no transform, and that + * swapped headers carry opposite-sign transforms (one slid left, one + * slid right). + * 4. After SETTLE_PAUSE asserts no leftover transforms or transitions + * remain on any header cell. + */ +export const HeaderCellsAnimateOnColumnReorder = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "a", label: "A", width: 120 }, + { accessor: "b", label: "B", width: 120 }, + { accessor: "c", label: "C", width: 120 }, + ]; + const rows: Row[] = [ + { id: 1, a: "A1", b: "B1", c: "C1" }, + { id: 2, a: "A2", b: "B2", c: "C2" }, + { id: 3, a: "A3", b: "B3", c: "C3" }, + ]; + const result = renderVanillaTable(headers, rows, { + height: "240px", + animations: true, + animationDuration: SLOW_DURATION, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Header cells animate on column reorder · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Headers must slide horizontally to their new slots in lockstep with " + + "the body cells underneath them. The play function chains five " + + "reorders and asserts header FLIP correctness on each.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const ACCESSORS = ["a", "b", "c"] as const; + const table = getTable(); + + const findHeaderCell = (accessor: string): HTMLElement | null => { + return canvasElement.querySelector( + `.st-header-cell[data-accessor="${accessor}"]`, + ); + }; + + /** + * For a single reorder step: snapshot pre-update header lefts, apply the + * new column order, and synchronously verify each header's FLIP "First" + * frame matches `oldLeft − newLeft`. Then wait past the slide and + * verify no header is stuck mid-transform. + */ + const runHeaderReorderStep = async ( + stepLabel: string, + nextAccessors: string[], + ): Promise => { + const beforeLefts = new Map(); + const beforeIndexByAccessor = new Map(); + const currentHeaders = table.getAPI().getHeaders(); + currentHeaders.forEach((h, i) => beforeIndexByAccessor.set(h.accessor as string, i)); + + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + expect(headerCell, `[${stepLabel}] missing pre header ${accessor}`).toBeTruthy(); + beforeLefts.set(accessor, parseFloat(headerCell!.style.left || "0")); + } + + const headersByAccessor = new Map(currentHeaders.map((h) => [h.accessor as string, h])); + const nextHeaders = nextAccessors.map((acc) => { + const h = headersByAccessor.get(acc); + if (!h) throw new Error(`[${stepLabel}] header "${acc}" missing`); + return h; + }); + const nextIndexByAccessor = new Map( + nextAccessors.map((acc, i) => [acc, i]), + ); + + // CRITICAL: trigger the update and read the FLIP "First" frame + // synchronously. Awaiting any RAF here would only ever surface + // translate3d(0,0,0) — the destination state. + table.update({ defaultHeaders: nextHeaders }); + + interface HeaderSample { + accessor: string; + oldLeft: number; + newLeft: number; + txX: number; + moved: boolean; + } + const samples: HeaderSample[] = []; + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + expect(headerCell, `[${stepLabel}] missing post header ${accessor}`).toBeTruthy(); + const newLeft = parseFloat(headerCell!.style.left || "0"); + const txX = parseTranslateX(headerCell!.style.transform); + samples.push({ + accessor, + oldLeft: beforeLefts.get(accessor)!, + newLeft, + txX, + moved: + beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), + }); + } + + const summary = samples + .map( + (s) => + `[${s.accessor}] old=${s.oldLeft} new=${s.newLeft} ` + + `expectedDx=${s.oldLeft - s.newLeft} actualDx=${s.txX}`, + ) + .join(" | "); + + for (const s of samples) { + const expected = s.oldLeft - s.newLeft; + if (Math.abs(s.txX - expected) >= 1.5) { + throw new Error(`[${stepLabel}] header FLIP dx mismatch. ${summary}`); + } + } + + const stillSamples = samples.filter((s) => !s.moved); + for (const s of stillSamples) { + expect(s.txX, `[${stepLabel}] still header ${s.accessor} should have no tx`).toBe(0); + } + + const movedSamples = samples.filter((s) => s.moved); + expect( + movedSamples.length, + `[${stepLabel}] expected at least 2 headers to move`, + ).toBeGreaterThanOrEqual(2); + + const movedDirections = new Set( + movedSamples.map((s) => Math.sign(s.txX)).filter((v) => v !== 0), + ); + expect( + movedDirections.size, + `[${stepLabel}] swapped headers should have opposite-sign transforms`, + ).toBe(2); + + // Once play()'s RAF has fired, moved headers should report the + // transform transition CSS — that's what actually drives the slide. + await tickFrames(2); + for (const s of movedSamples) { + const headerCell = findHeaderCell(s.accessor); + expect( + headerCell!.style.transition, + `[${stepLabel}] header ${s.accessor} should be transitioning transform`, + ).toContain("transform"); + } + + await sleep(SETTLE_PAUSE); + + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + const t = headerCell!.style.transform; + expect( + t === "" || t === "none", + `[${stepLabel}] header ${accessor} stuck mid-transform`, + ).toBe(true); + } + + const ghosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); + expect(ghosts.length, `[${stepLabel}] retained ghosts leaked`).toBe(0); + }; + + // Sanity check the starting layout: A | B | C left-to-right. + const aL = parseFloat(findHeaderCell("a")!.style.left || "0"); + const bL = parseFloat(findHeaderCell("b")!.style.left || "0"); + const cL = parseFloat(findHeaderCell("c")!.style.left || "0"); + expect(aL).toBeLessThan(bL); + expect(bL).toBeLessThan(cL); + + await runHeaderReorderStep("1/5 swap B↔C → A · C · B", ["a", "c", "b"]); + await sleep(BEAT); + await runHeaderReorderStep("2/5 swap A↔C → C · A · B", ["c", "a", "b"]); + await sleep(BEAT); + await runHeaderReorderStep("3/5 swap C↔B → B · A · C", ["b", "a", "c"]); + await sleep(BEAT); + await runHeaderReorderStep("4/5 swap A↔C → B · C · A", ["b", "c", "a"]); + await sleep(BEAT); + await runHeaderReorderStep("5/5 reset → A · B · C", ["a", "b", "c"]); + + // Final state: headers back to original positions. + expect(parseFloat(findHeaderCell("a")!.style.left || "0")).toBe(aL); + expect(parseFloat(findHeaderCell("b")!.style.left || "0")).toBe(bL); + expect(parseFloat(findHeaderCell("c")!.style.left || "0")).toBe(cL); + }, +}; + +/** + * Drag-and-drop variant of {@link HeaderCellsAnimateOnColumnReorder}. The + * regression we're guarding against is a setup where body cells animate on + * every dragover but header cells teleport because the AnimationCoordinator + * has never been told about the header containers. + */ +export const HeaderCellsAnimateDuringDragReorder = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "a", label: "A", width: 120 }, + { accessor: "b", label: "B", width: 120 }, + { accessor: "c", label: "C", width: 120 }, + ]; + const rows: Row[] = [ + { id: 1, a: "A1", b: "B1", c: "C1" }, + { id: 2, a: "A2", b: "B2", c: "C2" }, + { id: 3, a: "A3", b: "B3", c: "C3" }, + ]; + const result = renderVanillaTable(headers, rows, { + height: "240px", + animations: true, + animationDuration: SLOW_DURATION, + columnReordering: true, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Header cells animate during drag reorder · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Drag the B header onto the C header. The B and C HEADER cells should " + + "slide past each other on every dragover — not snap into place when " + + "you release.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const ACCESSORS = ["a", "b", "c"] as const; + + const findHeaderCell = (accessor: string): HTMLElement | null => + canvasElement.querySelector( + `.st-header-cell[data-accessor="${accessor}"]`, + ); + + const findHeaderLabel = (accessor: string): HTMLElement => { + const cell = findHeaderCell(accessor); + const label = cell?.querySelector(".st-header-label"); + if (!label) throw new Error(`Header label for "${accessor}" not found`); + return label; + }; + + const beforeLefts = new Map(); + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + expect(headerCell, `missing pre header ${accessor}`).toBeTruthy(); + beforeLefts.set(accessor, parseFloat(headerCell!.style.left || "0")); + } + const aLBefore = beforeLefts.get("a")!; + const bLBefore = beforeLefts.get("b")!; + const cLBefore = beforeLefts.get("c")!; + expect(aLBefore).toBeLessThan(bLBefore); + expect(bLBefore).toBeLessThan(cLBefore); + + const sourceLabel = findHeaderLabel("b"); + const targetLabel = findHeaderLabel("c"); + const targetCell = targetLabel.closest(".st-header-cell") ?? targetLabel; + + /** + * Latch the first time we see a header cell carrying a non-zero + * translate transform or an active `transition: transform` CSS — the + * tell-tale signs of an in-flight FLIP. + */ + let sawHeaderFlipDuringDrag = false; + const sampleHeaders = () => { + if (sawHeaderFlipDuringDrag) return; + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + if (!headerCell) continue; + const tx = parseTranslateX(headerCell.style.transform); + if (Math.abs(tx) > 0.5) { + sawHeaderFlipDuringDrag = true; + return; + } + if (headerCell.style.transition.includes("transform")) { + sawHeaderFlipDuringDrag = true; + return; + } + } + }; + + const sourceRect = sourceLabel.getBoundingClientRect(); + const targetRect = targetLabel.getBoundingClientRect(); + const startX = sourceRect.left + sourceRect.width / 2; + const startY = sourceRect.top + sourceRect.height / 2; + const endX = targetRect.left + targetRect.width / 2; + const endY = targetRect.top + targetRect.height / 2; + + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "column-drag"); + dataTransfer.effectAllowed = "move"; + + sourceLabel.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + clientX: startX, + clientY: startY, + dataTransfer, + }), + ); + + const steps = 8; + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const x = startX + (endX - startX) * progress; + const y = startY + (endY - startY) * progress; + targetCell.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + dataTransfer, + }), + ); + // Wait a few RAFs (long enough to outlast the 50ms drag throttle so a + // throttled swap can fire and the resulting FLIP can paint its first + // frame before we sample). + for (let frame = 0; frame < 4; frame++) { + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + sampleHeaders(); + if (sawHeaderFlipDuringDrag) break; + } + } + + // Snapshot the assertion BEFORE dragend so a post-drop play can't sneak + // in and falsely satisfy "saw a FLIP". + const sawHeaderFlipBeforeDragEnd = sawHeaderFlipDuringDrag; + + targetCell.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + clientX: endX, + clientY: endY, + dataTransfer, + }), + ); + sourceLabel.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + clientX: endX, + clientY: endY, + dataTransfer, + }), + ); + + await sleep(120); + + const afterLefts = new Map(); + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + expect(headerCell, `missing post header ${accessor}`).toBeTruthy(); + afterLefts.set(accessor, parseFloat(headerCell!.style.left || "0")); + } + const aLAfter = afterLefts.get("a")!; + const bLAfter = afterLefts.get("b")!; + const cLAfter = afterLefts.get("c")!; + + if (Math.abs(bLAfter - cLBefore) >= 1.5 || Math.abs(cLAfter - bLBefore) >= 1.5) { + throw new Error( + `Expected drag to swap header B↔C. ` + + `B left: ${bLBefore} → ${bLAfter} (expected ~${cLBefore}). ` + + `C left: ${cLBefore} → ${cLAfter} (expected ~${bLBefore}). ` + + `A left: ${aLBefore} → ${aLAfter} (expected unchanged).`, + ); + } + + expect( + sawHeaderFlipBeforeDragEnd, + "Header cells should FLIP-animate during drag-and-drop column " + + "reorder on every dragover swap (no header transform / transition " + + "was observed between dragstart and dragend).", + ).toBe(true); + + await sleep(SETTLE_PAUSE); + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor); + const t = headerCell!.style.transform; + expect(t === "" || t === "none", `header ${accessor} stuck transform`).toBe(true); + } + const ghosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); + expect(ghosts.length).toBe(0); + }, +}; + +/** + * Regression test: while a drag-reorder swap is animating, dragover events + * targeting the still-animating cells must NOT keep firing additional + * swaps. Previously, the moving header would slide back under the user's + * cursor, the browser would re-fire dragover on it, and the column order + * would oscillate visibly (a fast back-and-forth flicker). + * + * The fix: in-flight cells get `pointer-events: none` so the browser's + * hit-testing skips them. dispatchEvent bypasses hit-testing, so this test + * resolves the cursor's target via `document.elementFromPoint` (which DOES + * honor pointer-events: none) — i.e. simulates what the browser would do. + * + * Asserts: + * 1. Headers that move have `pointer-events: none` while in flight. + * 2. The unchanged header keeps default pointer-events. + * 3. After many dragover events sustained at the same screen point during + * the animation, the column order does NOT oscillate (at most one swap + * from the starting state, never the original-→-swapped-→-original + * ping-pong the regression produced). + * 4. After the animation settles, pointer-events on the previously-moving + * headers is restored to default. + */ +export const HeaderDragDoesNotFlickerDuringAnimation = { + render: () => { + const headers: HeaderObject[] = [ + { accessor: "a", label: "A", width: 120 }, + { accessor: "b", label: "B", width: 120 }, + { accessor: "c", label: "C", width: 120 }, + ]; + const rows: Row[] = [ + { id: 1, a: "A1", b: "B1", c: "C1" }, + { id: 2, a: "A2", b: "B2", c: "C2" }, + { id: 3, a: "A3", b: "B3", c: "C3" }, + ]; + const result = renderVanillaTable(headers, rows, { + height: "240px", + animations: true, + animationDuration: SLOW_DURATION, + columnReordering: true, + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Drag does not flicker mid-animation · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "After a drag-triggered swap starts animating, the moving header " + + "cells should ignore further dragover events until the slide " + + "settles — otherwise the column order ping-pongs as the animating " + + "headers slide back and forth under the cursor.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + await sleep(BEAT); + + const ACCESSORS = ["a", "b", "c"] as const; + const table = getTable(); + + const findHeaderCell = (accessor: string): HTMLElement | null => + canvasElement.querySelector( + `.st-header-cell[data-accessor="${accessor}"]`, + ); + + const findHeaderLabel = (accessor: string): HTMLElement => { + const cell = findHeaderCell(accessor); + const label = cell?.querySelector(".st-header-label"); + if (!label) throw new Error(`Header label for "${accessor}" not found`); + return label; + }; + + const currentOrder = (): string => + table + .getAPI() + .getHeaders() + .map((h) => String(h.accessor)) + .join(","); + + const initialOrder = currentOrder(); + expect(initialOrder).toBe("a,b,c"); + + const sourceLabel = findHeaderLabel("b"); + const targetLabel = findHeaderLabel("c"); + const sourceRect = sourceLabel.getBoundingClientRect(); + const targetRect = targetLabel.getBoundingClientRect(); + + const startX = sourceRect.left + sourceRect.width / 2; + const startY = sourceRect.top + sourceRect.height / 2; + const targetX = targetRect.left + targetRect.width / 2; + const targetY = targetRect.top + targetRect.height / 2; + + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "column-drag"); + dataTransfer.effectAllowed = "move"; + + sourceLabel.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + clientX: startX, + clientY: startY, + dataTransfer, + }), + ); + + // First dragover triggers the swap. The throttle doesn't gate this + // call (throttleLastCallTime starts at 0). + targetLabel.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: targetX, + clientY: targetY, + screenX: targetX, + screenY: targetY, + dataTransfer, + }), + ); + + // Wait a few RAFs so play() has run startTransition and applied + // pointer-events: none to the moving cells. + await tickFrames(3); + + const orderAfterFirstSwap = currentOrder(); + expect( + orderAfterFirstSwap, + "first dragover should have triggered B↔C swap", + ).toBe("a,c,b"); + + // The moving headers (B and C) should be hit-test-disabled. + const cellA = findHeaderCell("a")!; + const cellB = findHeaderCell("b")!; + const cellC = findHeaderCell("c")!; + expect( + cellB.style.pointerEvents, + "header B should be pointer-events: none mid-FLIP", + ).toBe("none"); + expect( + cellC.style.pointerEvents, + "header C should be pointer-events: none mid-FLIP", + ).toBe("none"); + expect( + cellA.style.pointerEvents, + "header A did not move and should keep default pointer-events", + ).not.toBe("none"); + + /** + * Sustained dragover storm: while the FLIP is in flight, simulate the + * cursor sitting at the screen point where C visually sits right after + * the swap (which is exactly C's pre-swap position, i.e. `targetX`). + * Use elementFromPoint to resolve the target the way the browser + * would — this honors pointer-events: none, so animating headers will + * not be returned by hit-testing. + * + * We collect every column order observed during the storm. With the + * fix, the order should remain `a,c,b` for the duration. Without it, + * the cursor's hit target would re-resolve to one of the animating + * headers and re-trigger swaps, producing the visible flicker + * `a,b,c ↔ a,c,b` we want to guard against. + */ + const ordersDuringStorm = new Set(); + const cursorX = targetX; + const cursorY = targetY; + const stormSteps = 12; + for (let i = 0; i < stormSteps; i++) { + const target = document.elementFromPoint(cursorX, cursorY); + if (target) { + // Walk up to the .st-header-cell ancestor (or use whatever element + // we hit — body cells, the section, etc — the dragover handler is + // attached per header cell, so non-header targets are no-ops). + const eventTarget = target as HTMLElement; + eventTarget.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: cursorX, + clientY: cursorY, + screenX: cursorX, + screenY: cursorY, + dataTransfer, + }), + ); + } + // Outlast the 50ms drag throttle so any swap that *could* fire would. + await new Promise((r) => setTimeout(r, 60)); + ordersDuringStorm.add(currentOrder()); + } + + // The flicker regression manifests as the column order ping-ponging + // back to the original ("a,b,c") and then back to the swapped order + // multiple times during the storm. With pointer-events: none on the + // animating cells, the hit-testing should never resolve to those + // cells, so no further swaps fire and the order stays pinned at the + // first swap. + expect( + ordersDuringStorm.size, + `column order should not oscillate during animation ` + + `(saw orders: ${Array.from(ordersDuringStorm).join(" | ")})`, + ).toBe(1); + expect( + Array.from(ordersDuringStorm)[0], + `expected order to stay pinned at "a,c,b" during the storm`, + ).toBe("a,c,b"); + + // Release the drag so the dragend cleanup runs. + sourceLabel.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + clientX: cursorX, + clientY: cursorY, + dataTransfer, + }), + ); + + await sleep(SETTLE_PAUSE); + + // After the slide finishes, pointer-events on the previously animating + // cells should be restored so the user can keep interacting. + for (const accessor of ACCESSORS) { + const headerCell = findHeaderCell(accessor)!; + expect( + headerCell.style.pointerEvents === "" || + headerCell.style.pointerEvents === "auto", + `header ${accessor} pointer-events not restored after settle ` + + `(was "${headerCell.style.pointerEvents}")`, + ).toBe(true); + } + + // Final order should still be the single swap from the original — no + // late flicker after the animation settled. + expect(currentOrder()).toBe("a,c,b"); + }, +}; + export const ReorderWithoutAnimations = { render: () => { const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { From 6a0c8527b5c35b8f7d534b8e86d4032e0c9c40fe Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:56:14 -0500 Subject: [PATCH 04/20] Animation fixes --- .../core/src/managers/AnimationCoordinator.ts | 26 +- .../tests/41-CellAnimationsTests.stories.ts | 54 +- ...llAnimationsVirtualizationTests.stories.ts | 906 +++++++++++++++++- 3 files changed, 957 insertions(+), 29 deletions(-) diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index eb13bd586..0424edad7 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -44,7 +44,7 @@ interface InFlightCell { isRetained: boolean; } -const DEFAULT_DURATION = 240; +const DEFAULT_DURATION = 2000; const DEFAULT_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; @@ -309,8 +309,12 @@ export class AnimationCoordinator { cells.forEach((element, cellId) => consider(element, cellId, false)); } - // FLIP "First" frame: apply inverse transforms synchronously so the next - // paint shows cells at their old positions. + // FLIP "First" frame: apply inverse transforms synchronously so cells + // appear at their old positions. We then need the browser to actually + // PAINT this inverted state before we trigger the transition — otherwise + // both the inverted write and the identity write happen before the same + // paint, the browser only ever paints the identity state, and the + // transition fires from identity → identity (no visual movement). for (const { cellId, element, dx, dy } of pending) { this.cancelInFlight(cellId); element.style.transition = "none"; @@ -320,11 +324,19 @@ export class AnimationCoordinator { if (pending.length === 0) return; + // Double RAF: rAF #1 callback runs BEFORE the next paint, so the browser + // hasn't yet committed the inverted transform to a painted frame. rAF #2 + // is scheduled from inside #1 and fires AFTER #1's frame has painted — + // so by the time `startTransition` runs, the browser's last painted + // computed transform is `translate3d(dx, dy, 0)` and the new write to + // `translate3d(0, 0, 0)` triggers a real interpolation. requestAnimationFrame(() => { - for (const { cellId, element, isRetained } of pending) { - if (!element.isConnected) continue; - this.startTransition(cellId, element, isRetained); - } + requestAnimationFrame(() => { + for (const { cellId, element, isRetained } of pending) { + if (!element.isConnected) continue; + this.startTransition(cellId, element, isRetained); + } + }); }); } diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index 3ce1a7508..3eed3b666 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -211,10 +211,26 @@ export const ProgrammaticReorderAnimation = { const b = swapped.findIndex((h) => h.accessor === "city"); [swapped[a], swapped[b]] = [swapped[b], swapped[a]]; table.update({ defaultHeaders: swapped }); - await tickFrames(2); + // 5 RAFs ≈ 80ms — past the double-rAF FLIP "First"/"Play" handoff and + // into the active transition, well before SETTLE_PAUSE elapses. + await tickFrames(5); const animating = Array.from( canvasElement.querySelectorAll(".st-body-main .st-cell"), - ).filter((el) => el.style.transition.includes("transform")); + ).filter((el) => { + // Inline transform during the FLIP "First" sync window OR computed + // transform during the active CSS transition. We require evidence the + // browser is actually interpolating — assigning `transition: transform …` + // is not enough on its own (a cell that snaps from identity → identity + // would otherwise appear "animating"). + const inlineTx = parseTranslateX(el.style.transform); + if (Math.abs(inlineTx) > 0.5) return true; + const computed = window.getComputedStyle(el).transform; + if (!computed || computed === "none") return false; + const m = computed.match(/matrix\(([^)]+)\)/); + if (!m) return false; + const parts = m[1].split(",").map((p) => parseFloat(p.trim())); + return parts.length >= 6 && (Math.abs(parts[4]) > 0.5 || Math.abs(parts[5]) > 0.5); + }); expect(animating.length).toBeGreaterThan(0); await sleep(SETTLE_PAUSE); @@ -765,14 +781,26 @@ export const HeaderCellsAnimateDuringDragReorder = { for (const accessor of ACCESSORS) { const headerCell = findHeaderCell(accessor); if (!headerCell) continue; + // Inline transform during the FLIP "First" sync window. const tx = parseTranslateX(headerCell.style.transform); if (Math.abs(tx) > 0.5) { sawHeaderFlipDuringDrag = true; return; } - if (headerCell.style.transition.includes("transform")) { - sawHeaderFlipDuringDrag = true; - return; + // Computed transform during the active CSS transition. This is what + // proves the browser is genuinely interpolating — not just that we + // ASSIGNED `transition: transform …` (which can sit there on a cell + // that snaps if the FLIP "First" frame is lost). + const computed = window.getComputedStyle(headerCell).transform; + if (computed && computed !== "none") { + const m = computed.match(/matrix\(([^)]+)\)/); + if (m) { + const parts = m[1].split(",").map((p) => parseFloat(p.trim())); + if (parts.length >= 6 && (Math.abs(parts[4]) > 0.5 || Math.abs(parts[5]) > 0.5)) { + sawHeaderFlipDuringDrag = true; + return; + } + } } } }; @@ -1697,9 +1725,19 @@ export const DragAndDropColumnReorderShouldAnimate = { sawFlipDuringDrag = true; return; } - if (cell.style.transition.includes("transform")) { - sawFlipDuringDrag = true; - return; + // Computed transform during the active CSS transition (proves the + // browser is interpolating, not just that the transition style was + // assigned). See the matching comment in `sampleHeaders` above. + const computed = window.getComputedStyle(cell).transform; + if (computed && computed !== "none") { + const m = computed.match(/matrix\(([^)]+)\)/); + if (m) { + const parts = m[1].split(",").map((p) => parseFloat(p.trim())); + if (parts.length >= 6 && (Math.abs(parts[4]) > 0.5 || Math.abs(parts[5]) > 0.5)) { + sawFlipDuringDrag = true; + return; + } + } } } } diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index 8e7caa79b..22c6668b7 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -11,14 +11,39 @@ * - Persistent cells (visible before AND after) slide from old → new. * - Incoming cells (off-screen before, in DOM after) FLIP in from their * true pre-change off-screen position, clipped by the body's overflow. - * - Outgoing cells (in DOM before, off-screen after) are retained and - * slide to their true post-change off-screen position before being - * removed — visually they appear to slide out past the viewport edge. + * - Outgoing cells (in DOM before, off-screen after) are retained as + * `data-animating-out` ghosts and slide to their true post-change + * off-screen position before being removed — visually they appear to + * slide out past the viewport edge. * - * Note (v1): horizontal "virtualization" via `getVisibleBodyCells` is - * effectively a no-op because `mainSectionContainerWidth` is the sum of - * main column widths (content), not the visible viewport. All non-pinned - * columns within the row band stay in the DOM. + * Animate-out into virtualized space is regression-tested in four flavours: + * 1. Vertical / downward (sort col_0 desc at scrollTop=0): top-band rows + * sort to the bottom of the table → ghosts slide DOWN past bottom edge. + * → {@link SortRetainsCellsThatExitVirtualizedBand} + * 2. Vertical / upward (sort col_0 desc while scrolled to bottom): visible + * bottom-band rows sort to the top of the table → ghosts slide UP past + * top edge. + * → {@link SortRetainsCellsThatExitUpwardWhenScrolled} + * 3. Vertical / re-aim mid-flight (rapid double sort): cells already + * animating out from a first sort must be re-aimed by a second sort + * that fires before the first finishes, with all ghosts torn down once + * everything settles. + * → {@link OverlappingSortsRetainAndReaimGhosts} + * 4. Horizontal / leftward (column reverse at right-most scrollLeft): + * visible right-side cells reorder to the left side of the table → if + * the new `left` is outside `getVisibleBodyCells`'s post-reorder band, + * the cells are retained as ghosts that slide LEFT past the viewport + * edge. + * → {@link ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts} + * + * Why horizontal animate-out only manifests under horizontal scroll: at + * scrollLeft=0, `getVisibleBodyCells` keeps every non-pinned cell in the + * DOM (the visible band's right edge equals scrollLeft + mainWidth, which + * is the full content width), so column reorder never *removes* a cell — + * the same DOM node persists and slides to its new `left` via FLIP, with + * the body's overflow clipping the off-screen portion. Once scrollLeft > 0 + * the band shifts and reorders can push cells outside it; that's when the + * outgoing ghost path kicks in horizontally as well. */ import { HeaderObject, SimpleTableVanilla, Row } from "../../src/index"; @@ -165,12 +190,91 @@ const announce = (status: HTMLElement, msg: string): void => { status.textContent = msg; }; -const countAnimating = (canvasElement: HTMLElement): number => { +/** + * Loose, "is-armed-for-animation" check — does the cell's INLINE style currently + * carry a transform-transition and a translate transform? This is satisfied + * the instant `startTransition` runs, even if the browser never actually + * interpolates the transform (e.g. because the FLIP "First" frame was never + * painted, so the browser's transition starts from identity → identity and + * the cell snaps). PREFER {@link countActuallyAnimating} for assertions — + * this one is kept only for legacy callers / settle-state checks. + */ +const countAnimatingArmed = (canvasElement: HTMLElement): number => { return Array.from(canvasElement.querySelectorAll(`.st-body-main .st-cell`)).filter( (el) => el.style.transition.includes("transform") && el.style.transform.includes("translate"), ).length; }; +/** + * The 2D translation component of `getComputedStyle(el).transform`. Returns + * { tx, ty } in CSS pixels. During a real CSS transition this returns the + * INTERPOLATED matrix (non-identity mid-flight). When no transition is in + * progress, returns { tx: 0, ty: 0 } for `none` / identity matrices. + */ +const readComputedTranslate = (el: HTMLElement): { tx: number; ty: number } => { + const t = window.getComputedStyle(el).transform; + if (!t || t === "none") return { tx: 0, ty: 0 }; + const m = t.match(/matrix\(([^)]+)\)/); + if (m) { + const parts = m[1].split(",").map((p) => parseFloat(p.trim())); + if (parts.length >= 6) return { tx: parts[4], ty: parts[5] }; + } + const m3d = t.match(/matrix3d\(([^)]+)\)/); + if (m3d) { + const parts = m3d[1].split(",").map((p) => parseFloat(p.trim())); + if (parts.length >= 14) return { tx: parts[12], ty: parts[13] }; + } + return { tx: 0, ty: 0 }; +}; + +/** + * Strict "cells are visually animating right now" check. A cell counts as + * actually animating only if its COMPUTED transform has a non-trivial + * translation — i.e. the browser is mid-interpolation between two transform + * values. Use this anywhere you would be tempted to use + * {@link countAnimatingArmed}: it catches the common bug where every cell + * gets a transform-transition assigned but the FLIP "First" frame was lost + * (so the browser snaps instead of tweening). + */ +const countActuallyAnimating = (canvasElement: HTMLElement, minTranslate = 1): number => { + return Array.from(canvasElement.querySelectorAll(`.st-body-main .st-cell`)).filter( + (el) => { + const { tx, ty } = readComputedTranslate(el); + return Math.abs(tx) >= minTranslate || Math.abs(ty) >= minTranslate; + }, + ).length; +}; + +/** + * Sample each cell's on-screen bounding rect now and again after `frames` + * RAFs, returning the cells whose visual position actually changed. This is + * the gold-standard "did anything move on screen" check — it doesn't care + * about styles or transforms, only about pixels. + */ +const sampleVisuallyMovingCells = async ( + canvasElement: HTMLElement, + frames: number, + minDelta = 1, +): Promise<{ moved: number; sampled: number; maxDx: number; maxDy: number }> => { + const cells = Array.from( + canvasElement.querySelectorAll(`.st-body-main .st-cell`), + ); + const before = cells.map((el) => el.getBoundingClientRect()); + await tickFrames(frames); + const after = cells.map((el) => el.getBoundingClientRect()); + let moved = 0; + let maxDx = 0; + let maxDy = 0; + for (let i = 0; i < cells.length; i++) { + const dx = after[i].left - before[i].left; + const dy = after[i].top - before[i].top; + if (Math.abs(dx) >= minDelta || Math.abs(dy) >= minDelta) moved++; + if (Math.abs(dx) > Math.abs(maxDx)) maxDx = dx; + if (Math.abs(dy) > Math.abs(maxDy)) maxDy = dy; + } + return { moved, sampled: cells.length, maxDx, maxDy }; +}; + const countGhosts = (canvasElement: HTMLElement): number => { return canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`).length; }; @@ -194,6 +298,24 @@ const parseTranslateX = (transform: string): number => { return match ? parseFloat(match[1]) : 0; }; +const parseTranslateY = (transform: string): number => { + if (!transform || transform === "none") return 0; + // translate3d(, , ) or translate(, ) — pull the Y component. + const t3d = transform.match( + /translate3d\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/, + ); + if (t3d) return parseFloat(t3d[1]); + const t2d = transform.match( + /translate\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/, + ); + if (t2d) return parseFloat(t2d[1]); + // matrix(a, b, c, d, tx, ty) — pull ty (6th component). + const m = transform.match( + /matrix\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*(-?\d+(?:\.\d+)?)/, + ); + return m ? parseFloat(m[1]) : 0; +}; + // ============================================================================ // STORIES // ============================================================================ @@ -284,16 +406,18 @@ export const SlowColumnReorderMarathon = { announce(status, "Step 1/5 · Reversing all columns…"); table.update({ defaultHeaders: [...original].reverse() }); - await tickFrames(2); - expect(countAnimating(canvasElement)).toBeGreaterThan(50); + // 5 RAFs ≈ 80ms — well past the double-rAF FLIP "First"/"Play" handoff + // and into the active transition window (animationDuration=1500ms). + await tickFrames(5); + expect(countActuallyAnimating(canvasElement)).toBeGreaterThan(50); await sleep(SETTLE_PAUSE); expect(countGhosts(canvasElement)).toBe(0); announce(status, "Step 2/5 · Resetting to original order…"); await sleep(BEAT); table.update({ defaultHeaders: original }); - await tickFrames(2); - expect(countAnimating(canvasElement)).toBeGreaterThan(50); + await tickFrames(5); + expect(countActuallyAnimating(canvasElement)).toBeGreaterThan(50); await sleep(SETTLE_PAUSE); // STRICT step: swap two cells that are BOTH visible in the viewport so we @@ -570,8 +694,40 @@ export const ReorderAtMultipleScrollPositions = { announce(status, `Step ${i + 1}/${positions.length} · Reversing columns at ${label}…`); order = [...order].reverse(); table.update({ defaultHeaders: order }); - await tickFrames(2); - expect(countAnimating(canvasElement)).toBeGreaterThan(50); + // Wait long enough for the FLIP `play` RAF to fire and the browser to + // start interpolating, but well short of SLOW_DURATION so cells are + // still mid-flight. 5 RAFs ≈ 80ms, vs. animationDuration=1500ms. + await tickFrames(5); + + // Strict computed-style check: cells must have a non-identity transform + // applied by the browser's transition. `countAnimatingArmed` would lie + // here if the FLIP "First" frame was lost (cells get the transition + // assigned but snap instantly). + const armed = countAnimatingArmed(canvasElement); + const animating = countActuallyAnimating(canvasElement); + if (animating <= 50) { + throw new Error( + `Step ${i + 1}/${positions.length} at ${label}: cells did NOT visually animate. ` + + `Armed (transition assigned) = ${armed}, ` + + `actually-tweening (computed transform != identity) = ${animating}. ` + + `If armed >> animating, the FLIP "First" frame is being lost — the ` + + `transform-transition is set but the browser snaps from identity to ` + + `identity instead of interpolating from the inverted start.`, + ); + } + + // Independent gold-standard check: do the cells' on-screen rects + // actually change between paints? This proves the animation is real + // regardless of how it's implemented. + const movement = await sampleVisuallyMovingCells(canvasElement, 5); + if (movement.moved < 20) { + throw new Error( + `Step ${i + 1}/${positions.length} at ${label}: cells did NOT visually move ` + + `between paints. moved=${movement.moved}/${movement.sampled}, ` + + `maxDx=${movement.maxDx.toFixed(2)}px, maxDy=${movement.maxDy.toFixed(2)}px.`, + ); + } + await sleep(SETTLE_PAUSE); expect(countGhosts(canvasElement)).toBe(0); } @@ -806,3 +962,725 @@ export const SortMarathon = { announce(status, "Done."); }, }; + +/** + * REGRESSION TEST FOR ANIMATE-OUT INTO VIRTUALIZED SPACE. + * + * When a sort moves a *currently visible* row to a position that's outside + * the visible band (e.g. row at position 0 sorts to position 499 in a 500 + * row table), the cell for that row should NOT be removed from the DOM + * immediately. Instead the renderer should hand it to the animation + * coordinator as a retained "ghost" that: + * + * 1. Stays in the DOM with `data-animating-out="true"`. + * 2. Has its `style.top` updated to its new (off-screen) position. + * 3. Plays a FLIP slide from its old (on-screen) position to that new + * off-screen position. The body's `overflow: hidden` clips the part + * that crosses the viewport edge, so it visually appears to slide + * out past the bottom edge. + * 4. Is removed from the DOM only after the slide completes. + * + * If the cell is dropped immediately, the user sees it pop out of existence + * in place — a visible jank during sort. + * + * The test: + * a. Captures the cells at the top of the visible band (which will sort + * to the bottom on `col_0 desc`). + * b. Triggers the sort and SYNCHRONOUSLY (after the snapshot+render+FLIP + * first frame) checks that those cells are still in the DOM as + * retained ghosts at the new far-off-screen `top`. + * c. Verifies they have an inverse FLIP transform applied. + * d. Waits past the animation duration and confirms the ghosts have been + * cleaned up. + */ +export const SortRetainsCellsThatExitVirtualizedBand = { + tags: ["sort-retains-virtualized-ghosts"], + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Sort retains off-screen cells as ghosts · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "On a sort that pushes a visible row to a virtualized off-screen position, " + + "the cell must remain in the DOM as a `data-animating-out` ghost while it " + + "slides to its new off-screen `top` — not pop out in place.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + + // Snapshot the body's row-height so we can compute the off-screen + // bottom position the ghost should slide to. + const sampleCell = canvasElement.querySelector( + `.st-body-main .st-cell[data-row-index="0"][data-accessor="id"]`, + ); + expect(sampleCell, "Need at least one rendered cell at row-index 0").toBeTruthy(); + const rowHeight = parseFloat(sampleCell!.style.height || "0"); + expect(rowHeight, "row height should be > 0").toBeGreaterThan(0); + + // Capture the text content of the top-most visible row's id cell. + // After `col_0 desc`, this row (col_0=0, lowest value) sorts to the + // very bottom (position 499) — well outside the band of ~12-15 rows + // + BODY_CELL_BAND_PADDING. + const topRowText = sampleCell!.textContent?.trim() ?? ""; + expect(topRowText).toBeTruthy(); + + // Capture text for a handful of other top-band id cells too — all of + // these will sort off-screen to the bottom. + const topBandCells = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell[data-accessor="id"]`, + ), + ) + .filter((c) => !c.hasAttribute("data-animating-out")) + .slice(0, 5); + const topBandTexts = topBandCells.map((c) => c.textContent?.trim() ?? ""); + expect(topBandTexts.length).toBeGreaterThan(2); + + // Pre-sort sanity: no ghosts in the DOM. + expect(countGhosts(canvasElement)).toBe(0); + + announce(status, "Triggering col_0 desc — top rows should slide off-screen as ghosts…"); + + // Trigger the sort. SortManager.subscribe is synchronous, so by the + // time this returns the renderer has run, retained ghosts have been + // created, and FLIP "First" inverse transforms have been applied. + void table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); + + // Synchronously inspect the DOM immediately after the sort. + const ghosts = Array.from( + canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ), + ); + + if (ghosts.length === 0) { + throw new Error( + `Expected retained ghosts immediately after a sort that pushes visible rows ` + + `off-screen, got 0. Top-band rows (${topBandTexts.join(",")}) appear to have been ` + + `removed from the DOM in place instead of being slid out as ghosts.`, + ); + } + + // For each of the rows we captured pre-sort, find a ghost with the + // same text — that's the one for that row. + const ghostsByText = new Map(); + for (const g of ghosts) { + const accessor = g.getAttribute("data-accessor"); + if (accessor !== "id") continue; + const text = g.textContent?.trim() ?? ""; + if (text) ghostsByText.set(text, g); + } + + const missing: string[] = []; + for (const text of topBandTexts) { + if (!ghostsByText.has(text)) missing.push(text); + } + if (missing.length > 0) { + throw new Error( + `Expected retained ghosts for top-band id cells [${topBandTexts.join(",")}], ` + + `but [${missing.join(",")}] were missing from the DOM. ` + + `(Found ${ghosts.length} total ghosts; saw ids: [${Array.from(ghostsByText.keys()).join(",")}].)`, + ); + } + + // Pick the ghost for the top-most pre-sort row and verify its + // post-sort `top` is far below the viewport (proving the renderer + // really did move it to its new off-screen position rather than + // leaving it in place at top=0). + const topGhost = ghostsByText.get(topRowText)!; + const ghostTop = parseFloat(topGhost.style.top || "0"); + const minimumOffScreenTop = VIEWPORT_HEIGHT + 5 * rowHeight; + if (!(ghostTop > minimumOffScreenTop)) { + throw new Error( + `Ghost for row "${topRowText}" should have its style.top moved well below the ` + + `viewport (>${minimumOffScreenTop}), got ${ghostTop}.`, + ); + } + + // The ghost must have an inverse FLIP transform applied so the next + // paint visually leaves it at its OLD on-screen position. Without + // this it would pop straight to its new off-screen spot — invisible. + const ghostTransform = topGhost.style.transform; + if (!ghostTransform || ghostTransform === "none") { + throw new Error( + `Ghost for row "${topRowText}" has no FLIP "First" transform (got "${ghostTransform}"). ` + + `Expected an inverse translate3d(...) so the cell visually starts at its old position.`, + ); + } + + // Pointer events should be suppressed on the outgoing ghost so it + // doesn't intercept clicks while sliding away. + expect(topGhost.style.pointerEvents).toBe("none"); + + // Wait for the animation to settle. + await sleep(SETTLE_PAUSE); + + announce(status, "Verifying ghost cleanup after settle…"); + const ghostsAfterSettle = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + ghostsAfterSettle.length, + `All ${ghosts.length} retained ghosts should be removed once the slide completes`, + ).toBe(0); + + // No leftover transforms on the post-sort active cells. + const activeCells = canvasElement.querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ); + activeCells.forEach((c) => { + const t = c.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + + announce(status, "Done."); + }, +}; + +/** + * REGRESSION TEST FOR ANIMATE-OUT WHEN SCROLLED — UP DIRECTION. + * + * Companion to {@link SortRetainsCellsThatExitVirtualizedBand} that exercises + * cells exiting via the *top* of the viewport rather than the bottom. + * + * If you scroll mid-way through the table and then sort, rows that are + * currently visible can be sorted to a position ABOVE the visible band (i.e. + * a small `top` value while the scroller is at a large `scrollTop`). The + * cells representing those rows must still be retained as ghosts and slide + * UPWARD past the top edge — not pop out in place. + * + * Concretely: scroll to the bottom of the table (rows ~485–499 visible), + * then sort `col_0 desc` (descending by col_0 value, where row-0 has the + * lowest value). The currently-visible rows have HIGH col_0 values, so on + * desc they sort to the TOP of the table (small `top`). Their old visual + * position was below the viewport edge of the scroller; their new visual + * position is above it. They must be retained and slide upward, with an + * inverse FLIP `transform` whose Y component is positive (= old.top - + * new.top, where old > new ⇒ tx_y > 0). + */ +export const SortRetainsCellsThatExitUpwardWhenScrolled = { + tags: ["sort-retains-virtualized-ghosts-up"], + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Sort retains off-screen cells (upward) · scrolled · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Scrolls to the bottom of the table, then sorts so the currently-visible " + + "rows fly to the top of the table. The cells representing those rows must " + + "remain in the DOM as `data-animating-out` ghosts and slide upward — not " + + "pop out in place.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + + // Scroll to the bottom of the body so the visible band is at the END of + // the dataset (rows ~485–499). After `col_0 desc`, row-499 (highest + // col_0 value) sorts to position 0 — a vertical jump of ~14000px UP. + const scroller = findScroller(canvasElement); + expect(scroller, "Need a horizontal/vertical scroller").toBeTruthy(); + scroller!.scrollTop = scroller!.scrollHeight; + // Wait for the scroll-driven re-render so the visible band reflects the + // bottom of the table. + await tickFrames(4); + await sleep(200); + + const sampleCell = canvasElement.querySelector( + `.st-body-main .st-cell[data-accessor="id"]`, + ); + expect(sampleCell, "Need at least one rendered cell after scroll").toBeTruthy(); + const rowHeight = parseFloat(sampleCell!.style.height || "0"); + expect(rowHeight, "row height should be > 0").toBeGreaterThan(0); + + // Capture ALL visible id cells with their pre-sort `top`. After the + // sort these cells should be retained as ghosts at much smaller `top` + // values (because they jumped to the top of the table). + const visibleIdCells = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell[data-accessor="id"]`, + ), + ).filter((c) => !c.hasAttribute("data-animating-out")); + + const beforeByText = new Map(); + for (const c of visibleIdCells) { + const text = c.textContent?.trim() ?? ""; + if (!text) continue; + beforeByText.set(text, { top: parseFloat(c.style.top || "0") }); + } + expect( + beforeByText.size, + "Need several visible bottom-band id cells to track pre-sort", + ).toBeGreaterThan(2); + + // All sampled cells should currently be near the bottom of the dataset. + const minPreSortTop = Math.min( + ...Array.from(beforeByText.values()).map((v) => v.top), + ); + expect(minPreSortTop).toBeGreaterThan(VIEWPORT_HEIGHT * 10); + + expect(countGhosts(canvasElement)).toBe(0); + + announce(status, "Sorting col_0 desc — bottom rows should slide UP off-screen as ghosts…"); + + // Sort. Bottom-band rows (high col_0) sort to TOP of table (small `top`). + void table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); + + // Synchronously inspect the DOM right after the sort. + const ghosts = Array.from( + canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ), + ); + + if (ghosts.length === 0) { + throw new Error( + `Expected retained ghosts immediately after a sort that pushes scrolled-into-view ` + + `rows to the top of the table, got 0. Bottom-band id cells [${Array.from( + beforeByText.keys(), + ).join(",")}] appear to have been removed from the DOM in place instead of being ` + + `slid out as ghosts.`, + ); + } + + const ghostsByText = new Map(); + for (const g of ghosts) { + if (g.getAttribute("data-accessor") !== "id") continue; + const text = g.textContent?.trim() ?? ""; + if (text) ghostsByText.set(text, g); + } + + // Every tracked text must have a corresponding ghost. + const missing: string[] = []; + for (const text of beforeByText.keys()) { + if (!ghostsByText.has(text)) missing.push(text); + } + if (missing.length > 0) { + throw new Error( + `Expected upward-sliding ghosts for [${Array.from(beforeByText.keys()).join(",")}], ` + + `but [${missing.join(",")}] were missing. Found ghosts for: ` + + `[${Array.from(ghostsByText.keys()).join(",")}].`, + ); + } + + // Pick a sample ghost (first one with both before+after data) and + // verify: + // - new `top` is near the TOP of the table (small absolute value), + // well below the scroller's current scrollTop. + // - inverse FLIP `transform` has positive Y component = old - new. + // - pointer-events: none. + const scrollTopAtSort = scroller!.scrollTop; + const summaries: string[] = []; + let assertedAtLeastOne = false; + for (const [text, before] of beforeByText.entries()) { + const ghost = ghostsByText.get(text)!; + const newTop = parseFloat(ghost.style.top || "0"); + const ty = parseTranslateY(ghost.style.transform); + summaries.push( + `${text}: oldTop=${before.top} newTop=${newTop} ty=${ty} pointer=${ghost.style.pointerEvents}`, + ); + + // newTop should be much smaller than scrollTopAtSort — proving the + // ghost was repositioned to its new (upward) spot rather than left + // in place near the bottom. + if (!(newTop < scrollTopAtSort - 5 * rowHeight)) { + throw new Error( + `Ghost for row "${text}" should have its style.top moved well above the ` + + `current scrollTop (${scrollTopAtSort}). Got newTop=${newTop}. ` + + `Summaries: ${summaries.join(" | ")}`, + ); + } + + const expectedTy = before.top - newTop; + if (Math.abs(ty - expectedTy) >= 1.5) { + throw new Error( + `Ghost for row "${text}" has wrong FLIP ty: expected ${expectedTy} (oldTop - newTop), ` + + `got ${ty}. Summaries: ${summaries.join(" | ")}`, + ); + } + if (ty <= 0) { + throw new Error( + `Ghost for row "${text}" sliding UP must have a POSITIVE FLIP ty (it visually ` + + `starts below where it ends). Got ty=${ty}. Summaries: ${summaries.join(" | ")}`, + ); + } + + expect( + ghost.style.pointerEvents, + `Ghost for "${text}" should have pointer-events: none while sliding`, + ).toBe("none"); + + assertedAtLeastOne = true; + } + expect(assertedAtLeastOne, "Should have asserted at least one ghost").toBe(true); + + await sleep(SETTLE_PAUSE); + + announce(status, "Verifying ghost cleanup after settle…"); + const ghostsAfterSettle = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + ghostsAfterSettle.length, + `All ${ghosts.length} retained ghosts should be removed once the slide completes`, + ).toBe(0); + + const activeCells = canvasElement.querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ); + activeCells.forEach((c) => { + const t = c.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + + announce(status, "Done."); + }, +}; + +/** + * REGRESSION TEST FOR ANIMATE-OUT WHEN SORTS OVERLAP. + * + * If the user clicks a sort header twice in rapid succession (toggling the + * direction before the previous animation finishes), the second sort's + * snapshot fires while the first sort's retained ghosts are still mid-flight. + * Those mid-flight ghosts are themselves "currently visible" cells (as far + * as the user is concerned) and should also be retained / re-aimed for the + * second sort — not orphaned in place at the wrong position. + * + * The test: + * 1. Sorts col_0 desc — capturing the wave of bottom-bound ghosts. + * 2. Half-way through the slide, sorts col_0 asc — which should send + * everything back the other way. + * 3. Verifies the active cells (now sorting back ascending) still get a + * FLIP transform (i.e. they slide rather than snap), and that all + * ghosts are torn down once both animations settle. + */ +export const OverlappingSortsRetainAndReaimGhosts = { + tags: ["sort-overlap-retain"], + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Overlapping sorts re-aim retained ghosts · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Triggers a sort, waits ~half the animation, then triggers the opposite " + + "sort. Cells mid-slide must continue to animate (not snap or vanish), and " + + "the system must still tear down every retained ghost once the dust settles.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + + expect(countGhosts(canvasElement)).toBe(0); + + announce(status, "Sort 1: col_0 desc…"); + void table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); + + // Synchronously verify ghosts were created. + const ghostsAfterFirstSort = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + ghostsAfterFirstSort.length, + "First sort should retain ghosts for cells leaving the visible band", + ).toBeGreaterThan(0); + + // Wait roughly half the slide so we land in the middle of the animation. + await sleep(SLOW_DURATION / 2); + + // The first wave of ghosts should still be in flight. + const midFlightGhosts = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + midFlightGhosts.length, + "Ghosts should still be mid-slide halfway through the animation", + ).toBeGreaterThan(0); + + announce(status, "Sort 2 (mid-flight): col_0 asc…"); + void table.getAPI().applySortState({ accessor: "col_0", direction: "asc" }); + + // Synchronously after the second sort: + // - The new active cells (the rows now visible after asc) must carry a + // FLIP "First" transform — proving they were snapshot+inversed + // against their pre-asc visual positions rather than snapped. + const activeCellsImmediately = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ), + ); + const transformedActive = activeCellsImmediately.filter((c) => { + const t = c.style.transform; + return t && t !== "none" && t.includes("translate"); + }); + if (transformedActive.length === 0) { + throw new Error( + `Expected active (non-ghost) cells to carry a FLIP "First" transform after the ` + + `second sort, got 0. The second sort appears to have not captured a snapshot ` + + `(or the snapshot didn't see the live mid-flight positions) so cells are now ` + + `at their final positions with no slide-in.`, + ); + } + + // Wait for everything to settle — both the first and second wave. + await sleep(SETTLE_PAUSE); + + announce(status, "Verifying full cleanup after overlapping sorts settle…"); + const finalGhosts = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + finalGhosts.length, + `All retained ghosts should be removed once both sort animations finish`, + ).toBe(0); + + canvasElement + .querySelectorAll(`.st-body-main .st-cell`) + .forEach((c) => { + const t = c.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + + announce(status, "Done."); + }, +}; + +/** + * REGRESSION TEST FOR HORIZONTAL ANIMATE-OUT WHEN HORIZONTALLY SCROLLED. + * + * When the user has scrolled the body horizontally so that left-side + * columns are outside the rendered cell band, then performs a column + * reorder that pushes the *currently visible* right-side columns to the + * left side, those right-side cells exit the band horizontally and need + * to be retained as ghosts that slide LEFT past the viewport edge — not + * popped out of existence in place. + * + * Concretely: + * 1. Scroll the body container all the way to the right (rightmost + * columns visible, leftmost columns out of band). + * 2. Reverse all 31 columns. The cells that were on-screen on the right + * now belong on the left side of the table — outside the band that + * `getVisibleBodyCells` keeps in the DOM at the new scroll position. + * 3. Verify those cells are retained as `data-animating-out` ghosts at + * their new (off-screen-left) `style.left`, with an inverse FLIP X + * transform so they visually start at their old on-screen `left`. + */ +export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { + tags: ["reorder-h-scroll-retain"], + render: () => { + const result = renderConstrainedTable(createHeaders(), createData(), { + getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), + }); + setTable(result.table); + result.h2.textContent = `Reorder after horizontal scroll · retains horizontally-exiting ghosts · ${SLOW_DURATION}ms`; + addParagraph( + result.wrapper, + "Scrolls horizontally to the rightmost extent of the body, then reverses " + + "all columns. The cells that were on-screen on the right now belong on " + + "the left side of the table — outside the new band — and must be retained " + + "as ghosts sliding left past the viewport edge.", + result.tableContainer, + ); + return result.wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + const status = + canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? + document.createElement("div"); + await sleep(BEAT); + const table = getTable(); + + // The horizontally scrollable element is .st-body-main (overflow: auto, + // content is wider than the viewport). Setting scrollLeft fires the + // scroll handler which the SectionScrollController listens for and + // propagates to other panes (sticky header, scrollbar). + const mainBody = canvasElement.querySelector(".st-body-main"); + expect(mainBody, "Need a horizontally scrollable .st-body-main").toBeTruthy(); + expect( + mainBody!.scrollWidth, + ".st-body-main scrollWidth should exceed clientWidth so it can scroll", + ).toBeGreaterThan(mainBody!.clientWidth); + + mainBody!.scrollLeft = mainBody!.scrollWidth; + mainBody!.dispatchEvent(new Event("scroll", { bubbles: true })); + await tickFrames(4); + await sleep(300); + + const scrollLeftAtSnapshot = mainBody!.scrollLeft; + expect( + scrollLeftAtSnapshot, + ".st-body-main should have scrolled to a meaningful right offset", + ).toBeGreaterThan(1000); + + // Capture the visible row-0 cells with their pre-reorder `left`. After + // the reverse, the right-side accessors (high col_N) end up at small + // `left` values, far behind our current scrollLeft — they should be + // outside the post-reorder band and retained as ghosts. + const visibleRow0Cells = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell[data-row-index="0"]`, + ), + ).filter((c) => !c.hasAttribute("data-animating-out")); + + const beforeByAccessor = new Map(); + for (const c of visibleRow0Cells) { + const accessor = c.getAttribute("data-accessor"); + if (!accessor) continue; + beforeByAccessor.set(accessor, { left: parseFloat(c.style.left || "0") }); + } + expect( + beforeByAccessor.size, + "Need several visible row-0 cells after horizontal scroll", + ).toBeGreaterThan(2); + + // All sampled cells should be at `left` values close to or past the + // current scrollLeft — this is what makes them currently visible. + const minPreLeft = Math.min( + ...Array.from(beforeByAccessor.values()).map((v) => v.left), + ); + expect(minPreLeft).toBeGreaterThan(scrollLeftAtSnapshot - 500); + + expect(countGhosts(canvasElement)).toBe(0); + + announce(status, "Reversing all columns at right-most scroll position…"); + + const original = table.getAPI().getHeaders(); + const reversed = [...original].reverse(); + table.update({ defaultHeaders: reversed }); + + // Synchronously inspect the DOM right after the reverse. + const ghosts = Array.from( + canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ), + ); + + if (ghosts.length === 0) { + throw new Error( + `Expected retained ghosts after reordering at right-most scroll, got 0. ` + + `Visible row-0 cells [${Array.from(beforeByAccessor.keys()).join(",")}] ` + + `should have been pushed to the left side of the table (off-band) and ` + + `retained as ghosts sliding left.`, + ); + } + + const ghostsByAccessorRow0 = new Map(); + for (const g of ghosts) { + const accessor = g.getAttribute("data-accessor"); + const rowIndex = g.getAttribute("data-row-index"); + if (!accessor) continue; + if (rowIndex !== "0") continue; + if (!ghostsByAccessorRow0.has(accessor)) { + ghostsByAccessorRow0.set(accessor, g); + } + } + + // We expect at least SOME of the tracked accessors to be retained as + // ghosts. (Not necessarily all — some right-side cells might still + // land inside the post-reorder band depending on the new scrollLeft.) + const retained: string[] = []; + for (const accessor of beforeByAccessor.keys()) { + if (ghostsByAccessorRow0.has(accessor)) retained.push(accessor); + } + if (retained.length === 0) { + throw new Error( + `Expected at least one of [${Array.from(beforeByAccessor.keys()).join(",")}] ` + + `to be retained as a row-0 ghost after the reverse. Found ghosts for ` + + `accessors: [${Array.from(ghostsByAccessorRow0.keys()).join(",")}].`, + ); + } + + // For each retained tracked accessor, verify FLIP correctness. + const summaries: string[] = []; + for (const accessor of retained) { + const ghost = ghostsByAccessorRow0.get(accessor)!; + const oldLeft = beforeByAccessor.get(accessor)!.left; + const newLeft = parseFloat(ghost.style.left || "0"); + const tx = parseTranslateX(ghost.style.transform); + summaries.push( + `${accessor}: oldLeft=${oldLeft} newLeft=${newLeft} tx=${tx} pointer=${ghost.style.pointerEvents}`, + ); + + // newLeft should be much less than oldLeft (cell jumped to the left + // side of the table). + if (!(newLeft < oldLeft - 500)) { + throw new Error( + `Ghost for "${accessor}" should have moved leftward by >500px after a ` + + `column reverse. Got oldLeft=${oldLeft} newLeft=${newLeft}. ` + + `Summaries: ${summaries.join(" | ")}`, + ); + } + + const expectedTx = oldLeft - newLeft; + if (Math.abs(tx - expectedTx) >= 1.5) { + throw new Error( + `Ghost for "${accessor}" has wrong FLIP tx: expected ${expectedTx} (oldLeft - newLeft), ` + + `got ${tx}. Summaries: ${summaries.join(" | ")}`, + ); + } + if (tx <= 0) { + throw new Error( + `Ghost for "${accessor}" sliding LEFT must have a POSITIVE FLIP tx (it visually ` + + `starts to the right of where it ends). Got tx=${tx}. ` + + `Summaries: ${summaries.join(" | ")}`, + ); + } + + expect( + ghost.style.pointerEvents, + `Ghost for "${accessor}" should have pointer-events: none while sliding`, + ).toBe("none"); + } + + await sleep(SETTLE_PAUSE); + + announce(status, "Verifying ghost cleanup after settle…"); + const ghostsAfterSettle = canvasElement.querySelectorAll( + `.st-body-main [data-animating-out="true"]`, + ); + expect( + ghostsAfterSettle.length, + `All retained ghosts should be removed once the slide completes`, + ).toBe(0); + + canvasElement + .querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ) + .forEach((c) => { + const t = c.style.transform; + expect(t === "" || t === "none").toBe(true); + }); + + announce(status, "Done."); + }, +}; From 88caaf48d919b1475566594a1cd3ffabf7e7d874 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:25:18 -0500 Subject: [PATCH 05/20] Slide in slide out improvements --- .../core/src/managers/AnimationCoordinator.ts | 126 ++++++++- .../tests/41-CellAnimationsTests.stories.ts | 72 ++--- ...llAnimationsVirtualizationTests.stories.ts | 251 ++++++------------ 3 files changed, 229 insertions(+), 220 deletions(-) diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 0424edad7..5ee84fd4a 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -197,6 +197,17 @@ export class AnimationCoordinator { newPosition: CellPosition; }): void { const { cellId, element, container, newPosition } = args; + const oldTop = parsePx(element.style.top); + + // Scale the visual destination so the slide journey is bounded but + // proportional to the true conceptual journey. Without scaling, a row + // sorted from position 0 to position 499 of a virtualized 500-row table + // would try to slide ~16k pixels in the animation window — under + // ease-out it crosses the 500px viewport in the first ~30ms and the + // cell appears to teleport. The scaling also gives cells with very + // different conceptual destinations visibly different slide distances, + // so they fan out instead of marching off-screen in lockstep. + const clippedTop = scaleFlipDistance(newPosition.top, oldTop, newPosition.height, container); let map = this.retainedCells.get(container); if (!map) { @@ -220,7 +231,7 @@ export class AnimationCoordinator { element.setAttribute(RETAINED_ATTR, "true"); element.style.left = `${newPosition.left}px`; - element.style.top = `${newPosition.top}px`; + element.style.top = `${clippedTop}px`; element.style.width = `${newPosition.width}px`; element.style.height = `${newPosition.height}px`; // Disable pointer events on departing cells so they don't intercept clicks. @@ -254,12 +265,19 @@ export class AnimationCoordinator { this.snapshot = null; if (!this.isEnabled() || !snapshot) { - // Nothing to play; clean up any leftover retained cells. + // Nothing to play. Drop only retained cells that aren't already + // mid-animation; in-flight ghosts have a transition running and will + // clean themselves up on transitionend. Wiping them here would kill + // the slide-out for renders triggered by ResizeObserver / scrollbar + // visibility / dimension recompute that fire during an animation. this.retainedCells.forEach((map) => { - map.forEach((element) => element.remove()); - map.clear(); + map.forEach((element, cellId) => { + if (!this.inFlight.has(cellId)) { + element.remove(); + map.delete(cellId); + } + }); }); - this.retainedCells.clear(); return; } @@ -273,7 +291,12 @@ export class AnimationCoordinator { const pending: Pending[] = []; const seen = new Set(); - const consider = (element: HTMLElement, cellId: string, isRetained: boolean) => { + const consider = ( + element: HTMLElement, + cellId: string, + isRetained: boolean, + container: HTMLElement, + ) => { if (seen.has(cellId)) return; const before = snapshot.get(cellId); if (!before) return; @@ -282,8 +305,21 @@ export class AnimationCoordinator { const currentLeft = parsePx(element.style.left); const currentTop = parsePx(element.style.top); + + // For incoming cells (newly created live cells), scale the FLIP + // "before" position so cells sliding in from far off-screen take a + // bounded but proportional journey. Without scaling, a row whose + // pre-sort conceptual top was 14970 sliding to currentTop=0 would + // start ~15k pixels below the viewport — with ease-out it stays + // off-screen for most of the animation, leaving the viewport empty + // until the last few percent. Retained cells already had their + // style.top scaled at retainCell time, so we don't re-scale here. + const beforeTopClipped = isRetained + ? before.top + : scaleFlipDistance(before.top, currentTop, element.offsetHeight || 0, container); + const dx = before.left - currentLeft; - const dy = before.top - currentTop; + const dy = beforeTopClipped - currentTop; if (Math.abs(dx) < MIN_DELTA && Math.abs(dy) < MIN_DELTA) { // No visual movement — if this was a retained cell with no movement // (a degenerate case), still drop it so we don't leak DOM. @@ -301,12 +337,12 @@ export class AnimationCoordinator { // Retained (outgoing) cells animate first so we collect them. const retained = this.retainedCells.get(container); if (retained) { - retained.forEach((element, cellId) => consider(element, cellId, true)); + retained.forEach((element, cellId) => consider(element, cellId, true, container)); } // Active cells: incoming + persistent. const cells = collectRenderedCells(container); - cells.forEach((element, cellId) => consider(element, cellId, false)); + cells.forEach((element, cellId) => consider(element, cellId, false, container)); } // FLIP "First" frame: apply inverse transforms synchronously so cells @@ -465,6 +501,78 @@ const parsePx = (value: string): number => { return Number.isFinite(parsed) ? parsed : 0; }; +/** + * Compression factor for the off-screen portion of the FLIP journey. Larger + * values squeeze the off-screen overshoot more aggressively (cells with very + * different conceptual destinations end up closer in slide distance); smaller + * values give more visual spread. Tuned so that a row sorting to the bottom + * of a typical 500-row dataset slides ~1.7× viewport while preserving the + * sense that it's heading "really far". + */ +const OFFSCREEN_COMPRESSION_FACTOR = 10; + +/** + * Scale a FLIP journey so the visible slide is bounded but its length is + * proportional to the cell's true conceptual journey, preserving the sign + * and a clear sense of "this cell is going further than that one". + * + * Returns the new `top` to assign to the FLIP endpoint (the outgoing ghost's + * `style.top`, or the snapshot `before.top` for an incoming cell). + * + * The journey is split into two regimes: + * + * 1. **In-viewport range** (|delta| ≤ viewportHeight + cellHeight): + * The cell is sliding to/from a position inside or just past the visible + * band, so we use the true delta untouched. Small reorders, partial-move + * sorts and persistent in-viewport cells are unaffected. + * + * 2. **Off-screen overshoot** (|delta| > visibleRange): + * The cell is sliding to/from a far conceptual position that's invisible + * anyway. We let the slide overshoot the visible edge by an amount that + * grows with the true delta but smoothly asymptotes at `maxOvershoot`, + * so cells with vastly different true journeys still slide *different* + * distances (no piling-up), and cells with truly extreme conceptual + * positions (e.g. a million pixels) stay bounded. + * + * The asymptotic formula is `maxOvershoot * extra / (extra + visibleRange * k)` + * which is 0 when `extra = 0`, approaches `maxOvershoot` as `extra → ∞`, and + * has no discontinuity at the boundary. + * + * No-op when the section's parent does not scroll vertically (small datasets, + * header sections). + */ +const scaleFlipDistance = ( + distantTop: number, + anchorTop: number, + cellHeight: number, + container: HTMLElement, +): number => { + const scroller = container.parentElement; + if (!scroller) return distantTop; + const clientHeight = scroller.clientHeight; + if (clientHeight <= 0 || scroller.scrollHeight <= clientHeight) return distantTop; + + const delta = distantTop - anchorTop; + const absDelta = Math.abs(delta); + if (absDelta === 0) return distantTop; + + const cellBuffer = cellHeight > 0 ? cellHeight : 0; + // Threshold below which we pass the journey through unchanged. Cells whose + // true delta fits within the visible band + one cell of overshoot are + // already on-screen and don't need scaling. + const visibleRange = clientHeight + cellBuffer; + if (absDelta <= visibleRange) return distantTop; + + // Off-screen extra distance, smoothly compressed and asymptotic to + // `maxOvershoot`. With maxOvershoot = clientHeight, the longest possible + // visible slide is ~2× viewport height (visibleRange + maxOvershoot). + const maxOvershoot = clientHeight; + const extra = absDelta - visibleRange; + const compressed = (maxOvershoot * extra) / (extra + visibleRange * OFFSCREEN_COMPRESSION_FACTOR); + const scaledMagnitude = visibleRange + compressed; + return anchorTop + Math.sign(delta) * scaledMagnitude; +}; + const readPrefersReducedMotion = (): boolean => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return false; diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index 3eed3b666..14e726da0 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -290,7 +290,10 @@ export const SimpleThreeByThreeCenterToRightSwap = { result.h2.textContent = `Simplest 3×3 column swap · move center → right · ${SLOW_DURATION}ms`; const findByAccessor = (accessor: string): HeaderObject => { - const h = result.table.getAPI().getHeaders().find((x) => x.accessor === accessor); + const h = result.table + .getAPI() + .getHeaders() + .find((x) => x.accessor === accessor); if (!h) throw new Error(`Missing header "${accessor}"`); return h; }; @@ -342,10 +345,7 @@ export const SimpleThreeByThreeCenterToRightSwap = { * change have no transform, and that columns that swap have opposite- * sign transforms (one slid left, the other slid right). */ - const runReorderStep = async ( - stepLabel: string, - nextAccessors: string[], - ): Promise => { + const runReorderStep = async (stepLabel: string, nextAccessors: string[]): Promise => { const beforeLefts = new Map(); const beforeIndexByAccessor = new Map(); const currentHeaders = table.getAPI().getHeaders(); @@ -365,9 +365,7 @@ export const SimpleThreeByThreeCenterToRightSwap = { if (!h) throw new Error(`[${stepLabel}] header "${acc}" missing`); return h; }); - const nextIndexByAccessor = new Map( - nextAccessors.map((acc, i) => [acc, i]), - ); + const nextIndexByAccessor = new Map(nextAccessors.map((acc, i) => [acc, i])); // CRITICAL: trigger the update and read the FLIP "First" frame // *synchronously*. play() sets `transform: translate3d(dx, dy, 0)` @@ -397,8 +395,7 @@ export const SimpleThreeByThreeCenterToRightSwap = { oldLeft: beforeLefts.get(`${row}:${accessor}`)!, newLeft, txX, - moved: - beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), + moved: beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), }); } } @@ -420,9 +417,7 @@ export const SimpleThreeByThreeCenterToRightSwap = { const stillSamples = samples.filter((s) => !s.moved); for (const s of stillSamples) { - expect(s.newLeft, `[${stepLabel}] still col ${s.accessor} should not move`).toBe( - s.oldLeft, - ); + expect(s.newLeft, `[${stepLabel}] still col ${s.accessor} should not move`).toBe(s.oldLeft); expect(s.txX, `[${stepLabel}] still col ${s.accessor} should have no tx`).toBe(0); } @@ -584,9 +579,7 @@ export const HeaderCellsAnimateOnColumnReorder = { if (!h) throw new Error(`[${stepLabel}] header "${acc}" missing`); return h; }); - const nextIndexByAccessor = new Map( - nextAccessors.map((acc, i) => [acc, i]), - ); + const nextIndexByAccessor = new Map(nextAccessors.map((acc, i) => [acc, i])); // CRITICAL: trigger the update and read the FLIP "First" frame // synchronously. Awaiting any RAF here would only ever surface @@ -611,8 +604,7 @@ export const HeaderCellsAnimateOnColumnReorder = { oldLeft: beforeLefts.get(accessor)!, newLeft, txX, - moved: - beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), + moved: beforeIndexByAccessor.get(accessor) !== nextIndexByAccessor.get(accessor), }); } @@ -743,9 +735,7 @@ export const HeaderCellsAnimateDuringDragReorder = { const ACCESSORS = ["a", "b", "c"] as const; const findHeaderCell = (accessor: string): HTMLElement | null => - canvasElement.querySelector( - `.st-header-cell[data-accessor="${accessor}"]`, - ); + canvasElement.querySelector(`.st-header-cell[data-accessor="${accessor}"]`); const findHeaderLabel = (accessor: string): HTMLElement => { const cell = findHeaderCell(accessor); @@ -975,9 +965,7 @@ export const HeaderDragDoesNotFlickerDuringAnimation = { const table = getTable(); const findHeaderCell = (accessor: string): HTMLElement | null => - canvasElement.querySelector( - `.st-header-cell[data-accessor="${accessor}"]`, - ); + canvasElement.querySelector(`.st-header-cell[data-accessor="${accessor}"]`); const findHeaderLabel = (accessor: string): HTMLElement => { const cell = findHeaderCell(accessor); @@ -1039,23 +1027,18 @@ export const HeaderDragDoesNotFlickerDuringAnimation = { await tickFrames(3); const orderAfterFirstSwap = currentOrder(); - expect( - orderAfterFirstSwap, - "first dragover should have triggered B↔C swap", - ).toBe("a,c,b"); + expect(orderAfterFirstSwap, "first dragover should have triggered B↔C swap").toBe("a,c,b"); // The moving headers (B and C) should be hit-test-disabled. const cellA = findHeaderCell("a")!; const cellB = findHeaderCell("b")!; const cellC = findHeaderCell("c")!; - expect( - cellB.style.pointerEvents, - "header B should be pointer-events: none mid-FLIP", - ).toBe("none"); - expect( - cellC.style.pointerEvents, - "header C should be pointer-events: none mid-FLIP", - ).toBe("none"); + expect(cellB.style.pointerEvents, "header B should be pointer-events: none mid-FLIP").toBe( + "none", + ); + expect(cellC.style.pointerEvents, "header C should be pointer-events: none mid-FLIP").toBe( + "none", + ); expect( cellA.style.pointerEvents, "header A did not move and should keep default pointer-events", @@ -1137,8 +1120,7 @@ export const HeaderDragDoesNotFlickerDuringAnimation = { for (const accessor of ACCESSORS) { const headerCell = findHeaderCell(accessor)!; expect( - headerCell.style.pointerEvents === "" || - headerCell.style.pointerEvents === "auto", + headerCell.style.pointerEvents === "" || headerCell.style.pointerEvents === "auto", `header ${accessor} pointer-events not restored after settle ` + `(was "${headerCell.style.pointerEvents}")`, ).toBe(true); @@ -1572,12 +1554,10 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { // After play()'s RAF fires, transitions are applied — and only on // transform, never on opacity. await tickFrames(2); - canvasElement - .querySelectorAll(`[data-animating-out="true"]`) - .forEach((el) => { - expect(el.style.transition).toContain("transform"); - expect(el.style.transition).not.toContain("opacity"); - }); + canvasElement.querySelectorAll(`[data-animating-out="true"]`).forEach((el) => { + expect(el.style.transition).toContain("transform"); + expect(el.style.transition).not.toContain("opacity"); + }); await sleep(SETTLE_PAUSE); @@ -1591,9 +1571,7 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { expect(afterIds.has("1")).toBe(false); // No leftover ghosts, transforms, or transitions on settled cells. - const leftoverGhosts = canvasElement.querySelectorAll( - `[data-animating-out="true"]`, - ); + const leftoverGhosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); expect(leftoverGhosts.length).toBe(0); canvasElement.querySelectorAll(".st-body-main .st-cell").forEach((cell) => { const t = cell.style.transform; diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index 22c6668b7..fd89cccbc 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -256,9 +256,7 @@ const sampleVisuallyMovingCells = async ( frames: number, minDelta = 1, ): Promise<{ moved: number; sampled: number; maxDx: number; maxDy: number }> => { - const cells = Array.from( - canvasElement.querySelectorAll(`.st-body-main .st-cell`), - ); + const cells = Array.from(canvasElement.querySelectorAll(`.st-body-main .st-cell`)); const before = cells.map((el) => el.getBoundingClientRect()); await tickFrames(frames); const after = cells.map((el) => el.getBoundingClientRect()); @@ -301,13 +299,9 @@ const parseTranslateX = (transform: string): number => { const parseTranslateY = (transform: string): number => { if (!transform || transform === "none") return 0; // translate3d(, , ) or translate(, ) — pull the Y component. - const t3d = transform.match( - /translate3d\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/, - ); + const t3d = transform.match(/translate3d\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/); if (t3d) return parseFloat(t3d[1]); - const t2d = transform.match( - /translate\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/, - ); + const t2d = transform.match(/translate\(\s*-?\d+(?:\.\d+)?px\s*,\s*(-?\d+(?:\.\d+)?)px/); if (t2d) return parseFloat(t2d[1]); // matrix(a, b, c, d, tx, ty) — pull ty (6th component). const m = transform.match( @@ -983,15 +977,14 @@ export const SortMarathon = { * If the cell is dropped immediately, the user sees it pop out of existence * in place — a visible jank during sort. * - * The test: - * a. Captures the cells at the top of the visible band (which will sort - * to the bottom on `col_0 desc`). - * b. Triggers the sort and SYNCHRONOUSLY (after the snapshot+render+FLIP - * first frame) checks that those cells are still in the DOM as - * retained ghosts at the new far-off-screen `top`. - * c. Verifies they have an inverse FLIP transform applied. - * d. Waits past the animation duration and confirms the ghosts have been - * cleaned up. + * The test grabs any cell in the top-most visible row, records its on-screen + * rect, triggers a sort that pushes it far off-screen, and then samples + * shortly after — well before the long animation could finish. With a + * working slide-out, the cell is still in the DOM and visually only a small + * fraction of the way to its new position. With a broken slide-out, the + * cell is either missing from the DOM (removed instantly) or already at its + * far-off-screen target (snapped). After the animation settles we also + * confirm the ghost was cleaned up. */ export const SortRetainsCellsThatExitVirtualizedBand = { tags: ["sort-retains-virtualized-ghosts"], @@ -1003,147 +996,95 @@ export const SortRetainsCellsThatExitVirtualizedBand = { result.h2.textContent = `Sort retains off-screen cells as ghosts · ${SLOW_DURATION}ms`; addParagraph( result.wrapper, - "On a sort that pushes a visible row to a virtualized off-screen position, " + - "the cell must remain in the DOM as a `data-animating-out` ghost while it " + - "slides to its new off-screen `top` — not pop out in place.", + "When a sort pushes the top-most visible row off-screen, the cell must " + + "stay in the DOM and slide visually toward its new position — not pop " + + "out in place and not snap straight to the destination.", result.tableContainer, ); return result.wrapper; }, play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - const status = - canvasElement.querySelector("div[style*='background: #f4f6fb']") ?? - document.createElement("div"); await sleep(BEAT); const table = getTable(); - // Snapshot the body's row-height so we can compute the off-screen - // bottom position the ghost should slide to. - const sampleCell = canvasElement.querySelector( - `.st-body-main .st-cell[data-row-index="0"][data-accessor="id"]`, + // Pick any cell in row 0 — sorting `col_0 desc` makes col_0=0 (the + // lowest value, which is row 0's value) sort to position 499 of 500, + // far below the viewport. + const cell = canvasElement.querySelector( + `.st-body-main .st-cell[data-row-index="0"]`, ); - expect(sampleCell, "Need at least one rendered cell at row-index 0").toBeTruthy(); - const rowHeight = parseFloat(sampleCell!.style.height || "0"); - expect(rowHeight, "row height should be > 0").toBeGreaterThan(0); - - // Capture the text content of the top-most visible row's id cell. - // After `col_0 desc`, this row (col_0=0, lowest value) sorts to the - // very bottom (position 499) — well outside the band of ~12-15 rows - // + BODY_CELL_BAND_PADDING. - const topRowText = sampleCell!.textContent?.trim() ?? ""; - expect(topRowText).toBeTruthy(); + expect(cell, "Need a row-0 cell to track").toBeTruthy(); + + // The viewport-relative rect of the cell BEFORE the sort. With a working + // slide-out animation, sampling shortly after the sort starts must show + // the cell still in the DOM and visually CLOSE to this position — only a + // small fraction of the way toward its eventual far-off-screen + // destination. With a broken slide-out the cell is either: + // - missing from the DOM entirely (no retain), OR + // - already at its post-sort `top` thousands of pixels below the + // viewport (snap instead of interpolate). + const rectBefore = cell!.getBoundingClientRect(); - // Capture text for a handful of other top-band id cells too — all of - // these will sort off-screen to the bottom. - const topBandCells = Array.from( - canvasElement.querySelectorAll( - `.st-body-main .st-cell[data-accessor="id"]`, - ), - ) - .filter((c) => !c.hasAttribute("data-animating-out")) - .slice(0, 5); - const topBandTexts = topBandCells.map((c) => c.textContent?.trim() ?? ""); - expect(topBandTexts.length).toBeGreaterThan(2); - - // Pre-sort sanity: no ghosts in the DOM. expect(countGhosts(canvasElement)).toBe(0); - announce(status, "Triggering col_0 desc — top rows should slide off-screen as ghosts…"); - - // Trigger the sort. SortManager.subscribe is synchronous, so by the - // time this returns the renderer has run, retained ghosts have been - // created, and FLIP "First" inverse transforms have been applied. void table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); - // Synchronously inspect the DOM immediately after the sort. - const ghosts = Array.from( - canvasElement.querySelectorAll( - `.st-body-main [data-animating-out="true"]`, - ), - ); + // Sample shortly after the sort fires. With a 20s animation, ~500ms is + // 2.5% of total time — even with aggressive ease-out, a real slide-out + // should still be visually within a few hundred pixels of its starting + // position. The cap (1/40 of the animation budget) keeps this honest if + // someone changes SLOW_DURATION. + const SAMPLE_AT_MS = Math.min(500, Math.floor(SLOW_DURATION / 40)); + await sleep(SAMPLE_AT_MS); - if (ghosts.length === 0) { + if (!cell!.isConnected) { throw new Error( - `Expected retained ghosts immediately after a sort that pushes visible rows ` + - `off-screen, got 0. Top-band rows (${topBandTexts.join(",")}) appear to have been ` + - `removed from the DOM in place instead of being slid out as ghosts.`, + `Row-0 cell was removed from the DOM ${SAMPLE_AT_MS}ms into a ${SLOW_DURATION}ms ` + + `sort animation. It should remain mounted as a 'data-animating-out' ghost ` + + `until the FLIP slide-out completes — not pop out in place.`, ); } - // For each of the rows we captured pre-sort, find a ghost with the - // same text — that's the one for that row. - const ghostsByText = new Map(); - for (const g of ghosts) { - const accessor = g.getAttribute("data-accessor"); - if (accessor !== "id") continue; - const text = g.textContent?.trim() ?? ""; - if (text) ghostsByText.set(text, g); - } - - const missing: string[] = []; - for (const text of topBandTexts) { - if (!ghostsByText.has(text)) missing.push(text); - } - if (missing.length > 0) { + // Direct "is the cell roughly where it was?" assertion. This is the + // user-visible behaviour: when a sort starts, the cells the user can + // currently see should not jerk to a completely different spot in a + // single frame. + const rectAfter = cell!.getBoundingClientRect(); + const dx = Math.abs(rectAfter.left - rectBefore.left); + const dy = Math.abs(rectAfter.top - rectBefore.top); + + // Generous bound: at 2.5% of a 20s slide with cubic-bezier(0.2, 0.8, + // 0.2, 1), the cell will have moved a few hundred pixels at most. A + // snap straight to row 499 is 14000+px. Half a viewport height + // comfortably distinguishes the two without flaking on slow CI. + const MAX_DRIFT_PX = VIEWPORT_HEIGHT / 2; + if (dy > MAX_DRIFT_PX || dx > MAX_DRIFT_PX) { + const styleTop = parseFloat(cell!.style.top || "0"); throw new Error( - `Expected retained ghosts for top-band id cells [${topBandTexts.join(",")}], ` + - `but [${missing.join(",")}] were missing from the DOM. ` + - `(Found ${ghosts.length} total ghosts; saw ids: [${Array.from(ghostsByText.keys()).join(",")}].)`, + `Row-0 cell jumped ${dy.toFixed(0)}px vertically (${dx.toFixed(0)}px ` + + `horizontally) ${SAMPLE_AT_MS}ms into a ${SLOW_DURATION}ms sort animation. ` + + `Max allowed drift is ${MAX_DRIFT_PX}px — anything more means the cell ` + + `snapped to its post-sort destination instead of interpolating from its ` + + `pre-sort position. ` + + `before={left:${rectBefore.left.toFixed(0)}, top:${rectBefore.top.toFixed(0)}} ` + + `after={left:${rectAfter.left.toFixed(0)}, top:${rectAfter.top.toFixed(0)}} ` + + `style.top=${styleTop} dataAnimatingOut=${cell!.getAttribute("data-animating-out")}`, ); } - // Pick the ghost for the top-most pre-sort row and verify its - // post-sort `top` is far below the viewport (proving the renderer - // really did move it to its new off-screen position rather than - // leaving it in place at top=0). - const topGhost = ghostsByText.get(topRowText)!; - const ghostTop = parseFloat(topGhost.style.top || "0"); - const minimumOffScreenTop = VIEWPORT_HEIGHT + 5 * rowHeight; - if (!(ghostTop > minimumOffScreenTop)) { - throw new Error( - `Ghost for row "${topRowText}" should have its style.top moved well below the ` + - `viewport (>${minimumOffScreenTop}), got ${ghostTop}.`, - ); - } - - // The ghost must have an inverse FLIP transform applied so the next - // paint visually leaves it at its OLD on-screen position. Without - // this it would pop straight to its new off-screen spot — invisible. - const ghostTransform = topGhost.style.transform; - if (!ghostTransform || ghostTransform === "none") { - throw new Error( - `Ghost for row "${topRowText}" has no FLIP "First" transform (got "${ghostTransform}"). ` + - `Expected an inverse translate3d(...) so the cell visually starts at its old position.`, - ); - } - - // Pointer events should be suppressed on the outgoing ghost so it - // doesn't intercept clicks while sliding away. - expect(topGhost.style.pointerEvents).toBe("none"); - - // Wait for the animation to settle. - await sleep(SETTLE_PAUSE); - - announce(status, "Verifying ghost cleanup after settle…"); - const ghostsAfterSettle = canvasElement.querySelectorAll( - `.st-body-main [data-animating-out="true"]`, - ); + // Direction sanity: any movement should be DOWNWARD (row 0 is sorting + // toward the bottom of the table). A negative dy with magnitude beyond + // a couple of pixels would mean the cell went the wrong way. expect( - ghostsAfterSettle.length, - `All ${ghosts.length} retained ghosts should be removed once the slide completes`, - ).toBe(0); - - // No leftover transforms on the post-sort active cells. - const activeCells = canvasElement.querySelectorAll( - `.st-body-main .st-cell:not([data-animating-out])`, - ); - activeCells.forEach((c) => { - const t = c.style.transform; - expect(t === "" || t === "none").toBe(true); - }); - - announce(status, "Done."); + rectAfter.top - rectBefore.top, + "Cell should be sliding downward (or barely moved), not upward", + ).toBeGreaterThanOrEqual(-2); + + // (Ghost teardown is covered by `OverlappingSortsRetainAndReaimGhosts` + // and the other settle-state tests below — keeping this test focused + // on the user-visible "is the cell still mid-slide?" question lets it + // run quickly even when SLOW_DURATION is bumped way up for debugging.) }, }; @@ -1216,9 +1157,7 @@ export const SortRetainsCellsThatExitUpwardWhenScrolled = { // sort these cells should be retained as ghosts at much smaller `top` // values (because they jumped to the top of the table). const visibleIdCells = Array.from( - canvasElement.querySelectorAll( - `.st-body-main .st-cell[data-accessor="id"]`, - ), + canvasElement.querySelectorAll(`.st-body-main .st-cell[data-accessor="id"]`), ).filter((c) => !c.hasAttribute("data-animating-out")); const beforeByText = new Map(); @@ -1233,9 +1172,7 @@ export const SortRetainsCellsThatExitUpwardWhenScrolled = { ).toBeGreaterThan(2); // All sampled cells should currently be near the bottom of the dataset. - const minPreSortTop = Math.min( - ...Array.from(beforeByText.values()).map((v) => v.top), - ); + const minPreSortTop = Math.min(...Array.from(beforeByText.values()).map((v) => v.top)); expect(minPreSortTop).toBeGreaterThan(VIEWPORT_HEIGHT * 10); expect(countGhosts(canvasElement)).toBe(0); @@ -1247,9 +1184,7 @@ export const SortRetainsCellsThatExitUpwardWhenScrolled = { // Synchronously inspect the DOM right after the sort. const ghosts = Array.from( - canvasElement.querySelectorAll( - `.st-body-main [data-animating-out="true"]`, - ), + canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`), ); if (ghosts.length === 0) { @@ -1454,20 +1389,16 @@ export const OverlappingSortsRetainAndReaimGhosts = { await sleep(SETTLE_PAUSE); announce(status, "Verifying full cleanup after overlapping sorts settle…"); - const finalGhosts = canvasElement.querySelectorAll( - `.st-body-main [data-animating-out="true"]`, - ); + const finalGhosts = canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`); expect( finalGhosts.length, `All retained ghosts should be removed once both sort animations finish`, ).toBe(0); - canvasElement - .querySelectorAll(`.st-body-main .st-cell`) - .forEach((c) => { - const t = c.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + canvasElement.querySelectorAll(`.st-body-main .st-cell`).forEach((c) => { + const t = c.style.transform; + expect(t === "" || t === "none").toBe(true); + }); announce(status, "Done."); }, @@ -1546,9 +1477,7 @@ export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { // `left` values, far behind our current scrollLeft — they should be // outside the post-reorder band and retained as ghosts. const visibleRow0Cells = Array.from( - canvasElement.querySelectorAll( - `.st-body-main .st-cell[data-row-index="0"]`, - ), + canvasElement.querySelectorAll(`.st-body-main .st-cell[data-row-index="0"]`), ).filter((c) => !c.hasAttribute("data-animating-out")); const beforeByAccessor = new Map(); @@ -1564,9 +1493,7 @@ export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { // All sampled cells should be at `left` values close to or past the // current scrollLeft — this is what makes them currently visible. - const minPreLeft = Math.min( - ...Array.from(beforeByAccessor.values()).map((v) => v.left), - ); + const minPreLeft = Math.min(...Array.from(beforeByAccessor.values()).map((v) => v.left)); expect(minPreLeft).toBeGreaterThan(scrollLeftAtSnapshot - 500); expect(countGhosts(canvasElement)).toBe(0); @@ -1579,9 +1506,7 @@ export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { // Synchronously inspect the DOM right after the reverse. const ghosts = Array.from( - canvasElement.querySelectorAll( - `.st-body-main [data-animating-out="true"]`, - ), + canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`), ); if (ghosts.length === 0) { @@ -1673,9 +1598,7 @@ export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { ).toBe(0); canvasElement - .querySelectorAll( - `.st-body-main .st-cell:not([data-animating-out])`, - ) + .querySelectorAll(`.st-body-main .st-cell:not([data-animating-out])`) .forEach((c) => { const t = c.style.transform; expect(t === "" || t === "none").toBe(true); From 24bd0df2c1d790182611e20040dc5a80f484ba01 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:19:43 -0500 Subject: [PATCH 06/20] Test fixes --- packages/core/src/core/SimpleTableVanilla.ts | 8 +- .../core/src/managers/AnimationCoordinator.ts | 2 +- packages/core/src/managers/SortManager.ts | 5 +- .../tests/41-CellAnimationsTests.stories.ts | 88 ++++++---- ...llAnimationsVirtualizationTests.stories.ts | 163 +++++++----------- 5 files changed, 135 insertions(+), 131 deletions(-) diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 967d865af..df3b7cb9c 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -191,9 +191,15 @@ export class SimpleTableVanilla { * than snapping into place. */ private captureAnimationSnapshot(): void { + // Skip the (potentially large) full-section pre-layout build when + // animations are disabled — captureSnapshot would discard the result + // anyway, but the argument is evaluated eagerly before the bail-out. + const preLayouts = this.animationCoordinator.isEnabled() + ? this.renderOrchestrator.getCurrentBodyLayouts() + : undefined; this.animationCoordinator.captureSnapshot({ containers: this.getAnimatableContainers(), - preLayouts: this.renderOrchestrator.getCurrentBodyLayouts(), + preLayouts, }); } diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 5ee84fd4a..45a17bb5c 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -44,7 +44,7 @@ interface InFlightCell { isRetained: boolean; } -const DEFAULT_DURATION = 2000; +const DEFAULT_DURATION = 240; const DEFAULT_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; diff --git a/packages/core/src/managers/SortManager.ts b/packages/core/src/managers/SortManager.ts index 0e979aa0f..e0bd62162 100644 --- a/packages/core/src/managers/SortManager.ts +++ b/packages/core/src/managers/SortManager.ts @@ -160,10 +160,13 @@ export class SortManager { updateSort(props?: { accessor: Accessor; direction?: SortDirection }): void { if (!props) { + // Route through computeSortedRows so grouped tables still see aggregated + // values when sort is cleared. With the aggregation cache this is ~free + // when nothing else has changed. this.state = { ...this.state, sort: null, - sortedRows: this.config.tableRows, + sortedRows: this.computeSortedRows(this.config.tableRows, null), }; this.config.onSortChange?.(null); this.notifySubscribers(); diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index 14e726da0..e4acc1882 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -1178,12 +1178,12 @@ export const ReorderWithoutAnimations = { table.update({ defaultHeaders: reversed }); await new Promise((r) => setTimeout(r, 50)); - const cells = canvasElement.querySelectorAll(".st-body-main .st-cell"); + const cells = Array.from( + canvasElement.querySelectorAll(".st-body-main .st-cell"), + ); expect(cells.length).toBeGreaterThan(0); - cells.forEach((cell) => { - const t = cell.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + const stuck = cells.filter((c) => c.style.transform && c.style.transform !== "none"); + expect(stuck.length, "cells with leftover transform").toBe(0); }, }; @@ -1283,12 +1283,12 @@ export const SortAnimationDemo = { void table.getAPI().applySortState(sort); await sleep(SETTLE_PAUSE); - const cells = canvasElement.querySelectorAll(".st-body-main .st-cell"); + const cells = Array.from( + canvasElement.querySelectorAll(".st-body-main .st-cell"), + ); expect(cells.length).toBeGreaterThan(0); - cells.forEach((cell) => { - const t = cell.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + const stuck = cells.filter((c) => c.style.transform && c.style.transform !== "none"); + expect(stuck.length, `cells with leftover transform after sort by ${sort.accessor}`).toBe(0); const ghosts = canvasElement.querySelectorAll(`.st-body-main [data-animating-out="true"]`); expect(ghosts.length).toBe(0); } @@ -1530,15 +1530,19 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { // Outgoing cells should be retained as ghosts (with non-zero translate) // sliding to their new off-screen positions. - const ghostsImmediately = canvasElement.querySelectorAll( - `[data-animating-out="true"]`, + const ghostsImmediately = Array.from( + canvasElement.querySelectorAll(`[data-animating-out="true"]`), ); expect(ghostsImmediately.length).toBeGreaterThan(0); - ghostsImmediately.forEach((el) => { - expect(el.style.transform).toContain("translate"); - // No fades. We only ever touched transform. - expect(el.style.opacity === "" || el.style.opacity === "1").toBe(true); - }); + const ghostsMissingTranslate = ghostsImmediately.filter( + (el) => !el.style.transform.includes("translate"), + ); + expect(ghostsMissingTranslate.length, "ghosts without a translate transform").toBe(0); + // No fades — we only ever touched transform. + const ghostsWithFade = ghostsImmediately.filter( + (el) => el.style.opacity !== "" && el.style.opacity !== "1", + ); + expect(ghostsWithFade.length, "ghosts with non-1 opacity (should not fade)").toBe(0); // Incoming cells (new ids that weren't in the DOM pre-sort) should also // carry a non-zero FLIP "First" translate, sliding in from off-screen. @@ -1546,18 +1550,35 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { canvasElement.querySelectorAll('.st-body-main [data-accessor="id"]'), ).filter((el) => !beforeIds.has((el.textContent ?? "").trim())); expect(incomingImmediately.length).toBeGreaterThan(0); - for (const el of incomingImmediately) { - expect(el.style.transform).toContain("translate"); - expect(el.style.opacity === "" || el.style.opacity === "1").toBe(true); - } + const incomingMissingTranslate = incomingImmediately.filter( + (el) => !el.style.transform.includes("translate"), + ); + expect( + incomingMissingTranslate.length, + "incoming cells without a translate FLIP transform", + ).toBe(0); + const incomingWithFade = incomingImmediately.filter( + (el) => el.style.opacity !== "" && el.style.opacity !== "1", + ); + expect(incomingWithFade.length, "incoming cells with non-1 opacity").toBe(0); // After play()'s RAF fires, transitions are applied — and only on // transform, never on opacity. await tickFrames(2); - canvasElement.querySelectorAll(`[data-animating-out="true"]`).forEach((el) => { - expect(el.style.transition).toContain("transform"); - expect(el.style.transition).not.toContain("opacity"); - }); + const ghostsAfterPlay = Array.from( + canvasElement.querySelectorAll(`[data-animating-out="true"]`), + ); + const ghostsMissingTransformTransition = ghostsAfterPlay.filter( + (el) => !el.style.transition.includes("transform"), + ); + expect( + ghostsMissingTransformTransition.length, + "ghosts whose transition does not target transform", + ).toBe(0); + const ghostsWithOpacityTransition = ghostsAfterPlay.filter((el) => + el.style.transition.includes("opacity"), + ); + expect(ghostsWithOpacityTransition.length, "ghosts whose transition targets opacity").toBe(0); await sleep(SETTLE_PAUSE); @@ -1573,12 +1594,17 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { // No leftover ghosts, transforms, or transitions on settled cells. const leftoverGhosts = canvasElement.querySelectorAll(`[data-animating-out="true"]`); expect(leftoverGhosts.length).toBe(0); - canvasElement.querySelectorAll(".st-body-main .st-cell").forEach((cell) => { - const t = cell.style.transform; - expect(t === "" || t === "none").toBe(true); - const o = cell.style.opacity; - expect(o === "" || o === "1").toBe(true); - }); + const settledCells = Array.from( + canvasElement.querySelectorAll(".st-body-main .st-cell"), + ); + const stuckSettled = settledCells.filter( + (c) => c.style.transform && c.style.transform !== "none", + ); + expect(stuckSettled.length, "settled cells with leftover transform").toBe(0); + const fadedSettled = settledCells.filter( + (c) => c.style.opacity !== "" && c.style.opacity !== "1", + ); + expect(fadedSettled.length, "settled cells with non-1 opacity").toBe(0); }, }; diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index fd89cccbc..31dbd57bf 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -315,10 +315,13 @@ const parseTranslateY = (transform: string): number => { // ============================================================================ /** - * Slow column reorder marathon: reverse → reset → swap pair → reset, with a - * BEAT pause between each step so each animation phase is clearly visible. - * Asserts that >50 cells get a `transform` transition mid-flight after each - * reorder, and that everything settles cleanly between steps. + * Large-scale reorder round-trip: reverse → reset on a 30-col × 500-row + * table, asserting that >50 cells are mid-flight after each step and that + * everything settles cleanly with no leftover ghosts. + * + * Strict per-cell FLIP transform-X correctness on a contained neighbour + * swap is covered by `ContainedNeighborSwapAnimation` (immediately below) + * on the same constrained table, so we don't repeat it here. */ export const SlowColumnReorderMarathon = { render: () => { @@ -398,7 +401,7 @@ export const SlowColumnReorderMarathon = { const api = table.getAPI(); const original = api.getHeaders(); - announce(status, "Step 1/5 · Reversing all columns…"); + announce(status, "Step 1/2 · Reversing all columns…"); table.update({ defaultHeaders: [...original].reverse() }); // 5 RAFs ≈ 80ms — well past the double-rAF FLIP "First"/"Play" handoff // and into the active transition window (animationDuration=1500ms). @@ -407,59 +410,13 @@ export const SlowColumnReorderMarathon = { await sleep(SETTLE_PAUSE); expect(countGhosts(canvasElement)).toBe(0); - announce(status, "Step 2/5 · Resetting to original order…"); + announce(status, "Step 2/2 · Resetting to original order…"); await sleep(BEAT); table.update({ defaultHeaders: original }); await tickFrames(5); expect(countActuallyAnimating(canvasElement)).toBeGreaterThan(50); await sleep(SETTLE_PAUSE); - // STRICT step: swap two cells that are BOTH visible in the viewport so we - // can synchronously assert the FLIP "First" frame's transform-X exactly - // equals (oldLeft - newLeft) — catches regressions where cells animate - // from a wrong anchor. - announce(status, "Step 3/5 · Strict contained swap col_0 ↔ col_2…"); - await sleep(BEAT); - const cellA = findCellByRowIndexAndAccessor(canvasElement, 0, "col_0"); - const cellB = findCellByRowIndexAndAccessor(canvasElement, 0, "col_2"); - expect(cellA).toBeTruthy(); - expect(cellB).toBeTruthy(); - const aOldLeft = parseFloat(cellA!.style.left || "0"); - const bOldLeft = parseFloat(cellB!.style.left || "0"); - expect(aOldLeft).not.toBe(bOldLeft); - - const swapped = [...original]; - const ai = swapped.findIndex((h) => h.accessor === "col_0"); - const bi = swapped.findIndex((h) => h.accessor === "col_2"); - [swapped[ai], swapped[bi]] = [swapped[bi], swapped[ai]]; - table.update({ defaultHeaders: swapped }); - - const cellAAfter = findCellByRowIndexAndAccessor(canvasElement, 0, "col_0"); - const cellBAfter = findCellByRowIndexAndAccessor(canvasElement, 0, "col_2"); - const aNewLeft = parseFloat(cellAAfter!.style.left || "0"); - const bNewLeft = parseFloat(cellBAfter!.style.left || "0"); - expect(aNewLeft).toBeCloseTo(bOldLeft, 0); - expect(bNewLeft).toBeCloseTo(aOldLeft, 0); - - const aTx = parseTranslateX(cellAAfter!.style.transform); - const bTx = parseTranslateX(cellBAfter!.style.transform); - expect(Math.abs(aTx - (aOldLeft - aNewLeft))).toBeLessThan(1.5); - expect(Math.abs(bTx - (bOldLeft - bNewLeft))).toBeLessThan(1.5); - expect(Math.sign(aTx)).not.toBe(Math.sign(bTx)); - expect(aTx).not.toBe(0); - expect(bTx).not.toBe(0); - await sleep(SETTLE_PAUSE); - - announce(status, "Step 4/5 · Reset after contained swap…"); - await sleep(BEAT); - table.update({ defaultHeaders: original }); - await sleep(SETTLE_PAUSE); - - announce(status, "Step 5/5 · Final reset (no-op)…"); - await sleep(BEAT); - table.update({ defaultHeaders: original }); - await sleep(SETTLE_PAUSE); - announce(status, "Done."); expect(countGhosts(canvasElement)).toBe(0); }, @@ -672,9 +629,11 @@ export const ReorderAtMultipleScrollPositions = { const scroller = findScroller(canvasElement); expect(scroller).toBeTruthy(); + // Two extremes are enough to prove the snapshot tracks the visible band + // wherever the user has scrolled — a mid-table position is just a point + // between these two and adds no behavioural coverage. const positions: Array<{ label: string; top: number }> = [ { label: "top", top: 0 }, - { label: "mid (4000px)", top: 4000 }, { label: "deep (10000px)", top: 10000 }, ]; @@ -864,13 +823,14 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { }; /** - * Sort marathon. With `getRowId` providing stable identities, sort now - * triggers FLIP slides: persistent rows slide to their new tops, rows - * sorting out of the visible band slide out past the viewport edge as - * retained ghosts, rows sorting into the band slide in from below/above. + * Sort cleanup at scale. Verifies that after a sort animation settles on a + * 500-row × 30-col table, every retained ghost is torn down and no cell is + * left with a stuck transform or transition. * - * After each sort settles (we wait past SLOW_DURATION) there must be no - * leftover ghosts, transforms, or stuck transitions. + * Per-sort FLIP correctness, ghost retention, mid-flight re-aim, and the + * upward / horizontal exit cases each have their own focused regression + * tests below — this one only proves cleanup holds at scale across a + * round-trip (sort → clear). */ export const SortMarathon = { render: () => { @@ -928,30 +888,28 @@ export const SortMarathon = { await sleep(BEAT); const table = getTable(); - const sequence: Array<{ - label: string; - sort: { accessor: string; direction: "asc" | "desc" } | undefined; - }> = [ - { label: "col_0 desc", sort: { accessor: "col_0", direction: "desc" } }, - { label: "col_0 asc", sort: { accessor: "col_0", direction: "asc" } }, - { label: "col_5 desc", sort: { accessor: "col_5", direction: "desc" } }, - { label: "cleared", sort: undefined }, - ]; - - for (let i = 0; i < sequence.length; i++) { - const { label, sort } = sequence[i]; - announce(status, `Step ${i + 1}/${sequence.length} · Sorting ${label}…`); - void table.getAPI().applySortState(sort); - // SETTLE_PAUSE outlasts the slide so retained ghosts are torn down. - await sleep(SETTLE_PAUSE); + const assertSettledAndClean = (): void => { expect(countGhosts(canvasElement)).toBe(0); - const cells = canvasElement.querySelectorAll(`.st-body-main .st-cell`); + const cells = Array.from( + canvasElement.querySelectorAll(`.st-body-main .st-cell`), + ); expect(cells.length).toBeGreaterThan(0); - cells.forEach((cell) => { - const t = cell.style.transform; - expect(t === "" || t === "none").toBe(true); - }); - } + // Aggregate per-cell checks into a single expect call. Per-cell + // expects on this 30-col × 500-row table generate hundreds of + // Storybook interactions per assertion, which lags the browser. + const stuck = cells.filter((c) => c.style.transform && c.style.transform !== "none"); + expect(stuck.length, `cells with leftover transform after settle`).toBe(0); + }; + + announce(status, "Sorting col_0 desc…"); + void table.getAPI().applySortState({ accessor: "col_0", direction: "desc" }); + await sleep(SETTLE_PAUSE); + assertSettledAndClean(); + + announce(status, "Clearing sort…"); + void table.getAPI().applySortState(); + await sleep(SETTLE_PAUSE); + assertSettledAndClean(); announce(status, "Done."); }, @@ -1279,13 +1237,13 @@ export const SortRetainsCellsThatExitUpwardWhenScrolled = { `All ${ghosts.length} retained ghosts should be removed once the slide completes`, ).toBe(0); - const activeCells = canvasElement.querySelectorAll( - `.st-body-main .st-cell:not([data-animating-out])`, + const activeCells = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ), ); - activeCells.forEach((c) => { - const t = c.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + const stuck = activeCells.filter((c) => c.style.transform && c.style.transform !== "none"); + expect(stuck.length, "active cells with leftover transform after settle").toBe(0); announce(status, "Done."); }, @@ -1395,10 +1353,15 @@ export const OverlappingSortsRetainAndReaimGhosts = { `All retained ghosts should be removed once both sort animations finish`, ).toBe(0); - canvasElement.querySelectorAll(`.st-body-main .st-cell`).forEach((c) => { - const t = c.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + const allCells = Array.from( + canvasElement.querySelectorAll(`.st-body-main .st-cell`), + ); + const stuckOverlap = allCells.filter( + (c) => c.style.transform && c.style.transform !== "none", + ); + expect(stuckOverlap.length, "cells with leftover transform after overlapping sorts settle").toBe( + 0, + ); announce(status, "Done."); }, @@ -1597,12 +1560,18 @@ export const ReorderAfterHorizontalScrollRetainsExitingCellsAsGhosts = { `All retained ghosts should be removed once the slide completes`, ).toBe(0); - canvasElement - .querySelectorAll(`.st-body-main .st-cell:not([data-animating-out])`) - .forEach((c) => { - const t = c.style.transform; - expect(t === "" || t === "none").toBe(true); - }); + const activeCellsHScroll = Array.from( + canvasElement.querySelectorAll( + `.st-body-main .st-cell:not([data-animating-out])`, + ), + ); + const stuckHScroll = activeCellsHScroll.filter( + (c) => c.style.transform && c.style.transform !== "none", + ); + expect( + stuckHScroll.length, + "active cells with leftover transform after horizontal-reorder settle", + ).toBe(0); announce(status, "Done."); }, From 05de1c917bbe8eab84a24b6228e7f8e5323aa68b Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:43:47 -0500 Subject: [PATCH 07/20] Sort slide out fix --- .../core/src/managers/AnimationCoordinator.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 45a17bb5c..595abe3ca 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -44,7 +44,7 @@ interface InFlightCell { isRetained: boolean; } -const DEFAULT_DURATION = 240; +const DEFAULT_DURATION = 240000; const DEFAULT_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; @@ -557,6 +557,24 @@ const scaleFlipDistance = ( if (absDelta === 0) return distantTop; const cellBuffer = cellHeight > 0 ? cellHeight : 0; + + // If `distantTop` is itself inside the visible viewport, it's a real visible + // position (a surviving cell's actual previous spot, or a real new spot we + // want a retained ghost to slide into) — not a far-off conceptual one. + // Compressing it would pull the cell AWAY from the viewport edge and hide + // the only on-screen portion of the journey. Pass it through unchanged. + // (Without this guard, a cell sliding from a visible row to an off-screen + // row "disappears" mid-animation: |delta| exceeds visibleRange, so the + // compression below pulls the visible end-point past the body's overflow + // clip and the cell is never painted.) + const scrollTop = scroller.scrollTop; + if ( + distantTop >= scrollTop - cellBuffer && + distantTop <= scrollTop + clientHeight + ) { + return distantTop; + } + // Threshold below which we pass the journey through unchanged. Cells whose // true delta fits within the visible band + one cell of overshoot are // already on-screen and don't need scaling. From 47d3bc7853e9b5f056c22025adff4cc8e6b26fa6 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:44:06 -0500 Subject: [PATCH 08/20] Revert --- packages/core/src/managers/AnimationCoordinator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 595abe3ca..d9ae6a098 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -44,7 +44,7 @@ interface InFlightCell { isRetained: boolean; } -const DEFAULT_DURATION = 240000; +const DEFAULT_DURATION = 240; const DEFAULT_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; From bd434ff4d218dfb9e86694993bf8f304399335d6 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:01:59 -0500 Subject: [PATCH 09/20] column re order fixes --- .../src/core/rendering/SectionRenderer.ts | 17 +++---- .../core/src/utils/headerCell/dragging.ts | 22 +++++---- packages/core/src/utils/headerCell/styling.ts | 48 ++++++++++--------- packages/core/src/utils/headerCellRenderer.ts | 26 ++++++++++ 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index fb06954d0..8750ec407 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -1083,22 +1083,19 @@ export class SectionRenderer { // (so expand icon can animate) and remove only cells no longer visible. this.bodyCellsCache.clear(); } else if (type === "header") { + // Only clear the calculated cells cache so we recompute layout for new header order/widths. + // Do NOT clear rendered cell elements: renderHeaderCells reuses cells by accessor and updates + // position/classes in place. Tearing down cells on every drag swap caused visible flicker + // because each `dragover` recreated all 11 header cells (debug session 65665a, H6). this.headerCellsCache.clear(); - // Clear rendered cell elements from all header sections - this.headerSections.forEach((section) => { - cleanupHeaderCellRendering(section); - }); } else if (type === "context") { this.contextCache.clear(); // Recompute absolute header layout from current effectiveHeaders; otherwise // cached AbsoluteCell.header refs drift from live objects (sort/resize bug). this.headerCellsCache.clear(); - // Clear header rendered elements so sort indicators etc. update. - // Do NOT clear body rendered elements: renderBodyCells will update existing cells - // in place (e.g. selection classes, expand icon state) so expand icon can animate. - this.headerSections.forEach((section) => { - cleanupHeaderCellRendering(section); - }); + // Do NOT clear rendered header cells: renderHeaderCells refreshes icons (sort/filter) + // in place via per-cell iconState tracking on dataset. See `renderHeaderCells` + // existing-cell branch in `headerCellRenderer.ts`. } } diff --git a/packages/core/src/utils/headerCell/dragging.ts b/packages/core/src/utils/headerCell/dragging.ts index 7b12d61a2..36a2deee7 100644 --- a/packages/core/src/utils/headerCell/dragging.ts +++ b/packages/core/src/utils/headerCell/dragging.ts @@ -126,7 +126,7 @@ export const attachDragHandlers = ( header: HeaderObject, context: HeaderRenderContext, ) => { - const { columnReordering, draggedHeaderRef, hoveredHeaderRef, headers } = context; + const { columnReordering, draggedHeaderRef, hoveredHeaderRef } = context; const isSelectionColumn = header.isSelectionColumn && context.enableRowSelection; if (!columnReordering || header.disableReorder || isSelectionColumn) return; @@ -161,9 +161,15 @@ export const attachDragHandlers = ( const dragEvent = event as DragEvent; dragEvent.preventDefault(); - if (!headers || !draggedHeaderRef.current) return; + if (!draggedHeaderRef.current) return; throttle(() => { + // Read live headers (not the closure snapshot from attach time): cells are reused + // across renders, so the captured `headers` ref drifts after each reorder. Using + // live headers here ensures every swap is computed against the current state. + const liveHeaders = context.getHeaders(); + if (!liveHeaders) return; + const { screenX, screenY } = dragEvent; const distance = Math.sqrt( Math.pow(screenX - prevDraggingPosition.screenX, 2) + @@ -184,15 +190,15 @@ export const attachDragHandlers = ( if (isCrossSectionDrag) { const result = insertHeaderAcrossSections({ - headers, + headers: liveHeaders, draggedHeader, hoveredHeader: header, }); newHeaders = result.newHeaders; emergencyBreak = result.emergencyBreak; } else { - const draggedHeaderIndexPath = getHeaderIndexPath(headers, draggedHeader.accessor); - const hoveredHeaderIndexPath = getHeaderIndexPath(headers, header.accessor); + const draggedHeaderIndexPath = getHeaderIndexPath(liveHeaders, draggedHeader.accessor); + const hoveredHeaderIndexPath = getHeaderIndexPath(liveHeaders, header.accessor); if (!draggedHeaderIndexPath || !hoveredHeaderIndexPath) return; @@ -218,7 +224,7 @@ export const attachDragHandlers = ( return; } - const result = swapHeaders(headers, draggedHeaderIndexPath, targetHoveredIndexPath); + const result = swapHeaders(liveHeaders, draggedHeaderIndexPath, targetHoveredIndexPath); newHeaders = result.newHeaders; emergencyBreak = result.emergencyBreak; } @@ -226,7 +232,7 @@ export const attachDragHandlers = ( if ( header.accessor === draggedHeader.accessor || distance < 10 || - JSON.stringify(newHeaders) === JSON.stringify(headers) || + JSON.stringify(newHeaders) === JSON.stringify(liveHeaders) || emergencyBreak ) { return; @@ -255,7 +261,7 @@ export const attachDragHandlers = ( setPrevUpdateTime(now); setPrevDraggingPosition({ screenX, screenY }); - setPrevHeaders(headers); + setPrevHeaders(liveHeaders); context.onTableHeaderDragEnd(newHeaders); }, DRAG_THROTTLE_LIMIT); diff --git a/packages/core/src/utils/headerCell/styling.ts b/packages/core/src/utils/headerCell/styling.ts index 33eb5e565..f42fbd086 100644 --- a/packages/core/src/utils/headerCell/styling.ts +++ b/packages/core/src/utils/headerCell/styling.ts @@ -263,29 +263,12 @@ export const getLastHeaderIndex = (absoluteCells: AbsoluteCell[]): number => { return Math.max(...absoluteCells.map((c) => c.colIndex)); }; -// Update an existing header cell element with current state -export const updateHeaderCellElement = ( +/** Replace sort/filter/collapse icons on an existing header cell, preserving label/drag handlers. */ +export const refreshHeaderCellIcons = ( cellElement: HTMLElement, - cell: AbsoluteCell, + header: AbsoluteCell["header"], context: HeaderRenderContext, - isLastMainAutoExpandColumn: boolean, ): void => { - const { header } = cell; - - // Update classes to reflect current state - cellElement.className = calculateHeaderCellClasses( - cell, - context, - isLastMainAutoExpandColumn, - ); - - // Update position (may have changed due to column resize or scroll) - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; - cellElement.style.width = `${cell.width}px`; - cellElement.style.height = `${cell.height}px`; - - // Update icons (sort/filter/collapse) - remove old ones and create new ones const oldSortIcon = cellElement.querySelector('.st-icon-container[aria-label*="Sort"]'); const oldFilterIcon = cellElement.querySelector('.st-icon-container[aria-label*="Filter"]'); const oldCollapseIcon = cellElement.querySelector(".st-expand-icon-container"); @@ -294,12 +277,10 @@ export const updateHeaderCellElement = ( oldFilterIcon?.remove(); oldCollapseIcon?.remove(); - // Recreate icons with current state const sortIcon = createSortIcon(header, context); const filterIcon = createFilterIcon(header, context); const collapseIcon = createCollapseIcon(header, context); - // Insert icons in the correct position based on alignment if (!header.headerRenderer && header.align === "right") { if (collapseIcon) cellElement.insertBefore(collapseIcon, cellElement.firstChild); if (filterIcon) cellElement.insertBefore(filterIcon, cellElement.firstChild); @@ -329,3 +310,26 @@ export const updateHeaderCellElement = ( } } }; + +// Update an existing header cell element with current state +export const updateHeaderCellElement = ( + cellElement: HTMLElement, + cell: AbsoluteCell, + context: HeaderRenderContext, + isLastMainAutoExpandColumn: boolean, +): void => { + const { header } = cell; + + cellElement.className = calculateHeaderCellClasses( + cell, + context, + isLastMainAutoExpandColumn, + ); + + cellElement.style.left = `${cell.left}px`; + cellElement.style.top = `${cell.top}px`; + cellElement.style.width = `${cell.width}px`; + cellElement.style.height = `${cell.height}px`; + + refreshHeaderCellIcons(cellElement, header, context); +}; diff --git a/packages/core/src/utils/headerCellRenderer.ts b/packages/core/src/utils/headerCellRenderer.ts index 66ec2519e..206141f1f 100644 --- a/packages/core/src/utils/headerCellRenderer.ts +++ b/packages/core/src/utils/headerCellRenderer.ts @@ -12,6 +12,7 @@ import { createHeaderCellElement, calculateHeaderCellClasses, getLastHeaderIndex, + refreshHeaderCellIcons, } from "./headerCell/styling"; import { updateHeaderSelectionCheckbox } from "./headerCell/selection"; import { updateHeaderCollapseIconState } from "./headerCell/collapsing"; @@ -151,6 +152,22 @@ export const renderHeaderCells = ( const isCollapsed = context.collapsedHeaders.has(cell.header.accessor); updateHeaderCollapseIconState(cellElement, isCollapsed, cell.header.label); } + + // Refresh sort/filter icons in place when this column's sort or filter state changed + // (cells are no longer torn down on header/context invalidation, so we must replace + // icons here when context state advances). Tracked per-cell on dataset to avoid + // unnecessary DOM churn on scroll/reorder where sort/filter haven't changed. + const sortStateForCell = + context.sort && context.sort.key.accessor === cell.header.accessor + ? context.sort.direction + : "none"; + const filterStateForCell = + context.filters && context.filters[cell.header.accessor as any] ? "1" : "0"; + const iconStateKey = `${sortStateForCell}|${filterStateForCell}`; + if (cellElement.dataset.stIconState !== iconStateKey) { + refreshHeaderCellIcons(cellElement, cell.header, context); + cellElement.dataset.stIconState = iconStateKey; + } } }); @@ -161,6 +178,15 @@ export const renderHeaderCells = ( context, isLastMainAutoExpandColumn, ); + // Seed icon-state dataset so the existing-cell branch doesn't refresh icons + // unnecessarily on the next render (icons are already current on freshly created cells). + const sortStateForCell = + context.sort && context.sort.key.accessor === cell.header.accessor + ? context.sort.direction + : "none"; + const filterStateForCell = + context.filters && context.filters[cell.header.accessor as any] ? "1" : "0"; + cellElement.dataset.stIconState = `${sortStateForCell}|${filterStateForCell}`; fragment.appendChild(cellElement); renderedCells.set(cellId, cellElement); positionCache.set(cellId, { From 015dfd81279a784a6d755c4a6fb5da26dd47566f Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:27:00 -0500 Subject: [PATCH 10/20] useOddEvenRowBackground fix + animation improvements --- .../src/core/rendering/SectionRenderer.ts | 20 ++- .../core/src/managers/AnimationCoordinator.ts | 126 +++++++++------- packages/core/src/styles/themes/frost.css | 5 +- .../core/src/styles/themes/modern-dark.css | 5 +- .../core/src/styles/themes/modern-light.css | 7 +- .../core/src/styles/themes/theme-custom.css | 7 +- .../core/stories/examples/BasicExample.ts | 1 + .../stories/tests/32-ThemesTests.stories.ts | 134 +++++++++++++++-- ...llAnimationsVirtualizationTests.stories.ts | 136 +++++++++++++++--- 9 files changed, 343 insertions(+), 98 deletions(-) diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index 8750ec407..1b176ce23 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -714,6 +714,12 @@ export class SectionRenderer { }) : rowIndex * rowHeight; + // Derive odd/even from the row's absolute table position rather than + // its index in the rendered (virtualized) slice. The slice index changes + // every time the user scrolls, which would otherwise flip a row's + // odd/even class as soon as it's reused for a different visible row. + const isOdd = tableRow.position % 2 === 1; + leafHeaders.forEach((header, leafIndex) => { const position = headerPositions.get(header.accessor); const colIndex = startColIndex + leafIndex; @@ -726,7 +732,7 @@ export class SectionRenderer { stableRowKey: tableRow.stableRowKey, displayRowNumber: tableRow.displayPosition, depth: tableRow.depth, - isOdd: rowIndex % 2 === 1, + isOdd, tableRow, left: position?.left ?? 0, top: topPosition, @@ -932,8 +938,10 @@ export class SectionRenderer { for (const c of cached.cells) { const ri = positionToVisualIndex.get(c.tableRow.position); if (ri === undefined) continue; - if (c.rowIndex !== ri || c.isOdd !== (ri % 2 === 1)) { - out.push({ ...c, rowIndex: ri, isOdd: ri % 2 === 1 }); + // isOdd is derived from c.tableRow.position upstream and is stable + // across viewport scrolls — only the visual rowIndex needs remapping. + if (c.rowIndex !== ri) { + out.push({ ...c, rowIndex: ri }); } else { out.push(c); } @@ -991,8 +999,10 @@ export class SectionRenderer { for (const c of cells) { const ri = positionToVisualIndex.get(c.tableRow.position); if (ri === undefined) continue; - if (c.rowIndex !== ri || c.isOdd !== (ri % 2 === 1)) { - mapped.push({ ...c, rowIndex: ri, isOdd: ri % 2 === 1 }); + // isOdd is derived from c.tableRow.position upstream and is stable + // across viewport scrolls — only the visual rowIndex needs remapping. + if (c.rowIndex !== ri) { + mapped.push({ ...c, rowIndex: ri }); } else { mapped.push(c); } diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index d9ae6a098..7729cf874 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -198,16 +198,21 @@ export class AnimationCoordinator { }): void { const { cellId, element, container, newPosition } = args; const oldTop = parsePx(element.style.top); - - // Scale the visual destination so the slide journey is bounded but - // proportional to the true conceptual journey. Without scaling, a row - // sorted from position 0 to position 499 of a virtualized 500-row table - // would try to slide ~16k pixels in the animation window — under - // ease-out it crosses the 500px viewport in the first ~30ms and the - // cell appears to teleport. The scaling also gives cells with very - // different conceptual destinations visibly different slide distances, - // so they fan out instead of marching off-screen in lockstep. - const clippedTop = scaleFlipDistance(newPosition.top, oldTop, newPosition.height, container); + const oldLeft = parsePx(element.style.left); + + // Scale the visual destination on each axis so the slide journey is + // bounded but proportional to the true conceptual journey. Without + // scaling, a row sorted from position 0 to position 499 of a virtualized + // 500-row table would try to slide ~16k pixels vertically in the + // animation window — under ease-out it crosses the 500px viewport in the + // first ~30ms and the cell appears to teleport. The same problem exists + // horizontally: a column moved across a virtualized 30-column table can + // need to slide ~6k pixels and would look identically broken. The + // scaling also gives cells with very different conceptual destinations + // visibly different slide distances, so they fan out instead of marching + // off-screen in lockstep. + const clippedTop = scaleFlipDistance(newPosition.top, oldTop, newPosition.height, container, "y"); + const clippedLeft = scaleFlipDistance(newPosition.left, oldLeft, newPosition.width, container, "x"); let map = this.retainedCells.get(container); if (!map) { @@ -230,7 +235,7 @@ export class AnimationCoordinator { element.classList.add(RETAINED_CLASS); element.setAttribute(RETAINED_ATTR, "true"); - element.style.left = `${newPosition.left}px`; + element.style.left = `${clippedLeft}px`; element.style.top = `${clippedTop}px`; element.style.width = `${newPosition.width}px`; element.style.height = `${newPosition.height}px`; @@ -308,17 +313,22 @@ export class AnimationCoordinator { // For incoming cells (newly created live cells), scale the FLIP // "before" position so cells sliding in from far off-screen take a - // bounded but proportional journey. Without scaling, a row whose - // pre-sort conceptual top was 14970 sliding to currentTop=0 would - // start ~15k pixels below the viewport — with ease-out it stays + // bounded but proportional journey on each axis. Without scaling, a + // row whose pre-sort conceptual top was 14970 sliding to currentTop=0 + // would start ~15k pixels below the viewport — with ease-out it stays // off-screen for most of the animation, leaving the viewport empty - // until the last few percent. Retained cells already had their - // style.top scaled at retainCell time, so we don't re-scale here. + // until the last few percent. The horizontal axis has the same + // failure mode for far-column reorders. Retained cells already had + // their style.top/left scaled at retainCell time, so we don't re-scale + // here. const beforeTopClipped = isRetained ? before.top - : scaleFlipDistance(before.top, currentTop, element.offsetHeight || 0, container); + : scaleFlipDistance(before.top, currentTop, element.offsetHeight || 0, container, "y"); + const beforeLeftClipped = isRetained + ? before.left + : scaleFlipDistance(before.left, currentLeft, element.offsetWidth || 0, container, "x"); - const dx = before.left - currentLeft; + const dx = beforeLeftClipped - currentLeft; const dy = beforeTopClipped - currentTop; if (Math.abs(dx) < MIN_DELTA && Math.abs(dy) < MIN_DELTA) { // No visual movement — if this was a retained cell with no movement @@ -511,17 +521,21 @@ const parsePx = (value: string): number => { */ const OFFSCREEN_COMPRESSION_FACTOR = 10; +type FlipAxis = "x" | "y"; + /** - * Scale a FLIP journey so the visible slide is bounded but its length is - * proportional to the cell's true conceptual journey, preserving the sign - * and a clear sense of "this cell is going further than that one". + * Scale a FLIP journey along a given axis so the visible slide is bounded + * but its length is proportional to the cell's true conceptual journey, + * preserving the sign and a clear sense of "this cell is going further than + * that one". * - * Returns the new `top` to assign to the FLIP endpoint (the outgoing ghost's - * `style.top`, or the snapshot `before.top` for an incoming cell). + * Returns the new coordinate to assign to the FLIP endpoint (the outgoing + * ghost's `style.top` / `style.left`, or the snapshot `before.top` / + * `before.left` for an incoming cell). * * The journey is split into two regimes: * - * 1. **In-viewport range** (|delta| ≤ viewportHeight + cellHeight): + * 1. **In-viewport range** (|delta| ≤ viewportSize + cellSize): * The cell is sliding to/from a position inside or just past the visible * band, so we use the true delta untouched. Small reorders, partial-move * sorts and persistent in-viewport cells are unaffected. @@ -538,57 +552,67 @@ const OFFSCREEN_COMPRESSION_FACTOR = 10; * which is 0 when `extra = 0`, approaches `maxOvershoot` as `extra → ∞`, and * has no discontinuity at the boundary. * - * No-op when the section's parent does not scroll vertically (small datasets, - * header sections). + * No-op when there's no scrolling along the requested axis (small datasets, + * pinned panes, or header sections in the vertical case). + * + * Vertical and horizontal use different scrollers because the table's layout + * splits scrolling responsibilities: the body section element (`.st-body-main` + * and pinned variants) is the *horizontal* scroller, while its parent + * (`.st-body-container`) is the *vertical* scroller. Header sections only + * scroll horizontally. */ const scaleFlipDistance = ( - distantTop: number, - anchorTop: number, - cellHeight: number, + distantPos: number, + anchorPos: number, + cellSize: number, container: HTMLElement, + axis: FlipAxis, ): number => { - const scroller = container.parentElement; - if (!scroller) return distantTop; - const clientHeight = scroller.clientHeight; - if (clientHeight <= 0 || scroller.scrollHeight <= clientHeight) return distantTop; + const scroller: HTMLElement | null = + axis === "y" ? container.parentElement : container; + if (!scroller) return distantPos; + + const clientSize = axis === "y" ? scroller.clientHeight : scroller.clientWidth; + const scrollSize = axis === "y" ? scroller.scrollHeight : scroller.scrollWidth; + if (clientSize <= 0 || scrollSize <= clientSize) return distantPos; - const delta = distantTop - anchorTop; + const delta = distantPos - anchorPos; const absDelta = Math.abs(delta); - if (absDelta === 0) return distantTop; + if (absDelta === 0) return distantPos; - const cellBuffer = cellHeight > 0 ? cellHeight : 0; + const cellBuffer = cellSize > 0 ? cellSize : 0; - // If `distantTop` is itself inside the visible viewport, it's a real visible + // If `distantPos` is itself inside the visible viewport, it's a real visible // position (a surviving cell's actual previous spot, or a real new spot we // want a retained ghost to slide into) — not a far-off conceptual one. // Compressing it would pull the cell AWAY from the viewport edge and hide // the only on-screen portion of the journey. Pass it through unchanged. - // (Without this guard, a cell sliding from a visible row to an off-screen - // row "disappears" mid-animation: |delta| exceeds visibleRange, so the - // compression below pulls the visible end-point past the body's overflow - // clip and the cell is never painted.) - const scrollTop = scroller.scrollTop; + // (Without this guard, a cell sliding from a visible position to an + // off-screen position "disappears" mid-animation: |delta| exceeds + // visibleRange, so the compression below pulls the visible end-point past + // the section's overflow clip and the cell is never painted.) + const scrollOffset = axis === "y" ? scroller.scrollTop : scroller.scrollLeft; if ( - distantTop >= scrollTop - cellBuffer && - distantTop <= scrollTop + clientHeight + distantPos >= scrollOffset - cellBuffer && + distantPos <= scrollOffset + clientSize ) { - return distantTop; + return distantPos; } // Threshold below which we pass the journey through unchanged. Cells whose // true delta fits within the visible band + one cell of overshoot are // already on-screen and don't need scaling. - const visibleRange = clientHeight + cellBuffer; - if (absDelta <= visibleRange) return distantTop; + const visibleRange = clientSize + cellBuffer; + if (absDelta <= visibleRange) return distantPos; // Off-screen extra distance, smoothly compressed and asymptotic to - // `maxOvershoot`. With maxOvershoot = clientHeight, the longest possible - // visible slide is ~2× viewport height (visibleRange + maxOvershoot). - const maxOvershoot = clientHeight; + // `maxOvershoot`. With maxOvershoot = clientSize, the longest possible + // visible slide is ~2× viewport size (visibleRange + maxOvershoot). + const maxOvershoot = clientSize; const extra = absDelta - visibleRange; const compressed = (maxOvershoot * extra) / (extra + visibleRange * OFFSCREEN_COMPRESSION_FACTOR); const scaledMagnitude = visibleRange + compressed; - return anchorTop + Math.sign(delta) * scaledMagnitude; + return anchorPos + Math.sign(delta) * scaledMagnitude; }; const readPrefersReducedMotion = (): boolean => { diff --git a/packages/core/src/styles/themes/frost.css b/packages/core/src/styles/themes/frost.css index fa4c48e20..7ec9eb923 100644 --- a/packages/core/src/styles/themes/frost.css +++ b/packages/core/src/styles/themes/frost.css @@ -19,9 +19,10 @@ --st-footer-background-color: #f8faff; --st-last-group-row-separator-border-color: #c5d1dd; - /* Row colors */ + /* Row colors - Crisp white with a faint icy band on even rows when the + * useOddEvenRowBackground option is enabled. */ --st-odd-row-background-color: #ffffff; - --st-even-row-background-color: #ffffff; + --st-even-row-background-color: #f4f8fc; --st-hover-row-background-color: #e5effe; --st-selected-row-background-color: #d4e4fd; diff --git a/packages/core/src/styles/themes/modern-dark.css b/packages/core/src/styles/themes/modern-dark.css index a296f7823..a98f53a49 100644 --- a/packages/core/src/styles/themes/modern-dark.css +++ b/packages/core/src/styles/themes/modern-dark.css @@ -31,9 +31,10 @@ --st-footer-background-color: #1f2937; --st-last-group-row-separator-border-color: #4b5563; - /* Row colors - Dark background, subtle hover */ + /* Row colors - Dark background; when useOddEvenRowBackground is on, even + * rows lift slightly to keep the table readable on a dark canvas. */ --st-odd-row-background-color: #1f2937; - --st-even-row-background-color: #1f2937; + --st-even-row-background-color: #232f3f; --st-hover-row-background-color: #374151; --st-selected-row-background-color: #1e3a5f; diff --git a/packages/core/src/styles/themes/modern-light.css b/packages/core/src/styles/themes/modern-light.css index 08b1a218a..5dc51bccc 100644 --- a/packages/core/src/styles/themes/modern-light.css +++ b/packages/core/src/styles/themes/modern-light.css @@ -31,10 +31,11 @@ --st-footer-background-color: var(--st-white); --st-last-group-row-separator-border-color: #e5e7eb; - /* Row colors - Clean white background, subtle hover */ + /* Row colors - Clean white background; when useOddEvenRowBackground is on, + * even rows pick up a very subtle off-white band for readable striping. */ --st-odd-row-background-color: var(--st-white); - --st-even-row-background-color: var(--st-white); - --st-hover-row-background-color: #f9fafb; + --st-even-row-background-color: #fafafa; + --st-hover-row-background-color: #f3f4f6; --st-selected-row-background-color: #eff6ff; /* Column colors - No alternating columns by default */ diff --git a/packages/core/src/styles/themes/theme-custom.css b/packages/core/src/styles/themes/theme-custom.css index 651f1bf1f..39c6c276c 100644 --- a/packages/core/src/styles/themes/theme-custom.css +++ b/packages/core/src/styles/themes/theme-custom.css @@ -21,10 +21,11 @@ --st-footer-background-color: var(--st-white); --st-last-group-row-separator-border-color: #e5e7eb; - /* Row colors - Clean white background, subtle hover */ + /* Row colors - Clean white background; when useOddEvenRowBackground is on, + * even rows pick up a very subtle off-white band for readable striping. */ --st-odd-row-background-color: var(--st-white); - --st-even-row-background-color: var(--st-white); - --st-hover-row-background-color: #f9fafb; + --st-even-row-background-color: #fafafa; + --st-hover-row-background-color: #f3f4f6; --st-selected-row-background-color: #eff6ff; /* Column colors - No alternating columns by default */ diff --git a/packages/core/stories/examples/BasicExample.ts b/packages/core/stories/examples/BasicExample.ts index 179428677..f4d21eddb 100644 --- a/packages/core/stories/examples/BasicExample.ts +++ b/packages/core/stories/examples/BasicExample.ts @@ -14,6 +14,7 @@ export const basicExampleDefaults = { selectableCells: true, columnReordering: true, height: "500px", + useOddEvenRowBackground: true, }; export function createBasicData(rowLength: number): Row[] { diff --git a/packages/core/stories/tests/32-ThemesTests.stories.ts b/packages/core/stories/tests/32-ThemesTests.stories.ts index da9c99bc9..a1d627114 100644 --- a/packages/core/stories/tests/32-ThemesTests.stories.ts +++ b/packages/core/stories/tests/32-ThemesTests.stories.ts @@ -32,6 +32,16 @@ const data = () => [ { id: 2, name: "Bob" }, ]; +// Larger dataset so we can verify alternating classes across multiple rows +const stripedData = () => [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + { id: 3, name: "Carol" }, + { id: 4, name: "Dave" }, + { id: 5, name: "Eve" }, + { id: 6, name: "Frank" }, +]; + export const ThemeLight = { render: () => { const { wrapper } = renderVanillaTable(headers, data(), { @@ -178,10 +188,11 @@ export const UseHoverRowBackground = { }; export const UseOddEvenRowBackground = { + tags: ["odd-even-row-background"], render: () => { - const { wrapper } = renderVanillaTable(headers, data(), { + const { wrapper } = renderVanillaTable(headers, stripedData(), { getRowId: (p) => String((p.row as { id?: number })?.id), - height: "250px", + height: "400px", useOddEvenRowBackground: true, }); return wrapper; @@ -190,13 +201,118 @@ export const UseOddEvenRowBackground = { await waitForTable(); const root = canvasElement.querySelector(".simple-table-root") as HTMLElement | null; expect(root).toBeTruthy(); - const hasOddEvenClass = - root!.classList.contains("odd-even-row-background") || - root!.classList.contains("use-odd-even-rows") || - root!.getAttribute("data-odd-even") === "true" || - root!.className.includes("odd-even") || - root!.className.includes("striped"); - expect(hasOddEvenClass || root !== null).toBe(true); + + // The feature applies "st-cell-odd-row" / "st-cell-even-row" classes to + // every body cell. Sanity-check that each rendered row has the expected + // class and that adjacent rows alternate. + const allBodyCells = canvasElement.querySelectorAll( + ".st-body-container .st-cell[data-row-index]", + ); + expect(allBodyCells.length).toBeGreaterThan(0); + + // Group cells by their row index so we can verify per-row class consistency. + const cellsByRow = new Map(); + allBodyCells.forEach((cell) => { + const idx = Number(cell.getAttribute("data-row-index")); + if (!cellsByRow.has(idx)) cellsByRow.set(idx, []); + cellsByRow.get(idx)!.push(cell); + }); + + const sortedRowIndices = Array.from(cellsByRow.keys()).sort((a, b) => a - b); + expect(sortedRowIndices.length).toBeGreaterThanOrEqual(4); + + let oddRowCount = 0; + let evenRowCount = 0; + + sortedRowIndices.forEach((rowIndex) => { + const cells = cellsByRow.get(rowIndex)!; + // 0-based: rowIndex 0 is visually the 1st row → "odd" (1-based); + // rowIndex 1 is visually the 2nd row → "even" (1-based). + const expectedClass = + rowIndex % 2 === 0 ? "st-cell-odd-row" : "st-cell-even-row"; + const forbiddenClass = + rowIndex % 2 === 0 ? "st-cell-even-row" : "st-cell-odd-row"; + + cells.forEach((cell) => { + expect(cell.classList.contains(expectedClass)).toBe(true); + expect(cell.classList.contains(forbiddenClass)).toBe(false); + }); + + if (expectedClass === "st-cell-odd-row") oddRowCount++; + else evenRowCount++; + }); + + // Both classes must actually appear in the rendered output, otherwise + // the alternating background effect cannot occur. + expect(oddRowCount).toBeGreaterThan(0); + expect(evenRowCount).toBeGreaterThan(0); + }, +}; + +export const UseOddEvenRowBackgroundDisabled = { + tags: ["odd-even-row-background"], + render: () => { + const { wrapper } = renderVanillaTable(headers, stripedData(), { + getRowId: (p) => String((p.row as { id?: number })?.id), + height: "400px", + useOddEvenRowBackground: false, + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + // When the flag is off, no body cell should carry the alternating-row classes. + const oddCells = canvasElement.querySelectorAll( + ".st-body-container .st-cell.st-cell-odd-row", + ); + const evenCells = canvasElement.querySelectorAll( + ".st-body-container .st-cell.st-cell-even-row", + ); + expect(oddCells.length).toBe(0); + expect(evenCells.length).toBe(0); + }, +}; + +// Visual-effect test: with a theme that defines distinct odd/even colors +// (e.g. "light"), enabling useOddEvenRowBackground must actually produce +// different computed background colors between adjacent rows. This guards +// against regressions where the class is applied but the styling does not +// resolve (e.g. due to broken selectors or specificity issues). +export const UseOddEvenRowBackgroundVisualEffect = { + tags: ["odd-even-row-background"], + render: () => { + const { wrapper } = renderVanillaTable(headers, stripedData(), { + getRowId: (p) => String((p.row as { id?: number })?.id), + height: "400px", + theme: "light", + useOddEvenRowBackground: true, + }); + return wrapper; + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(); + + const cellRow0 = canvasElement.querySelector( + '.st-body-container .st-cell[data-row-index="0"]', + ); + const cellRow1 = canvasElement.querySelector( + '.st-body-container .st-cell[data-row-index="1"]', + ); + expect(cellRow0).toBeTruthy(); + expect(cellRow1).toBeTruthy(); + + expect(cellRow0!.classList.contains("st-cell-odd-row")).toBe(true); + expect(cellRow1!.classList.contains("st-cell-even-row")).toBe(true); + + const bg0 = window.getComputedStyle(cellRow0!).backgroundColor; + const bg1 = window.getComputedStyle(cellRow1!).backgroundColor; + // Both must resolve to a real, visible color (not "transparent" / empty). + expect(bg0).toBeTruthy(); + expect(bg1).toBeTruthy(); + expect(bg0).not.toBe("rgba(0, 0, 0, 0)"); + expect(bg1).not.toBe("rgba(0, 0, 0, 0)"); + // And the two row colors must actually differ — that's the whole point. + expect(bg0).not.toBe(bg1); }, }; diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index 31dbd57bf..d254cfdc7 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -93,7 +93,7 @@ const COLUMN_WIDTH = 220; const VIEWPORT_WIDTH = 800; const VIEWPORT_HEIGHT = 500; /** Slow on purpose so each animation is easy to follow when watching. */ -const SLOW_DURATION = 1500; +const SLOW_DURATION = 15000; /** A pause that comfortably outlasts SLOW_DURATION so the animation finishes. */ const SETTLE_PAUSE = SLOW_DURATION + 400; /** A short pause to let the user appreciate a new state before the next step. */ @@ -695,11 +695,22 @@ export const ReorderAtMultipleScrollPositions = { }; /** - * Strict per-cell FLIP correctness check at scale: when reversing 30 columns, - * every sampled cell's transform on the synchronously-set "First" frame must - * exactly equal `oldLeft - newLeft`. Catches regressions where the snapshot - * is captured against the post-mutation layout, where preLayouts overwrites - * live DOM positions, or where some cells get skipped from the FLIP pass. + * Per-cell FLIP correctness check at scale. + * + * When reversing 30 columns: + * - Cells whose pre-reverse position is currently on-screen (or whose true + * journey fits within ~one viewport) must FLIP exactly to that position + * (`txX === oldLeft - newLeft` to within sub-pixel rounding). + * - Cells whose pre-reverse position is far off-screen are scaled by + * `AnimationCoordinator.scaleFlipDistance` so the visible slide stays + * bounded. For those, we relax the strict equality to: same sign as the + * true journey, magnitude < the true journey, and magnitude inside the + * `[viewport, ~2 × viewport]` band the scaler produces. + * + * Catches regressions where the snapshot is captured against the post- + * mutation layout, where preLayouts overwrites live DOM positions, where + * some cells get skipped from the FLIP pass, or where horizontal scaling + * collapses the journey too aggressively (e.g. dropping it to 0). */ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { render: () => { @@ -711,8 +722,8 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { addParagraph( result.wrapper, "Reverses the columns, then synchronously reads each sampled cell's transform " + - "and asserts it equals (oldLeft - newLeft). Cells that move in different " + - "directions must produce transforms with opposite signs.", + "and asserts on-screen sources FLIP exactly while far off-screen sources " + + "are scaled to a bounded but sign-correct slide.", ); return result.wrapper; }, @@ -735,11 +746,19 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { ]; const ROW_INDEX = 0; - const beforeLefts = new Map(); + const mainBody = canvasElement.querySelector(".st-body-main"); + expect(mainBody, "Need .st-body-main for viewport bounds").toBeTruthy(); + const scrollLeftPre = mainBody!.scrollLeft; + const clientWidth = mainBody!.clientWidth; + + const beforeLefts = new Map(); for (const accessor of sampledAccessors) { const cell = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, accessor); if (!cell) continue; - beforeLefts.set(accessor, parseFloat(cell.style.left || "0")); + beforeLefts.set(accessor, { + left: parseFloat(cell.style.left || "0"), + width: cell.offsetWidth || parseFloat(cell.style.width || "0"), + }); } if (beforeLefts.size < 2) { throw new Error( @@ -757,21 +776,29 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { accessor: string; oldLeft: number; newLeft: number; + cellWidth: number; txX: number; direction: "right" | "left" | "still"; }> = []; for (const accessor of sampledAccessors) { - if (!beforeLefts.has(accessor)) continue; + const before = beforeLefts.get(accessor); + if (!before) continue; const cell = findCellByRowIndexAndAccessor(canvasElement, ROW_INDEX, accessor); if (!cell) continue; - const oldLeft = beforeLefts.get(accessor)!; const newLeft = parseFloat(cell.style.left || "0"); const txX = parseTranslateX(cell.style.transform); let direction: "right" | "left" | "still" = "still"; - if (newLeft - oldLeft > 0.5) direction = "right"; - else if (newLeft - oldLeft < -0.5) direction = "left"; - samples.push({ accessor, oldLeft, newLeft, txX, direction }); + if (newLeft - before.left > 0.5) direction = "right"; + else if (newLeft - before.left < -0.5) direction = "left"; + samples.push({ + accessor, + oldLeft: before.left, + newLeft, + cellWidth: before.width, + txX, + direction, + }); } const summary = samples @@ -782,11 +809,63 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { ) .join(" | "); + // Mirror the predicate in `scaleFlipDistance`: a cell is scaled iff its + // pre-reverse position is OUTSIDE the live viewport AND the raw journey + // exceeds the viewport+cell band. Otherwise the scaler passes through + // and the FLIP must equal the true journey exactly. + const isScaled = (s: (typeof samples)[number]): boolean => { + const buffer = s.cellWidth > 0 ? s.cellWidth : 0; + const inViewport = + s.oldLeft >= scrollLeftPre - buffer && s.oldLeft <= scrollLeftPre + clientWidth; + if (inViewport) return false; + const visibleRange = clientWidth + buffer; + return Math.abs(s.oldLeft - s.newLeft) > visibleRange; + }; + for (const s of samples) { const expected = s.oldLeft - s.newLeft; - if (Math.abs(s.txX - expected) >= 1.5) { + if (!isScaled(s)) { + if (Math.abs(s.txX - expected) >= 1.5) { + throw new Error( + `FLIP dx mismatch for "${s.accessor}" (expected ${expected}, got ${s.txX}). ${summary}`, + ); + } + continue; + } + + // Scaled cells: the visible slide is bounded by the scaler. Magnitude + // must be (a) sign-correct, (b) at least one viewport (since the + // scaler floor is `visibleRange` before the asymptotic overshoot is + // added), (c) strictly less than the unscaled journey, and (d) + // bounded above by `visibleRange + maxOvershoot ≈ 2× clientWidth` + // plus a small slack for cell-buffer math. + const expectedSign = Math.sign(expected); + const actualSign = Math.sign(s.txX); + if (expectedSign !== 0 && actualSign !== expectedSign) { + throw new Error( + `FLIP dx sign wrong for scaled "${s.accessor}" (expected sign ${expectedSign}, got ${actualSign}). ${summary}`, + ); + } + const absTx = Math.abs(s.txX); + const absExpected = Math.abs(expected); + const visibleRange = clientWidth + s.cellWidth; + if (absTx >= absExpected) { + throw new Error( + `Scaled FLIP dx for "${s.accessor}" should be smaller than the unscaled journey ` + + `(|tx|=${absTx} vs |expected|=${absExpected}). ${summary}`, + ); + } + if (absTx < visibleRange - 1) { + throw new Error( + `Scaled FLIP dx for "${s.accessor}" should be at least one viewport (~${visibleRange}px), ` + + `got |tx|=${absTx}. ${summary}`, + ); + } + const maxAllowed = clientWidth * 2 + s.cellWidth + 50; + if (absTx > maxAllowed) { throw new Error( - `FLIP dx mismatch for "${s.accessor}" (expected ${expected}, got ${s.txX}). ${summary}`, + `Scaled FLIP dx for "${s.accessor}" exceeds the bounded slide window ` + + `(|tx|=${absTx} > maxAllowed=${maxAllowed}). ${summary}`, ); } } @@ -816,6 +895,18 @@ export const ReorderAtScaleAnimatesFromPreviousPositionPerCell = { } } + // The whole point of horizontal scaling is to bound far-column journeys. + // Verify at least one of the sampled cells actually got scaled — without + // this the test would silently regress to "pure FLIP" if scaling is + // disabled or the predicate breaks. + const scaledCount = samples.filter(isScaled).length; + if (scaledCount === 0) { + throw new Error( + `Expected at least one sampled cell to be scaled (oldLeft outside viewport ` + + `with |dx| > viewport+cellWidth). samples: ${summary}`, + ); + } + await sleep(SETTLE_PAUSE); announce(status, "Done."); expect(countGhosts(canvasElement)).toBe(0); @@ -1356,12 +1447,11 @@ export const OverlappingSortsRetainAndReaimGhosts = { const allCells = Array.from( canvasElement.querySelectorAll(`.st-body-main .st-cell`), ); - const stuckOverlap = allCells.filter( - (c) => c.style.transform && c.style.transform !== "none", - ); - expect(stuckOverlap.length, "cells with leftover transform after overlapping sorts settle").toBe( - 0, - ); + const stuckOverlap = allCells.filter((c) => c.style.transform && c.style.transform !== "none"); + expect( + stuckOverlap.length, + "cells with leftover transform after overlapping sorts settle", + ).toBe(0); announce(status, "Done."); }, From 129c18cc1a95ce38bc127d9abafd3b9b5edcd995 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:30:46 -0500 Subject: [PATCH 11/20] Duration fix --- .../tests/42-CellAnimationsVirtualizationTests.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index d254cfdc7..05b5779ad 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -93,7 +93,7 @@ const COLUMN_WIDTH = 220; const VIEWPORT_WIDTH = 800; const VIEWPORT_HEIGHT = 500; /** Slow on purpose so each animation is easy to follow when watching. */ -const SLOW_DURATION = 15000; +const SLOW_DURATION = 1500; /** A pause that comfortably outlasts SLOW_DURATION so the animation finishes. */ const SETTLE_PAUSE = SLOW_DURATION + 400; /** A short pause to let the user appreciate a new state before the next step. */ From 51c9179e914f692842f26c77c701ffeab118efec Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:37:31 -0500 Subject: [PATCH 12/20] Animations rename --- packages/core/src/core/SimpleTableVanilla.ts | 26 +++++++--------- packages/core/src/index.ts | 2 ++ packages/core/src/types/AnimationsConfig.ts | 14 +++++++++ packages/core/src/types/SimpleTableConfig.ts | 5 ++- packages/core/src/types/SimpleTableProps.ts | 5 ++- .../examples/sales-example/SalesExample.ts | 2 +- .../tests/41-CellAnimationsTests.stories.ts | 31 +++++++------------ ...llAnimationsVirtualizationTests.stories.ts | 9 +++--- 8 files changed, 48 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/types/AnimationsConfig.ts diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index df3b7cb9c..0e4756624 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -121,18 +121,22 @@ export class SimpleTableVanilla { this.renderOrchestrator = new RenderOrchestrator(); this.animationCoordinator = new AnimationCoordinator(); - this.animationCoordinator.setEnabled(config.animations ?? true); - if (config.animationDuration !== undefined) { - this.animationCoordinator.setDuration(config.animationDuration); - } - if (config.animationEasing !== undefined) { - this.animationCoordinator.setEasing(config.animationEasing); - } + this.applyAnimationsConfig(config.animations); this.rebuildRowIndexMap(); this.initializeManagers(); } + private applyAnimationsConfig(animations: SimpleTableConfig["animations"]): void { + this.animationCoordinator.setEnabled(animations?.enabled ?? true); + if (animations?.duration !== undefined) { + this.animationCoordinator.setDuration(animations.duration); + } + if (animations?.easing !== undefined) { + this.animationCoordinator.setEasing(animations.easing); + } + } + private rebuildRowIndexMap(): void { this.rowIndexMap.clear(); this.localRows.forEach((row, index) => { @@ -668,13 +672,7 @@ export class SimpleTableVanilla { this.config = { ...this.config, ...config }; if (config.animations !== undefined) { - this.animationCoordinator.setEnabled(config.animations); - } - if (config.animationDuration !== undefined) { - this.animationCoordinator.setDuration(config.animationDuration); - } - if (config.animationEasing !== undefined) { - this.animationCoordinator.setEasing(config.animationEasing); + this.applyAnimationsConfig(config.animations); } if (config.rows !== undefined) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9ed0d89e2..af8a972ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -73,6 +73,7 @@ import type { IconsConfig } from "./types/IconsConfig"; import type { GetRowId, GetRowIdParams } from "./types/GetRowId"; import type { SimpleTableConfig } from "./types/SimpleTableConfig"; import type { SimpleTableProps } from "./types/SimpleTableProps"; +import type { AnimationsConfig } from "./types/AnimationsConfig"; import type { RowId } from "./types/RowId"; import type { PinnedSectionsState } from "./types/PinnedSectionsState"; @@ -83,6 +84,7 @@ export type { Accessor, AggregationConfig, AggregationType, + AnimationsConfig, BoundingBox, Cell, CellChangeProps, diff --git a/packages/core/src/types/AnimationsConfig.ts b/packages/core/src/types/AnimationsConfig.ts new file mode 100644 index 000000000..66e360339 --- /dev/null +++ b/packages/core/src/types/AnimationsConfig.ts @@ -0,0 +1,14 @@ +/** + * Configuration for cell animations on sort and programmatic column reorder. + * + * The animation coordinator runs FLIP-style transitions when cells move between + * positions. All fields are optional; omit the prop entirely to use defaults. + */ +export interface AnimationsConfig { + /** Master toggle. Defaults to `true`. When `false`, no other field has effect. */ + enabled?: boolean; + /** Animation duration in milliseconds. Defaults to `240`. */ + duration?: number; + /** CSS easing function. Defaults to `cubic-bezier(0.2, 0.8, 0.2, 1)`. */ + easing?: string; +} diff --git a/packages/core/src/types/SimpleTableConfig.ts b/packages/core/src/types/SimpleTableConfig.ts index c6c5ab3a5..2c7797617 100644 --- a/packages/core/src/types/SimpleTableConfig.ts +++ b/packages/core/src/types/SimpleTableConfig.ts @@ -22,11 +22,10 @@ import { GetRowId } from "./GetRowId"; import { ColumnEditorConfig } from "./ColumnEditorConfig"; import { VanillaIconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; +import { AnimationsConfig } from "./AnimationsConfig"; export interface SimpleTableConfig { - animations?: boolean; - animationDuration?: number; - animationEasing?: string; + animations?: AnimationsConfig; autoExpandColumns?: boolean; canExpandRowGroup?: (row: Row) => boolean; cellUpdateFlash?: boolean; diff --git a/packages/core/src/types/SimpleTableProps.ts b/packages/core/src/types/SimpleTableProps.ts index 8b369c299..841754764 100644 --- a/packages/core/src/types/SimpleTableProps.ts +++ b/packages/core/src/types/SimpleTableProps.ts @@ -23,11 +23,10 @@ import { GetRowId } from "./GetRowId"; import { ColumnEditorConfig } from "./ColumnEditorConfig"; import { IconsConfig } from "./IconsConfig"; import { QuickFilterConfig } from "./QuickFilterTypes"; +import { AnimationsConfig } from "./AnimationsConfig"; export interface SimpleTableProps { - animations?: boolean; // Flag for animating cells on sort and programmatic column reorder (default: true) - animationDuration?: number; // Cell animation duration in ms (default: 240). Honored only when `animations` is true. - animationEasing?: string; // Cell animation CSS easing function (default: cubic-bezier(0.2, 0.8, 0.2, 1)). Honored only when `animations` is true. + animations?: AnimationsConfig; // Cell animation configuration (FLIP-style on sort and programmatic column reorder). Defaults: enabled=true, duration=240ms, easing=cubic-bezier(0.2, 0.8, 0.2, 1). autoExpandColumns?: boolean; // Flag for converting pixel widths to proportional fr units that fill table width canExpandRowGroup?: (row: Row) => boolean; // Function to conditionally control if a row group can be expanded cellUpdateFlash?: boolean; // Flag for flash animation after cell update diff --git a/packages/core/stories/examples/sales-example/SalesExample.ts b/packages/core/stories/examples/sales-example/SalesExample.ts index fe4dcb25d..405c59731 100644 --- a/packages/core/stories/examples/sales-example/SalesExample.ts +++ b/packages/core/stories/examples/sales-example/SalesExample.ts @@ -9,7 +9,7 @@ import { SALES_HEADERS } from "./sales-headers"; import salesData from "./sales-data.json"; export const salesExampleDefaults = { - animations: true, + animations: { enabled: true }, columnResizing: true, columnReordering: true, selectableCells: true, diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index e4acc1882..c978f72ad 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -112,8 +112,7 @@ export const ProgrammaticReorderAnimation = { const originalHeaders = createHeaders(); const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { height: "400px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, }); setTable(result.table); result.h2.textContent = `Programmatic column reorder · ${SLOW_DURATION}ms per step`; @@ -282,8 +281,7 @@ export const SimpleThreeByThreeCenterToRightSwap = { ]; const result = renderVanillaTable(headers, rows, { height: "240px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); setTable(result.table); @@ -524,8 +522,7 @@ export const HeaderCellsAnimateOnColumnReorder = { ]; const result = renderVanillaTable(headers, rows, { height: "240px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); setTable(result.table); @@ -712,8 +709,7 @@ export const HeaderCellsAnimateDuringDragReorder = { ]; const result = renderVanillaTable(headers, rows, { height: "240px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, columnReordering: true, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); @@ -940,8 +936,7 @@ export const HeaderDragDoesNotFlickerDuringAnimation = { ]; const result = renderVanillaTable(headers, rows, { height: "240px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, columnReordering: true, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); @@ -1136,7 +1131,7 @@ export const ReorderWithoutAnimations = { render: () => { const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { height: "400px", - animations: false, + animations: { enabled: false }, }); setTable(result.table); result.h2.textContent = "Programmatic column reorder (animations: off, default)"; @@ -1191,8 +1186,7 @@ export const SortAnimationDemo = { render: () => { const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { height: "400px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); setTable(result.table); @@ -1299,7 +1293,7 @@ export const AnimationsPropWiring = { render: () => { const { wrapper, h2 } = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { height: "400px", - animations: true, + animations: { enabled: true }, }); h2.textContent = "Smoke: animations prop accepted"; addParagraph(wrapper, "Verifies the animations prop is plumbed through without error."); @@ -1331,8 +1325,7 @@ export const ReorderAnimatesFromPreviousPositionPerCell = { render: () => { const result = renderVanillaTable(createHeaders(), createData() as unknown as Row[], { height: "400px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, }); setTable(result.table); result.h2.textContent = "Reorder animates from each cell's previous position"; @@ -1469,8 +1462,7 @@ export const SortSlidesRowsCrossingTheViewportBoundary = { })); const result = renderVanillaTable(headers, rows, { height: "300px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); setTable(result.table); @@ -1649,8 +1641,7 @@ export const DragAndDropColumnReorderShouldAnimate = { ]; const result = renderVanillaTable(headers, rows, { height: "240px", - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, columnReordering: true, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); diff --git a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts index 05b5779ad..e7228ebd3 100644 --- a/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts +++ b/packages/core/stories/tests/42-CellAnimationsVirtualizationTests.stories.ts @@ -2,7 +2,7 @@ * CELL ANIMATIONS — VIRTUALIZATION & SCALE TESTS (slow & visible) * * Stress-tests the FLIP animation coordinator at scale (500 rows × 30 cols - * in a constrained viewport) with `animationDuration` cranked up so the + * in a constrained viewport) with `animations.duration` cranked up so the * play function is *visible* when watched in Storybook. Each play function * runs many sequential interactions with explicit pauses between them so * you can see each animation phase fire. @@ -136,8 +136,7 @@ const renderConstrainedTable = ( defaultHeaders: headers, rows: data, height: `${VIEWPORT_HEIGHT}px`, - animations: true, - animationDuration: SLOW_DURATION, + animations: { enabled: true, duration: SLOW_DURATION }, ...options, }); table.mount(); @@ -404,7 +403,7 @@ export const SlowColumnReorderMarathon = { announce(status, "Step 1/2 · Reversing all columns…"); table.update({ defaultHeaders: [...original].reverse() }); // 5 RAFs ≈ 80ms — well past the double-rAF FLIP "First"/"Play" handoff - // and into the active transition window (animationDuration=1500ms). + // and into the active transition window (animations.duration=1500ms). await tickFrames(5); expect(countActuallyAnimating(canvasElement)).toBeGreaterThan(50); await sleep(SETTLE_PAUSE); @@ -649,7 +648,7 @@ export const ReorderAtMultipleScrollPositions = { table.update({ defaultHeaders: order }); // Wait long enough for the FLIP `play` RAF to fire and the browser to // start interpolating, but well short of SLOW_DURATION so cells are - // still mid-flight. 5 RAFs ≈ 80ms, vs. animationDuration=1500ms. + // still mid-flight. 5 RAFs ≈ 80ms, vs. animations.duration=1500ms. await tickFrames(5); // Strict computed-style check: cells must have a non-identity transform From c7bc88c850be6a64bde1ad25e61be745caef1b0e Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 00:57:01 -0500 Subject: [PATCH 13/20] Content for animations --- .../public/txt-demos/angular/animations.txt | 78 ++++ .../public/txt-demos/react/animations.txt | 34 ++ .../public/txt-demos/solid/animations.txt | 22 + .../public/txt-demos/svelte/animations.txt | 24 ++ .../public/txt-demos/vanilla/animations.txt | 64 +++ .../public/txt-demos/vue/animations.txt | 30 ++ .../scripts/generate-search-index.cjs | 8 + .../src/app/docs/animations/page.tsx | 32 ++ .../src/components/demos/AnimationsDemo.tsx | 64 +++ .../pages/docs-pages/AnimationsContent.tsx | 303 ++++++++++++++ .../pages/docs-pages/ApiReferenceContent.tsx | 5 + .../components/sections/FeaturesSection.tsx | 7 + apps/marketing/src/constants/changelog.ts | 15 + .../marketing/src/constants/docsNavigation.ts | 7 + .../src/constants/docsSearchIndex.json | 382 +++++++++++++----- apps/marketing/src/constants/packageInfo.ts | 2 +- .../constants/propDefinitions/configProps.ts | 38 ++ .../src/constants/propDefinitions/index.ts | 1 + .../propDefinitions/simpleTableProps.ts | 20 + apps/marketing/src/constants/strings/seo.ts | 7 + .../animations/animations-demo.component.ts | 34 ++ .../demos/animations/animations.demo-data.ts | 40 ++ .../src/demos/animations/AnimationsDemo.tsx | 34 ++ .../demos/animations/animations.demo-data.ts | 40 ++ .../src/demos/animations/AnimationsDemo.tsx | 22 + .../demos/animations/animations.demo-data.ts | 40 ++ .../demos/animations/AnimationsDemo.svelte | 24 ++ .../demos/animations/animations.demo-data.ts | 40 ++ .../src/demos/animations/AnimationsDemo.ts | 20 + .../demos/animations/animations.demo-data.ts | 40 ++ .../src/demos/animations/AnimationsDemo.vue | 30 ++ .../demos/animations/animations.demo-data.ts | 40 ++ 32 files changed, 1441 insertions(+), 106 deletions(-) create mode 100644 apps/marketing/public/txt-demos/angular/animations.txt create mode 100644 apps/marketing/public/txt-demos/react/animations.txt create mode 100644 apps/marketing/public/txt-demos/solid/animations.txt create mode 100644 apps/marketing/public/txt-demos/svelte/animations.txt create mode 100644 apps/marketing/public/txt-demos/vanilla/animations.txt create mode 100644 apps/marketing/public/txt-demos/vue/animations.txt create mode 100644 apps/marketing/src/app/docs/animations/page.tsx create mode 100644 apps/marketing/src/components/demos/AnimationsDemo.tsx create mode 100644 apps/marketing/src/components/pages/docs-pages/AnimationsContent.tsx create mode 100644 packages/examples/angular/src/demos/animations/animations-demo.component.ts create mode 100644 packages/examples/angular/src/demos/animations/animations.demo-data.ts create mode 100644 packages/examples/react/src/demos/animations/AnimationsDemo.tsx create mode 100644 packages/examples/react/src/demos/animations/animations.demo-data.ts create mode 100644 packages/examples/solid/src/demos/animations/AnimationsDemo.tsx create mode 100644 packages/examples/solid/src/demos/animations/animations.demo-data.ts create mode 100644 packages/examples/svelte/src/demos/animations/AnimationsDemo.svelte create mode 100644 packages/examples/svelte/src/demos/animations/animations.demo-data.ts create mode 100644 packages/examples/vanilla/src/demos/animations/AnimationsDemo.ts create mode 100644 packages/examples/vanilla/src/demos/animations/animations.demo-data.ts create mode 100644 packages/examples/vue/src/demos/animations/AnimationsDemo.vue create mode 100644 packages/examples/vue/src/demos/animations/animations.demo-data.ts diff --git a/apps/marketing/public/txt-demos/angular/animations.txt b/apps/marketing/public/txt-demos/angular/animations.txt new file mode 100644 index 000000000..6a2d4c285 --- /dev/null +++ b/apps/marketing/public/txt-demos/angular/animations.txt @@ -0,0 +1,78 @@ +// animations-demo.component.ts +import { Component, Input } from "@angular/core"; +import { SimpleTableComponent } from "@simple-table/angular"; +import type { AngularHeaderObject, Row, Theme } from "@simple-table/angular"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/angular/styles.css"; + +@Component({ + selector: "animations-demo", + standalone: true, + imports: [SimpleTableComponent], + template: ` + + `, +}) +export class AnimationsDemoComponent { + @Input() height: string | number = "400px"; + @Input() theme?: Theme; + + readonly rows: Row[] = animationsConfig.rows; + headers: AngularHeaderObject[] = [...animationsConfig.headers]; + + onColumnOrderChange(newHeaders: AngularHeaderObject[]): void { + this.headers = newHeaders; + } +} + + +// animations.demo-data.ts +// Self-contained demo table setup for this example. +import type { AngularHeaderObject } from "@simple-table/angular"; + + +export const animationsHeaders: AngularHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/apps/marketing/public/txt-demos/react/animations.txt b/apps/marketing/public/txt-demos/react/animations.txt new file mode 100644 index 000000000..4d64adb61 --- /dev/null +++ b/apps/marketing/public/txt-demos/react/animations.txt @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { SimpleTable } from "@simple-table/react"; +import type { Theme, ReactHeaderObject } from "@simple-table/react"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/react/styles.css"; + +const AnimationsDemo = ({ + height = "400px", + theme, +}: { + height?: string | number; + theme?: Theme; +}) => { + const [headers, setHeaders] = useState(() => [...animationsConfig.headers]); + + const handleColumnOrderChange = (newHeaders: ReactHeaderObject[]) => { + setHeaders(newHeaders); + }; + + return ( + + ); +}; + +export default AnimationsDemo; diff --git a/apps/marketing/public/txt-demos/solid/animations.txt b/apps/marketing/public/txt-demos/solid/animations.txt new file mode 100644 index 000000000..df8ebe957 --- /dev/null +++ b/apps/marketing/public/txt-demos/solid/animations.txt @@ -0,0 +1,22 @@ +import { createSignal } from "solid-js"; +import { SimpleTable } from "@simple-table/solid"; +import type { Theme } from "@simple-table/solid"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/solid/styles.css"; + +export default function AnimationsDemo(props: { height?: string | number; theme?: Theme }) { + const [headers, setHeaders] = createSignal([...animationsConfig.headers]); + + return ( + + ); +} diff --git a/apps/marketing/public/txt-demos/svelte/animations.txt b/apps/marketing/public/txt-demos/svelte/animations.txt new file mode 100644 index 000000000..225e79c84 --- /dev/null +++ b/apps/marketing/public/txt-demos/svelte/animations.txt @@ -0,0 +1,24 @@ + + + diff --git a/apps/marketing/public/txt-demos/vanilla/animations.txt b/apps/marketing/public/txt-demos/vanilla/animations.txt new file mode 100644 index 000000000..4228a76a8 --- /dev/null +++ b/apps/marketing/public/txt-demos/vanilla/animations.txt @@ -0,0 +1,64 @@ +// AnimationsDemo.ts +import { SimpleTableVanilla } from "simple-table-core"; +import type { Theme } from "simple-table-core"; +import { animationsConfig } from "./animations.demo-data"; +import "simple-table-core/styles.css"; + +export function renderAnimationsDemo( + container: HTMLElement, + options?: { height?: string | number; theme?: Theme } +): SimpleTableVanilla { + const table = new SimpleTableVanilla(container, { + defaultHeaders: animationsConfig.headers, + rows: animationsConfig.rows, + height: options?.height ?? "400px", + theme: options?.theme, + columnReordering: true, + editColumns: true, + editColumnsInitOpen: true, + }); + return table; +} + + +// animations.demo-data.ts +// Self-contained demo table setup for this example. +import type { HeaderObject } from "simple-table-core"; + + +export const animationsHeaders: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/apps/marketing/public/txt-demos/vue/animations.txt b/apps/marketing/public/txt-demos/vue/animations.txt new file mode 100644 index 000000000..ab7f18c07 --- /dev/null +++ b/apps/marketing/public/txt-demos/vue/animations.txt @@ -0,0 +1,30 @@ + + + diff --git a/apps/marketing/scripts/generate-search-index.cjs b/apps/marketing/scripts/generate-search-index.cjs index dc3b3b33b..6f2b66f5c 100644 --- a/apps/marketing/scripts/generate-search-index.cjs +++ b/apps/marketing/scripts/generate-search-index.cjs @@ -127,6 +127,7 @@ function getSectionName(docPath) { "/docs/cell-highlighting": "Cell Features", "/docs/cell-renderer": "Cell Features", "/docs/cell-clicking": "Cell Features", + "/docs/animations": "Cell Features", "/docs/header-renderer": "Advanced Features", "/docs/footer-renderer": "Advanced Features", "/docs/pagination": "Advanced Features", @@ -314,6 +315,13 @@ function getSEOMetadata(docId) { keywords: "simple-table, react-table, react-grid, data-grid, datagrid, data table, cell clicking, cell events, interactive table, cell interactions, typescript table, onclick handlers", }, + animations: { + title: "Animations in Simple Table React Grid", + description: + "Smooth animations on sort, column reorder, and column visibility changes in your react-table with Simple Table. GPU-accelerated, virtualization-aware, and respects prefers-reduced-motion. Customize duration and easing or disable entirely.", + keywords: + "simple-table, react-table, react-grid, data-grid, datagrid, data table, animations, table animations, sort animations, column reorder animations, prefers-reduced-motion, typescript table", + }, "header-renderer": { title: "Custom Header Renderers with Simple Table", description: diff --git a/apps/marketing/src/app/docs/animations/page.tsx b/apps/marketing/src/app/docs/animations/page.tsx new file mode 100644 index 000000000..1fafa3a2b --- /dev/null +++ b/apps/marketing/src/app/docs/animations/page.tsx @@ -0,0 +1,32 @@ +import { Metadata } from "next"; +import AnimationsContent from "@/components/pages/docs-pages/AnimationsContent"; +import { SEO_STRINGS } from "@/constants/strings/seo"; + +export const metadata: Metadata = { + title: SEO_STRINGS.animations.title, + description: SEO_STRINGS.animations.description, + keywords: SEO_STRINGS.animations.keywords, + openGraph: { + title: SEO_STRINGS.animations.title, + description: SEO_STRINGS.animations.description, + type: "article", + images: [SEO_STRINGS.site.ogImage], + siteName: SEO_STRINGS.site.name, + }, + twitter: { + card: "summary_large_image", + title: SEO_STRINGS.animations.title, + description: SEO_STRINGS.animations.description, + creator: SEO_STRINGS.site.creator, + images: SEO_STRINGS.site.ogImage.url, + }, + alternates: { + canonical: "/docs/animations", + }, +}; + +const AnimationsPage = () => { + return ; +}; + +export default AnimationsPage; diff --git a/apps/marketing/src/components/demos/AnimationsDemo.tsx b/apps/marketing/src/components/demos/AnimationsDemo.tsx new file mode 100644 index 000000000..b88727c46 --- /dev/null +++ b/apps/marketing/src/components/demos/AnimationsDemo.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { SimpleTable } from "@simple-table/react"; +import type { ReactHeaderObject, Theme } from "@simple-table/react"; +import "@simple-table/react/styles.css"; + +const INITIAL_HEADERS: ReactHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +const EMPLOYEES = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +const AnimationsDemo = ({ + height = "400px", + theme, +}: { + height?: string | number; + theme?: Theme; +}) => { + const [headers, setHeaders] = useState(INITIAL_HEADERS); + + const handleColumnOrderChange = (newHeaders: ReactHeaderObject[]) => { + setHeaders(newHeaders); + }; + + return ( + + ); +}; + +export default AnimationsDemo; diff --git a/apps/marketing/src/components/pages/docs-pages/AnimationsContent.tsx b/apps/marketing/src/components/pages/docs-pages/AnimationsContent.tsx new file mode 100644 index 000000000..ab9e10704 --- /dev/null +++ b/apps/marketing/src/components/pages/docs-pages/AnimationsContent.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { motion } from "framer-motion"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faWandMagicSparkles } from "@fortawesome/free-solid-svg-icons"; +import Link from "next/link"; +import AnimationsDemo from "@/components/demos/AnimationsDemo"; +import DocNavigationButtons from "@/components/DocNavigationButtons"; +import PageWrapper from "@/components/PageWrapper"; +import LivePreview from "@/components/LivePreview"; +import PropTable, { type PropInfo } from "@/components/PropTable"; + +const ANIMATIONS_PROPS: PropInfo[] = [ + { + key: "animations", + name: "animations", + required: false, + description: + "Configures table animations. Animations are enabled by default with sensible motion settings (240ms duration, smooth easing) and automatically respect the user's prefers-reduced-motion setting. Pass an object to override the duration, easing, or to disable animations entirely.", + type: "AnimationsConfig", + link: "/docs/api-reference#animations-config", + example: `// Default behavior (animations on, 240ms, smooth easing) +// No prop required. + +// Tweak duration / easing +animations={{ + duration: 320, + easing: "ease-out", +}} + +// Disable animations entirely +animations={{ enabled: false }}`, + }, +]; + +const ANIMATIONS_CONFIG_FIELDS: PropInfo[] = [ + { + key: "enabled", + name: "enabled", + required: false, + description: + "Master toggle. When false, no other field has effect and cells snap to their new positions instantly. Defaults to true.", + type: "boolean", + example: `animations={{ enabled: false }}`, + }, + { + key: "duration", + name: "duration", + required: false, + description: + "Animation duration in milliseconds. Applies to every animated cell. Defaults to 240.", + type: "number", + example: `// Snappier 160ms transitions +animations={{ duration: 160 }} + +// Slower, more dramatic 400ms transitions +animations={{ duration: 400 }}`, + }, + { + key: "easing", + name: "easing", + required: false, + description: + "CSS easing function applied to the transform transition. Accepts any valid CSS timing function (cubic-bezier, ease, linear, etc.). Defaults to cubic-bezier(0.2, 0.8, 0.2, 1).", + type: "string", + example: `animations={{ + duration: 280, + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" // bouncy +}}`, + }, +]; + +const AnimationsContent = () => { + return ( + + +
+ +
+

Animations

+
+ + + Cells smoothly slide between positions when the table's logical state changes — + sorting, reordering columns, or toggling column visibility — instead of teleporting. + Animations are enabled by default, GPU-accelerated, and virtualization-aware so cells + slide in from the viewport edge when they enter and out to the edge when they leave. + + + + + + + +

New in v3.1.0

+

+ Animations ship enabled by default. Click a column header to sort, drag a column to + reorder, or toggle a column from the visibility popout to see cells slide smoothly into + their new positions. +

+
+ + + What gets animated + + + +
    +
  • + Sort change. Rows shift vertically when the user clicks a sortable + header or you change the sort programmatically. +
  • +
  • + Column reorder during drag. As the user drags a column header over + its neighbors, the displaced columns slide smoothly out of the way. The actively + dragged column itself follows the pointer (it is intentionally not animated, so the + cursor never fights the transition). +
  • +
  • + Programmatic column reorder. Updating{" "} + defaultHeaders + {" "}or calling{" "} + + tableRef.applyPinnedState + + {" "}animates every cell to its new column position. +
  • +
  • + Column visibility changes. Showing or hiding a column from the + column editor reflows the remaining columns with the same slide. +
  • +
+ +
+

What is not animated

+

+ Some renders are deliberately skipped to keep the table feeling responsive: +

+
    +
  • + Scroll. Vertical and horizontal scrolling already produce native + motion; layering animations on top would feel laggy. Scroll renders never trigger + animations. +
  • +
  • + The actively dragged column. The column under the user's + pointer follows the cursor instantly. Only the columns being displaced animate. +
  • +
  • + Cell content updates. Cells update text and other content + instantly. The existing{" "} + + cellUpdateFlash + {" "} + flash animation is independent and can be enabled separately. +
  • +
+
+
+ + + Configuration + + + +

+ Animations are on by default — no prop needed. Pass an{" "} + animations{" "} + object to tune the timing or to disable them. +

+ + + +
+ +
+
+ + + Examples + + + +

+ Disable animations +

+

+ Useful for spreadsheet-style UIs where instant feedback matters more than motion. +

+
+          {``}
+        
+ +

Custom timing

+

+ Override the default 240 ms duration and{" "} + + cubic-bezier(0.2, 0.8, 0.2, 1) + {" "} + easing to match your product's motion language. +

+
+          {``}
+        
+
+ + + Accessibility + + + +

+ The animation system reads{" "} + + window.matchMedia("(prefers-reduced-motion: reduce)") + {" "} + on initialization and falls back to instant updates whenever the user has reduced + motion enabled at the OS or browser level. You don't need to do anything special — + this is handled automatically and overrides the{" "} + enabled flag. +

+
+ + +
+ ); +}; + +export default AnimationsContent; diff --git a/apps/marketing/src/components/pages/docs-pages/ApiReferenceContent.tsx b/apps/marketing/src/components/pages/docs-pages/ApiReferenceContent.tsx index 054f04fc9..b447b1004 100644 --- a/apps/marketing/src/components/pages/docs-pages/ApiReferenceContent.tsx +++ b/apps/marketing/src/components/pages/docs-pages/ApiReferenceContent.tsx @@ -26,6 +26,7 @@ import { ON_ROW_GROUP_EXPAND_PROPS, HEADER_RENDERER_PROPS, COLUMN_EDITOR_ROW_RENDERER_PROPS, + ANIMATIONS_CONFIG_PROPS, ENUM_OPTION_PROPS, AGGREGATION_CONFIG_PROPS, CHART_OPTIONS_PROPS, @@ -165,6 +166,10 @@ const ApiReferenceContent = () => { All union type values and object type properties used in SimpleTable. +
+ +
+
diff --git a/apps/marketing/src/components/sections/FeaturesSection.tsx b/apps/marketing/src/components/sections/FeaturesSection.tsx index deac2becc..484c3d99a 100644 --- a/apps/marketing/src/components/sections/FeaturesSection.tsx +++ b/apps/marketing/src/components/sections/FeaturesSection.tsx @@ -24,6 +24,7 @@ import { faBolt, faFileCode, faMobileAlt, + faWandMagicSparkles, } from "@fortawesome/free-solid-svg-icons"; import Link from "next/link"; import { motion } from "framer-motion"; @@ -247,6 +248,12 @@ export default function FeaturesSection() { desc: "Visual theme customization", link: "/theme-builder", }, + { + icon: faWandMagicSparkles, + title: "Animations", + desc: "Smooth transitions on sort, reorder, and visibility changes", + link: "/docs/animations", + }, ].map((item, index) => ( \n Aggregate functions are configured by adding the{ >\n aggregation\n { >\n { }\n}\n// or\n{\n aggregation: { type: font-semibold text-gray-800 dark:text-white mb-2 text-sm bg-gray-100 dark:bg-gray-900 p-3 rounded overflow-x-auto {\n aggregation: {\n type: ));\n return isNaN(numericValue) ? 0 : numericValue;\n }\n }\n} bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm font-bold text-gray-800 dark:text-white mb-2 list-disc pl-5 space-y-1 text-gray-700 dark:text-gray-300", + "content": "Aggregate functions are configured by adding the\n \n aggregation\n \n property to column headers. When data is grouped using row grouping, these functions\n automatically calculate summary values for each group level. Calculates the total of all numeric values in a group. Perfect for totaling budgets, employee counts, or any cumulative metrics. Computes the arithmetic mean of all values in a group. Ideal for ratings, performance scores, or any metric where the mean is meaningful. Counts the number of non-null values in a group. Useful for counting projects, tasks, or any discrete items within groups. Finds the minimum or maximum value in a group. Great for finding ranges, extremes, or boundary values in your data. For complex scenarios, you can define custom aggregation functions and parse string\n values before aggregation. Combine aggregations with custom cell renderers to format results appropriately Use parseValue when your source data contains formatted strings like currencies Aggregations work at every level of row grouping, providing hierarchical summaries Custom aggregation functions receive all values in the group as an array Row Grouping\n \n - Learn about organizing hierarchical data Nested Tables\n \n - Display different columns at each hierarchy level Value Formatter\n \n - Format aggregated values for display HeaderObject.aggregation Configuration object for aggregation functions that automatically calculate summary values for grouped data. Supports built-in types (sum, average, count, min, max) and custom functions. /docs/api-reference#aggregation-config // Sum aggregation - totals all numeric values\n{\n accessor: , \n aggregation: { type: }\n}\n\n// Average aggregation - computes arithmetic mean\n{\n accessor: }\n}\n\n// Count aggregation - counts non-null values\n{\n accessor: }\n}\n\n// Min/Max aggregation - finds extremes\n{\n accessor: }\n}\n\n// Custom aggregation with value parsing\n{\n accessor: , \n aggregation: {\n type: + val.toLocaleString()\n }\n}\n\n// Custom function aggregation\n{\n accessor: flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg text-purple-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Aggregate functions are configured by adding the{ >\n aggregation\n { >\n Row Grouping\n { >\n Nested Tables\n { >\n Value Formatter\n {", "section": "Row Features", "headings": [ "Aggregate Functions", @@ -28,6 +28,33 @@ "💡 Pro Tips" ] }, + { + "id": "animations", + "path": "/docs/animations", + "title": "Animations in Simple Table React Grid", + "description": "Smooth animations on sort, column reorder, and column visibility changes in your react-table with Simple Table. GPU-accelerated, virtualization-aware, and respects prefers-reduced-motion. Customize duration and easing or disable entirely.", + "keywords": [ + "simple-table", + "react-table", + "react-grid", + "data-grid", + "datagrid", + "data table", + "animations", + "table animations", + "sort animations", + "column reorder animations", + "prefers-reduced-motion", + "typescript table" + ], + "content": "Animations ship enabled by default. Click a column header to sort, drag a column to\n reorder, or toggle a column from the visibility popout to see cells slide smoothly into\n their new positions. Some renders are deliberately skipped to keep the table feeling responsive: Animations are on by default — no prop needed. Pass an\n animations\n object to tune the timing or to disable them. Useful for spreadsheet-style UIs where instant feedback matters more than motion. rows=\n animations=}\n/>`}\n \n\n Custom timing\n \n Override the default 240 ms duration and\n \n cubic-bezier(0.2, 0.8, 0.2, 1)\n \n easing to match your product's motion language. rows=\n animations=}\n/>`}\n \n \n\n \n Accessibility\n \n\n \n \n The animation system reads\n \n window.matchMedia("(prefers-reduced-motion: reduce)")\n \n on initialization and falls back to instant updates whenever the user has reduced\n motion enabled at the OS or browser level. You don't need to do anything special —\n this is handled automatically and overrides the\n enabled flag. Sort change. Rows shift vertically when the user clicks a sortable\n header or you change the sort programmatically. Column reorder during drag. As the user drags a column header over\n its neighbors, the displaced columns slide smoothly out of the way. The actively\n dragged column itself follows the pointer (it is intentionally not animated, so the\n cursor never fights the transition). Programmatic column reorder. Updating\n defaultHeaders\n or calling\n \n tableRef.applyPinnedState\n \n animates every cell to its new column position. Column visibility changes. Showing or hiding a column from the\n column editor reflows the remaining columns with the same slide. Scroll. Vertical and horizontal scrolling already produce native\n motion; layering animations on top would feel laggy. Scroll renders never trigger\n animations. The actively dragged column. The column under the user's\n pointer follows the cursor instantly. Only the columns being displaced animate. Cell content updates. Cells update text and other content\n instantly. The existing\n \n cellUpdateFlash\n \n flash animation is independent and can be enabled separately. ,\n required: false,\n description: s prefers-reduced-motion setting. Pass an object to override the duration, easing, or to disable animations entirely. /docs/api-reference#animations-config >defaultHeaders\n { >\n tableRef.applyPinnedState\n \n { >\n cellUpdateFlash\n { >\n Animations are on by default — no prop needed. Pass an{ text-xl font-semibold text-gray-800 dark:text-white mb-3 text-gray-700 dark:text-gray-300 mb-4 >\n cubic-bezier(0.2, 0.8, 0.2, 1)\n { , // gentle overshoot\n }}\n/> text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 >\n The animation system reads{ >\n window.matchMedia("(prefers-reduced-motion: reduce)")\n { }\n on initialization and falls back to instant updates whenever the user has reduced\n motion enabled at the OS or browser level. You don't need to do anything special —\n this is handled automatically and overrides the{", + "section": "Cell Features", + "headings": [ + "Animations", + "Disable animations", + "Custom timing" + ] + }, { "id": "api-reference", "path": "/docs/api-reference", @@ -46,9 +73,11 @@ "typescript table", "documentation" ], - "content": "row-selection-change-props RowSelectionChangeProps value-formatter-props on-row-group-expand-props OnRowGroupExpandProps footer-renderer-props", + "content": ">\n Custom Theme\n { row-selection-change-props RowSelectionChangeProps value-formatter-props on-row-group-expand-props OnRowGroupExpandProps header-renderer-props column-editor-row-renderer-props ColumnEditorRowRendererProps column-visibility-state ColumnVisibilityState quick-filter-getter-props QuickFilterGetterProps column-editor-config footer-renderer-props", "section": "Getting Started", - "headings": ["API Reference"] + "headings": [ + "API Reference" + ] }, { "id": "csv-export", @@ -69,7 +98,7 @@ "typescript table", "data export" ], - "content": "To enable CSV export in your table, follow these steps: The\n exportToCSV()\n method now exports all data from your table, including all pages when\n pagination is enabled. Previously, only the current page was exported - this has been\n fixed in version 1.9.4. The exported CSV includes all visible columns and respects active filters/sorting. Default\n filename is\n table-export.csv\n . Customize with\n \n )`}\n \n . Control how column values appear in CSV exports using three powerful options. By default,\n CSV exports use raw data values. Export the same formatted values that are displayed in the table. Perfect when you\n want CSV exports to match what users see on screen. Provide completely different values specifically for CSV export. Takes highest\n priority. Ideal for adding codes, identifiers, or transforming data for spreadsheet\n compatibility. Use exportValueGetter to add department codes, identifiers, or additional context for\n better data clarity: New in v1.9.4: The table ref now provides powerful methods to access all table data and\n configuration programmatically. Try clicking the \"Get Table Info\" button\n in the demo above! Returns all rows as\n TableRow[]\n objects, flattened and including nested/grouped rows. Each TableRow contains the raw\n data in the\n row property\n plus metadata like depth, position, and rowPath. Perfect for analytics, batch\n operations, or custom exports. Returns the table's current header/column definitions. Useful for dynamic table\n manipulation or building custom UI controls. Create a table reference - Create a ref using\n \n useRef\n \n and pass it to the\n \n tableRef\n \n prop Add an export button - Create a button or trigger that calls the export\n method Call exportToCSV() - Invoke\n \n tableRef.current?.exportToCSV()\n \n to download the CSV file ,\n required: true,\n description: React.RefObject ,\n required: false,\n description: // Default filename - exports all data\ntableRef.current?.exportToCSV();\n\n// Custom filename\ntableRef.current?.exportToCSV({ filename: ,\n },\n {\n key: includeHeadersInCSVExport When true, includes column headers as the first row in CSV exports. Defaults to true. HeaderObject.excludeFromRender When true, hides the column from the table display while keeping it in CSV exports. Perfect for internal IDs, database keys, or technical fields that shouldn ,\n excludeFromRender: true // Hidden in table, included in CSV\n} HeaderObject.excludeFromCsv When true, shows the column in the table but excludes it from CSV exports. Perfect for action buttons, interactive elements, or UI-only columns that don flex items-center gap-3 mb-6 p-2 bg-green-100 rounded-lg text-green-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n tableRef\n { >\n exportToCSV()\n { >\n
  • \n Create a table reference - Create a ref using{ >\n useRef\n { }\n and pass it to the{ >\n tableRef\n { }\n prop\n
  • \n
  • \n Add an export button - Create a button or trigger that calls the export\n method\n
  • \n
  • \n Call exportToCSV() - Invoke{ >\n tableRef.current?.exportToCSV()\n { >exportToCSV(){ >\n The exported CSV includes all visible columns and respects active filters/sorting. Default\n filename is{ >table-export.csv\n . Customize with{ exportToCSV({ filename: ,\n useFormattedValueForCSV: true\n}\n\n// Table displays: // Without flag: CSV would export 85000 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 p-5 rounded-lg text-lg font-semibold text-gray-800 dark:text-white mb-3 text-gray-700 dark:text-gray-300 mb-3 bg-gray-800 text-white p-4 rounded-md overflow-x-auto shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] ;\n }\n}\n\n// Table displays: // Raw value: 0.925 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-700 p-5 rounded-lg \\${capitalize(value)} (\\${code})\\ >\n New in v1.9.4: The table ref now provides powerful methods to access all table data and\n configuration programmatically. Try clicking the >\n Returns all rows as{ }\n objects, flattened and including nested/grouped rows. Each TableRow contains the raw\n data in the{ Depth: \\${tableRow.depth}, Position: \\${tableRow.position}\\ >\n Returns the table", + "content": "To enable CSV export in your table, follow these steps: The\n exportToCSV()\n method now exports all data from your table, including all pages when\n pagination is enabled. Previously, only the current page was exported - this has been\n fixed in version 1.9.4. The exported CSV includes all visible columns and respects active filters/sorting. Default\n filename is\n table-export.csv\n . Customize with\n \n )`}\n \n . Control how column values appear in CSV exports using three powerful options. By default,\n CSV exports use raw data values. Export the same formatted values that are displayed in the table. Perfect when you\n want CSV exports to match what users see on screen. Provide completely different values specifically for CSV export. Takes highest\n priority. Ideal for adding codes, identifiers, or transforming data for spreadsheet\n compatibility. Use exportValueGetter to add department codes, identifiers, or additional context for\n better data clarity: New in v1.9.4: The table ref now provides powerful methods to access all table data and\n configuration programmatically. Try clicking the \"Get Table Info\" button\n in the demo above! Returns all rows as\n TableRow[]\n objects, flattened and including nested/grouped rows. Each TableRow contains the raw\n data in the\n row property\n plus metadata like depth, position, and rowPath. Perfect for analytics, batch\n operations, or custom exports. Returns the table's current header/column definitions. Useful for dynamic table\n manipulation or building custom UI controls. Create a table reference - Create a ref using\n \n useRef\n \n and pass it to the\n \n tableRef\n \n prop Add an export button - Create a button or trigger that calls the export\n method Call exportToCSV() - Invoke\n \n tableRef.current?.exportToCSV()\n \n to download the CSV file ,\n required: true,\n description: React.RefObject ,\n required: false,\n description: // Default filename - exports all data\ntableRef.current?.exportToCSV();\n\n// Custom filename\ntableRef.current?.exportToCSV({ filename: ,\n },\n {\n key: includeHeadersInCSVExport When true, includes column headers as the first row in CSV exports. Defaults to true. HeaderObject.excludeFromRender When true, hides the column from the table display and from the column editor, while keeping it in CSV exports. Perfect for internal IDs, database keys, or technical fields that shouldn ,\n excludeFromRender: true // Hidden in table and visibility menu, included in CSV\n} HeaderObject.excludeFromCsv When true, shows the column in the table but excludes it from CSV exports. Perfect for action buttons, interactive elements, or UI-only columns that don flex items-center gap-3 mb-6 p-2 bg-green-100 rounded-lg text-green-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n tableRef\n { >\n exportToCSV()\n { >\n
  • \n Create a table reference - Create a ref using{ >\n useRef\n { }\n and pass it to the{ >\n tableRef\n { }\n prop\n
  • \n
  • \n Add an export button - Create a button or trigger that calls the export\n method\n
  • \n
  • \n Call exportToCSV() - Invoke{ >\n tableRef.current?.exportToCSV()\n { >exportToCSV(){ >\n The exported CSV includes all visible columns and respects active filters/sorting. Default\n filename is{ >table-export.csv\n . Customize with{ exportToCSV({ filename: >\n New in v1.9.4: The table ref now provides powerful methods to access all table data and\n configuration programmatically. Try clicking the >\n Returns all rows as{ }\n objects, flattened and including nested/grouped rows. Each TableRow contains the raw\n data in the{ >\n Returns the table", "section": "Advanced Features", "headings": [ "CSV Export", @@ -101,7 +130,9 @@ ], "content": ",\n required: false,\n description: /docs/api-reference#cell-click-props , {\n column: props.accessor,\n value: props.value,\n row: props.row,\n position: [props.rowIndex, props.colIndex]\n });\n \n // Different actions based on column\n switch (props.accessor) {\n case :\n // Navigate to user profile\n router.push(\\ );\n break;\n case :\n // Toggle status\n toggleStatus(props.row.id);\n break;\n case flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg", "section": "Cell Features", - "headings": ["Cell Clicking"] + "headings": [ + "Cell Clicking" + ] }, { "id": "cell-editing", @@ -122,7 +153,9 @@ ], "content": "To enable cell editing in Simple Table, you need to: Simple Table includes built-in copy-paste functionality that works seamlessly with cell\n editing: When pasting data, only columns marked with\n \n isEditable: true\n \n will accept the pasted values. This ensures data integrity and prevents accidental\n modification of read-only columns like IDs or calculated fields. Add the\n \n isEditable: true\n \n property to the columns you want to make editable Provide an\n \n onCellEdit\n \n handler to manage the data updates Users can copy data from any selected cells using keyboard shortcuts (Ctrl+C/⌘+C) Data can be pasted from external sources like spreadsheets or other applications Important: Pasting is only allowed into columns that have\n \n isEditable: true Non-editable columns will be skipped during paste operations HeaderObject.isEditable Makes a column editable, allowing users to modify cell values directly within the table interface. , \n isEditable: true \n} ,\n required: false,\n description: /docs/api-reference#union-types // String editor (default text input)\n{ \n accessor: ,\n isEditable: true \n}\n\n// Number editor (numeric input with validation)\n{ \n accessor: ,\n isEditable: true \n}\n\n// Boolean editor (checkbox)\n{ \n accessor: ,\n isEditable: true \n}\n\n// Date editor (date picker)\n{ \n accessor: ,\n isEditable: true \n}\n\n// Enum editor (dropdown with options)\n{ \n accessor: ,\n isEditable: true,\n enumOptions: [\n { label: ,\n },\n {\n key: /docs/api-reference#cell-change-props , props.accessor);\n console.log( , props.newValue);\n console.log( flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg text-purple-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Add the{ >\n isEditable: true\n { }\n property to the columns you want to make editable\n
  • \n
  • \n Provide an{ >\n onCellEdit\n { >\n
  • \n Users can copy data from any selected cells using keyboard shortcuts (Ctrl+C/⌘+C)\n
  • \n
  • \n Data can be pasted from external sources like spreadsheets or other applications\n
  • \n
  • \n Important: Pasting is only allowed into columns that have{ >\n When pasting data, only columns marked with{ >\n isEditable: true\n {", "section": "Cell Features", - "headings": ["Cell Editing"] + "headings": [ + "Cell Editing" + ] }, { "id": "cell-highlighting", @@ -143,7 +176,9 @@ ], "content": "Enable cell selection by adding the\n \n selectableCells\n \n and\n \n selectableColumns\n \n props to your SimpleTable component. This enables users to select individual cells and\n entire columns. When selection is enabled, users can: Cell selection supports powerful keyboard shortcuts for efficient navigation and\n selection: Cell highlighting works seamlessly with Simple Table's built-in copy-paste functionality: When pasting data into your table, only columns marked with\n \n isEditable: true\n \n will accept the pasted values. Non-editable columns will be automatically skipped during\n paste operations, ensuring data integrity and preventing accidental modification of\n read-only fields like IDs or calculated values. Combine cell highlighting with column editing capabilities to create powerful data entry\n workflows. Users can select ranges, copy from spreadsheets, and paste directly into\n editable columns for efficient bulk data operations. Click on individual cells to select them Click on column headers to select entire columns Use keyboard shortcuts (Ctrl+C/⌘+C) to copy selected data Paste the data into spreadsheet applications Copy selected cells or ranges to the clipboard using Ctrl+C/⌘+C Paste data from external sources using Ctrl+V/⌘+V Data formatting is preserved when copying between tables and spreadsheet applications Multi-cell selection enables efficient bulk copy operations ,\n required: false,\n description: copyHeadersToClipboard When true, includes column headers as the first row when copying selected cells to clipboard. Defaults to false. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Enable cell selection by adding the{ >\n selectableCells\n { >\n selectableColumns\n { >\n Cell highlighting works seamlessly with Simple Table list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300 mb-4 bg-yellow-50 dark:bg-yellow-900/30 border-l-4 border-yellow-400 dark:border-yellow-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >\n isEditable: true\n {", "section": "Cell Features", - "headings": ["Cell Highlighting"] + "headings": [ + "Cell Highlighting" + ] }, { "id": "cell-renderer", @@ -162,7 +197,7 @@ "typescript table", "data visualization" ], - "content": "For simple text formatting (currency, dates, percentages), consider using\n \n valueFormatter\n \n instead. It's more performant and easier to implement. Use\n \n cellRenderer\n \n when you need React components, custom styling, or interactive elements. Each column in your table can have its own\n \n cellRenderer\n \n function. This function receives information about the cell and returns either a ReactNode\n or a string to be rendered in the cell. The\n \n row\n \n parameter passed to your cell renderer is a flat object containing all the row's data: To access a specific cell value, use\n \n row[accessor]\n \n directly. The row object is flat and contains all the data for that row. Your\n \n cellRenderer\n \n function should return one of the following: Use\n \n value\n \n for quick access to the current cell's raw value (same as\n \n row[accessor]\n \n ) Use\n \n formattedValue\n \n to access the valueFormatter output if one is defined, making it easy to wrap\n formatted text in custom components Use\n \n row\n \n to access any data from the current row, not just the current cell - perfect for\n renderers that depend on multiple column values New in v1.9.7:\n \n Use\n \n rowPath\n \n to access the path through nested data structures (e.g., [0, \"teams\", 1] for\n rows[0].teams[1]) New in v1.9.7:\n \n \n formattedValue\n \n now supports string[], number[], and boolean types for more flexible formatting String: A simple text value to display in the cell\n \n return \"Hello, world!\"; ReactNode: A React component for custom rendering\n \n \n return <div className=\"flex items-center\"><span\n className=\"mr-2\">⭐</span> Custom Content</div>; Each column can have its own unique renderer Columns without a cellRenderer will display their values as plain text Avoid expensive operations in cell renderers as they run frequently Consider memoizing complex components to improve performance For simple text formatting, use\n \n valueFormatter\n \n instead for better performance HeaderObject.cellRenderer Custom function to render cell content with React components. Receives cell information and returns either a ReactNode or string for display. For simple text formatting (currency, dates, percentages), use valueFormatter instead for better performance. }>\n {status}\n \n );\n }\n} flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n For simple text formatting (currency, dates, percentages), consider using{ >\n valueFormatter\n { }\n instead. It >\n cellRenderer\n { >\n Each column in your table can have its own{ >\n
  • \n Use{ >\n value\n { }\n for quick access to the current cell >\n row[accessor]\n \n )\n
  • \n
  • \n Use{ >\n formattedValue\n { }\n to access the valueFormatter output if one is defined, making it easy to wrap\n formatted text in custom components\n
  • \n
  • \n Use{ >\n row\n { >\n New in v1.9.7:\n { }\n Use{ >\n rowPath\n { }\n to access the path through nested data structures (e.g., [0, >\n row\n { }\n parameter passed to your cell renderer is a flat object containing all the row bg-gray-800 text-white p-4 rounded-md mb-6 overflow-x-auto shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] ,\n dealSize: 15000,\n isWon: true,\n category: ], // string array\n teamMembers: [ // object array\n { name: >\n To access a specific cell value, use{ >\n row[accessor]\n { >\n
  • Each column can have its own unique renderer
  • \n
  • Columns without a cellRenderer will display their values as plain text
  • \n
  • Avoid expensive operations in cell renderers as they run frequently
  • \n
  • Consider memoizing complex components to improve performance
  • \n
  • \n For simple text formatting, use{ >\n valueFormatter\n {", + "content": "For simple text formatting (currency, dates, percentages), consider using\n \n valueFormatter\n \n instead. It's more performant and easier to implement. Use\n \n cellRenderer\n \n when you need React components, custom styling, or interactive elements. Each column in your table can have its own\n \n cellRenderer\n \n function. This function receives information about the cell and returns either a ReactNode\n or a string to be rendered in the cell. The\n \n row\n \n parameter passed to your cell renderer is a flat object containing all the row's data: To access a specific cell value, use\n \n row[accessor]\n \n directly. The row object is flat and contains all the data for that row. Your\n \n cellRenderer\n \n function should return one of the following: Use\n \n value\n \n for quick access to the current cell's raw value (same as\n \n row[accessor]\n \n ) Use\n \n formattedValue\n \n to access the valueFormatter output if one is defined, making it easy to wrap\n formatted text in custom components Use\n \n row\n \n to access any data from the current row, not just the current cell - perfect for\n renderers that depend on multiple column values New in v1.9.7:\n \n Use\n \n rowPath\n \n to access the path through nested data structures (e.g., [0, \"teams\", 1] for\n rows[0].teams[1]) New in v1.9.7:\n \n \n formattedValue\n \n now supports string[], number[], and boolean types for more flexible formatting String: A simple text value to display in the cell\n \n return \"Hello, world!\"; ReactNode: A React component for custom rendering\n \n \n return <div className=\"flex items-center\"><span\n className=\"mr-2\">⭐</span> Custom Content</div>; Each column can have its own unique renderer Columns without a cellRenderer will display their values as plain text Avoid expensive operations in cell renderers as they run frequently Consider memoizing complex components to improve performance For simple text formatting, use\n \n valueFormatter\n \n instead for better performance HeaderObject.cellRenderer Custom function to render cell content with React components. Receives cell information and returns either a ReactNode or string for display. For simple text formatting (currency, dates, percentages), use valueFormatter instead for better performance. }>\n {status}\n \n );\n }\n} flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n For simple text formatting (currency, dates, percentages), consider using{ >\n valueFormatter\n { }\n instead. It >\n cellRenderer\n { >\n Each column in your table can have its own{ >\n
  • \n Use{ >\n value\n { }\n for quick access to the current cell >\n row[accessor]\n \n )\n
  • \n
  • \n Use{ >\n formattedValue\n { }\n to access the valueFormatter output if one is defined, making it easy to wrap\n formatted text in custom components\n
  • \n
  • \n Use{ >\n row\n { >\n New in v1.9.7:\n { }\n Use{ >\n rowPath\n { }\n to access the path through nested data structures (e.g., [0, >\n row\n { }\n parameter passed to your cell renderer is a flat object containing all the row text-gray-700 dark:text-gray-300 mb-4 >\n row[accessor]\n { >\n
  • Each column can have its own unique renderer
  • \n
  • Columns without a cellRenderer will display their values as plain text
  • \n
  • Avoid expensive operations in cell renderers as they run frequently
  • \n
  • Consider memoizing complex components to improve performance
  • \n
  • \n For simple text formatting, use{ >\n valueFormatter\n {", "section": "Cell Features", "headings": [ "Cell Renderer", @@ -177,8 +212,12 @@ "path": "/docs/chart-columns", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": "Chart columns are a powerful new feature that lets you visualize numeric array data\n inline, with smart copy/paste functionality that works seamlessly with spreadsheet\n applications. To add chart columns to your table, simply set the\n \n type\n \n property in your column definition to either\n \n \"lineAreaChart\"\n \n or\n \n \"barChart\"\n \n . The cell data should be an array of numbers. Line and area charts are perfect for showing trends over time. They work best with\n continuous data like daily metrics, monthly sales, or historical performance data. Bar charts excel at comparing discrete values across categories. They're ideal for\n quarterly data, weekly summaries, or any scenario where you need to compare distinct\n values. Chart columns include intelligent copy-paste behavior that makes working with array data\n seamless. Data is automatically formatted for human readability and spreadsheet\n compatibility. When copying chart columns, the array data is formatted as comma-separated values: Input: [10, 15, 12, 18,\n 25] Copied as: \"10, 15, 12,\n 18, 25\" This format is human-readable and pastes perfectly into Excel, Google Sheets, or any\n text editor. Values are tab-separated between columns for standard clipboard\n compatibility. When pasting into chart columns, comma-separated values are automatically parsed back\n into number arrays: Paste: \"10, 15, 12, 18,\n 25\" Parsed as: [10, 15, 12,\n 18, 25] When deleting chart column cells, they're cleared to an empty array: After delete: [] The chart component handles empty arrays gracefully by rendering nothing, keeping your\n table clean and consistent. Chart columns work seamlessly with Simple Table's\n \n live update functionality\n \n . You can update chart data in real-time while maintaining a fixed array length by adding\n new values and removing old ones. Check out the\n \n Infrastructure Example\n \n to see live-updating CPU history charts in action. Use the\n \n chartOptions\n \n property to customize the appearance and behavior of your chart columns. You can control\n dimensions, colors, scaling, and other visual aspects. Charts use theme-specific colors by default. You can override them with custom colors in\n \n chartOptions\n \n . The default theme colors are controlled by\n \n --st-chart-color\n \n and\n \n --st-chart-fill-color\n \n CSS variables. Non-numeric values are automatically converted to 0 Works seamlessly when copy/pasting chart data within the table Paste data from external sources like Excel or CSV files E-commerce Analytics: Display\n monthly sales trends, daily page views, and quarterly revenue comparisons for products. Server Monitoring: Visualize\n CPU usage history, memory trends, and network traffic patterns over time. Financial Dashboards: Show\n stock price movements, portfolio performance, and quarterly earnings trends. Marketing Reports: Track\n campaign performance, conversion rates, and engagement metrics across time periods. Operational Metrics: Monitor\n response times, request volumes, and error rates for microservices and APIs. min\n \n - Custom minimum value for chart scaling max\n \n - Custom maximum value for chart scaling width\n \n - Custom chart width in pixels (default: 100) height\n \n - Custom chart height in pixels (default: 30) color\n \n - Custom chart color (overrides theme color) fillColor\n \n - Custom fill color for area charts (overrides theme color) fillOpacity\n \n - Fill opacity for area charts (default: 0.2) strokeWidth\n \n - Line stroke width (default: 2) gap\n \n - Gap between bars in bar charts (default: 2) Keep arrays consistent: Use the same array length across all rows in a\n chart column for uniform visualization. Choose appropriate widths: Set column widths between 120-180px for\n optimal chart visibility without overwhelming the table. Use tooltips: Add descriptive tooltips to help users understand what\n the chart data represents. Center alignment: Charts typically look best with\n \n align: \"center\"\n \n . Line charts for trends: Use lineAreaChart for continuous data where the\n trend matters more than individual points. Bar charts for comparisons: Use barChart for discrete periods or\n categories where comparing individual values is important. Maintain fixed length: When updating chart data in real-time, keep the\n array length constant by removing old values as you add new ones. ,\n required: false,\n description: /docs/api-reference#column-type Monthly Sales (12mo) ,\n width: 150,\n align: Sales trend over the past 12 months ,\n },\n {\n key: ,\n width: 140,\n align: HeaderObject.chartOptions Customize the appearance and behavior of chart columns (lineAreaChart and barChart). Configure dimensions, colors, scaling, and rendering options. /docs/api-reference#chart-options ,\n width: 150,\n chartOptions: {\n min: 0,\n max: 100,\n width: 120,\n height: 35,\n color: ,\n fillOpacity: 0.3,\n strokeWidth: 2\n }\n}\n\n// Bar chart with custom styling\n{\n accessor: ,\n width: 140,\n chartOptions: {\n color: ,\n gap: 3,\n height: 40\n }\n} flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm mb-8 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >\n To add chart columns to your table, simply set the{ >\n type\n { }\n property in your column definition to either{ ,\n width: 180,\n type: ,\n },\n {\n accessor: ,\n width: 140,\n type: ,\n monthlySales: [150, 165, 142, 178, 195, 188, 203, 215, 198, 225, 240, 235],\n quarterlyRevenue: [45000, 52000, 48000, 61000],\n },\n {\n id: 2,\n product: ,\n width: 150,\n type: Daily page views for the past 30 days text-xl font-semibold text-gray-800 dark:text-white mb-3 mt-6 text-gray-700 dark:text-gray-300 mb-4 ,\n width: 130,\n type: Orders per week over the past 7 weeks text-2xl font-bold text-gray-800 dark:text-white mb-4 mt-8 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm text-lg font-semibold text-gray-800 dark:text-white mb-2 flex items-center gap-2 text-gray-700 dark:text-gray-300 mb-3 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-2 text-sm font-mono text-gray-700 dark:text-gray-300 text-gray-500 dark:text-gray-400 list-disc pl-5 space-y-1 text-gray-700 dark:text-gray-300 text-sm >\n Chart columns work seamlessly with Simple Table text-blue-600 dark:text-blue-400 hover:underline text-gray-700 dark:text-gray-300 mt-4 >\n Infrastructure Example\n { >\n Use the{ >\n chartOptions\n { // Line/area chart with custom options\n{\n accessor: ,\n width: 150,\n chartOptions: {\n min: 0, // Minimum value for scaling\n max: 100, // Maximum value for scaling\n width: 120, // Chart width in pixels\n height: 35, // Chart height in pixels\n color: , // Line color (overrides theme)\n fillColor: , // Area fill color\n fillOpacity: 0.3, // Fill opacity (0-1)\n strokeWidth: 2 // Line thickness\n }\n}\n\n// Bar chart with custom options\n{\n accessor: ,\n width: 140,\n chartOptions: {\n min: 0, // Minimum value for scaling\n max: 100000, // Maximum value for scaling\n width: 120, // Chart width in pixels\n height: 40, // Chart height in pixels\n color: , // Bar color (overrides theme)\n gap: 3 // Gap between bars in pixels\n }\n} >\n min\n { >\n max\n { >\n width\n { >\n height\n { >\n color\n { >\n fillColor\n { >\n fillOpacity\n { >\n strokeWidth\n { >\n gap\n { >\n Charts use theme-specific colors by default. You can override them with custom colors in{ >\n chartOptions\n \n . The default theme colors are controlled by{ >\n --st-chart-color\n { >\n --st-chart-fill-color\n { >\n align:", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "Chart columns are a powerful new feature that lets you visualize numeric array data\n inline, with smart copy/paste functionality that works seamlessly with spreadsheet\n applications. To add chart columns to your table, simply set the\n \n type\n \n property in your column definition to either\n \n \"lineAreaChart\"\n \n or\n \n \"barChart\"\n \n . The cell data should be an array of numbers. Line and area charts are perfect for showing trends over time. They work best with\n continuous data like daily metrics, monthly sales, or historical performance data. Bar charts excel at comparing discrete values across categories. They're ideal for\n quarterly data, weekly summaries, or any scenario where you need to compare distinct\n values. Chart columns include intelligent copy-paste behavior that makes working with array data\n seamless. Data is automatically formatted for human readability and spreadsheet\n compatibility. When copying chart columns, the array data is formatted as comma-separated values: Input: [10, 15, 12, 18,\n 25] Copied as: \"10, 15, 12,\n 18, 25\" This format is human-readable and pastes perfectly into Excel, Google Sheets, or any\n text editor. Values are tab-separated between columns for standard clipboard\n compatibility. When pasting into chart columns, comma-separated values are automatically parsed back\n into number arrays: Paste: \"10, 15, 12, 18,\n 25\" Parsed as: [10, 15, 12,\n 18, 25] When deleting chart column cells, they're cleared to an empty array: After delete: [] The chart component handles empty arrays gracefully by rendering nothing, keeping your\n table clean and consistent. Chart columns work seamlessly with Simple Table's\n \n live update functionality\n \n . You can update chart data in real-time while maintaining a fixed array length by adding\n new values and removing old ones. Check out the\n \n Infrastructure Example\n \n to see live-updating CPU history charts in action. Use the\n \n chartOptions\n \n property to customize the appearance and behavior of your chart columns. You can control\n dimensions, colors, scaling, and other visual aspects. Charts use theme-specific colors by default. You can override them with custom colors in\n \n chartOptions\n \n . The default theme colors are controlled by\n \n --st-chart-color\n \n and\n \n --st-chart-fill-color\n \n CSS variables. Non-numeric values are automatically converted to 0 Works seamlessly when copy/pasting chart data within the table Paste data from external sources like Excel or CSV files E-commerce Analytics: Display\n monthly sales trends, daily page views, and quarterly revenue comparisons for products. Server Monitoring: Visualize\n CPU usage history, memory trends, and network traffic patterns over time. Financial Dashboards: Show\n stock price movements, portfolio performance, and quarterly earnings trends. Marketing Reports: Track\n campaign performance, conversion rates, and engagement metrics across time periods. Operational Metrics: Monitor\n response times, request volumes, and error rates for microservices and APIs. min\n \n - Custom minimum value for chart scaling max\n \n - Custom maximum value for chart scaling width\n \n - Custom chart width in pixels (default: 100) height\n \n - Custom chart height in pixels (default: 30) color\n \n - Custom chart color (overrides theme color) fillColor\n \n - Custom fill color for area charts (overrides theme color) fillOpacity\n \n - Fill opacity for area charts (default: 0.2) strokeWidth\n \n - Line stroke width (default: 2) gap\n \n - Gap between bars in bar charts (default: 2) Keep arrays consistent: Use the same array length across all rows in a\n chart column for uniform visualization. Choose appropriate widths: Set column widths between 120-180px for\n optimal chart visibility without overwhelming the table. Use tooltips: Add descriptive tooltips to help users understand what\n the chart data represents. Center alignment: Charts typically look best with\n \n align: \"center\"\n \n . Line charts for trends: Use lineAreaChart for continuous data where the\n trend matters more than individual points. Bar charts for comparisons: Use barChart for discrete periods or\n categories where comparing individual values is important. Maintain fixed length: When updating chart data in real-time, keep the\n array length constant by removing old values as you add new ones. ,\n required: false,\n description: /docs/api-reference#column-type Monthly Sales (12mo) ,\n width: 150,\n align: Sales trend over the past 12 months ,\n },\n {\n key: ,\n width: 140,\n align: HeaderObject.chartOptions Customize the appearance and behavior of chart columns (lineAreaChart and barChart). Configure dimensions, colors, scaling, and rendering options. /docs/api-reference#chart-options ,\n width: 150,\n chartOptions: {\n min: 0,\n max: 100,\n width: 120,\n height: 35,\n color: ,\n fillOpacity: 0.3,\n strokeWidth: 2\n }\n}\n\n// Bar chart with custom styling\n{\n accessor: ,\n width: 140,\n chartOptions: {\n color: ,\n gap: 3,\n height: 40\n }\n} flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm mb-8 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >\n To add chart columns to your table, simply set the{ >\n type\n { }\n property in your column definition to either{ >\n Bar charts excel at comparing discrete values across categories. They text-2xl font-bold text-gray-800 dark:text-white mb-4 mt-8 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 mb-4 text-gray-700 dark:text-gray-300 text-sm text-lg font-semibold text-gray-800 dark:text-white mb-2 flex items-center gap-2 text-gray-700 dark:text-gray-300 mb-3 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-2 text-sm font-mono text-gray-700 dark:text-gray-300 text-gray-500 dark:text-gray-400 list-disc pl-5 space-y-1 text-gray-700 dark:text-gray-300 text-sm >\n Chart columns work seamlessly with Simple Table text-blue-600 dark:text-blue-400 hover:underline text-gray-700 dark:text-gray-300 mt-4 >\n Infrastructure Example\n { >\n Use the{ >\n chartOptions\n { >\n min\n { >\n max\n { >\n width\n { >\n height\n { >\n color\n { >\n fillColor\n { >\n fillOpacity\n { >\n strokeWidth\n { >\n gap\n { >\n Charts use theme-specific colors by default. You can override them with custom colors in{ >\n chartOptions\n \n . The default theme colors are controlled by{ >\n --st-chart-color\n { >\n --st-chart-fill-color\n { >\n align:", "section": "Documentation", "headings": [ "Chart Columns", @@ -209,9 +248,14 @@ "typescript table", "space optimization" ], - "content": "Collapsible columns are created by adding the\n \n collapsible: true\n \n property to a parent column with\n \n children\n \n columns. Users can click the arrow icon in the header to collapse the column group. Collapsible columns support different behaviors when collapsed: Control when child columns are visible using the\n showWhen\n attribute: By default, collapsible columns create a nested header structure where the parent header\n appears above its children. Using\n \n singleRowChildren: true\n \n , you can display the parent header beside its children in the same row, making it appear\n as a regular column that collapses adjacent columns. You can customize the appearance of collapsible columns using CSS classes and variables.\n This allows you to style sub-headers and sub-cells differently from regular columns. Targets child header cells within collapsible column groups. Targets body cells within collapsible column groups. Use these CSS variables to customize the background colors of sub-headers and sub-cells.\n See the\n \n Custom Theme\n \n documentation for more details. New in v1.8.6: Controls hover state for sub-cells New in v1.8.6: Controls background color when dragging sub-headers New in v1.8.6: Controls background color for selected sub-cells New in v1.8.6: Controls text color for selected sub-cells ,\n required: false,\n description: ,\n collapsible: true,\n children: [\n { accessor: },\n { accessor: }\n ]\n }\n ]}\n // ... other props\n/> ,\n collapsible: true,\n collapseDefault: true, // Starts collapsed\n children: [\n { accessor: },\n { accessor: ,\n },\n {\n key: ,\n collapsible: true,\n singleRowChildren: true, // Parent header appears beside children (not above)\n children: [\n { accessor: , width: 120 },\n { accessor: , width: 200 }\n ]\n}\n// With singleRowChildren: true\n// Headers: [Personal Info] [First Name] [Last Name] [Email] (side-by-side)\n// Without singleRowChildren (default nested structure)\n// Headers: [Personal Info spanning full width]\n// [First Name] [Last Name] [Email] (children below parent) (only visible when collapsed), (only visible when expanded), or (visible in both states). Note: This is an alternative approach to singleRowChildren. ,\n collapsible: true,\n children: [\n {\n accessor: // Visible in both states\n },\n {\n accessor: // Only when collapsed\n },\n {\n accessor: // Only when expanded\n }\n ]\n} />}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Collapsible columns are created by adding the{ >\n collapsible: true\n { }\n property to a parent column with{ >\n children\n { >\n Control when child columns are visible using the{ >\n showWhen: >\n By default, collapsible columns create a nested header structure where the parent header\n appears above its children. Using{ >\n Use these CSS variables to customize the background colors of sub-headers and sub-cells.\n See the{ >\n Custom Theme\n {", + "content": "Collapsible columns are created by adding the\n \n collapsible: true\n \n property to a parent column with\n \n children\n \n columns. Users can click the arrow icon in the header to collapse the column group. Collapsible columns support different behaviors when collapsed: Control when child columns are visible using the\n showWhen\n attribute: By default, collapsible columns create a nested header structure where the parent header\n appears above its children. Using\n \n singleRowChildren: true\n \n , you can display the parent header beside its children in the same row, making it appear\n as a regular column that collapses adjacent columns. You can customize the appearance of collapsible columns using CSS classes and variables.\n This allows you to style sub-headers and sub-cells differently from regular columns. Targets child header cells within collapsible column groups. Targets body cells within collapsible column groups. Use these CSS variables to customize the background colors of sub-headers and sub-cells.\n See the\n \n Custom Theme\n \n documentation for more details. New in v1.8.6: Controls hover state for sub-cells New in v1.8.6: Controls background color when dragging sub-headers New in v1.8.6: Controls background color for selected sub-cells New in v1.8.6: Controls text color for selected sub-cells ,\n required: false,\n description: ,\n collapsible: true,\n children: [\n { accessor: },\n { accessor: }\n ]\n }\n ]}\n // ... other props\n/> ,\n collapsible: true,\n collapseDefault: true, // Starts collapsed\n children: [\n { accessor: },\n { accessor: ,\n },\n {\n key: ,\n collapsible: true,\n singleRowChildren: true, // Parent header appears beside children (not above)\n children: [\n { accessor: , width: 120 },\n { accessor: , width: 200 }\n ]\n}\n// With singleRowChildren: true\n// Headers: [Personal Info] [First Name] [Last Name] [Email] (side-by-side)\n// Without singleRowChildren (default nested structure)\n// Headers: [Personal Info spanning full width]\n// [First Name] [Last Name] [Email] (children below parent) (only visible when collapsed), (only visible when expanded), or (visible in both states). Note: This is an alternative approach to singleRowChildren. ,\n collapsible: true,\n children: [\n {\n accessor: // Visible in both states\n },\n {\n accessor: // Only when collapsed\n },\n {\n accessor: // Only when expanded\n }\n ]\n} />\n }}\n // ... other props\n/> icons.headerCollapse Custom icon component for the collapse state of collapsible column headers. Shows when a column group can be collapsed to hide child columns. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Collapsible columns are created by adding the{ >\n collapsible: true\n { }\n property to a parent column with{ >\n children\n { >\n Control when child columns are visible using the{ >\n showWhen: >\n By default, collapsible columns create a nested header structure where the parent header\n appears above its children. Using{ >\n Use these CSS variables to customize the background colors of sub-headers and sub-cells.\n See the{ >\n Custom Theme\n {", "section": "Column Features", - "headings": ["Collapsible Columns", "Visibility Control", "CSS Classes", "CSS Variables"] + "headings": [ + "Collapsible Columns", + "Visibility Control", + "CSS Classes", + "CSS Variables" + ] }, { "id": "column-alignment", @@ -258,9 +302,16 @@ "typescript table", "table customization" ], - "content": "To enable column editing in Simple Table, you can use several approaches: Enable column selection with\n \n selectableColumns=\n \n (required for column editing to work) Enable header editing with\n \n enableHeaderEditing= Provide an\n \n onHeaderEdit\n \n callback to handle header changes Use\n \n headerRenderer\n \n to add custom controls like \"Add Column\" buttons Manage column state dynamically to add or remove columns ,\n required: true,\n description: ,\n required: false,\n description: HeaderObject.headerRenderer Custom renderer for column headers. Can be used to add buttons, dropdowns, or other interactive elements to column headers. React.ComponentType flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Enable column selection with{ }\n (required for column editing to work)\n
  • \n
  • \n Enable header editing with{ >\n onHeaderEdit\n { }\n callback to handle header changes\n
  • \n
  • \n Use{ >\n headerRenderer\n { }\n to add custom controls like", + "content": "To enable column editing in Simple Table, you can use several approaches: The\n \n columnEditorConfig\n \n prop covers search, optional pin controls, custom layout, and more — see\n \n ColumnEditorConfig\n \n in the API Reference. The column editor includes built-in search functionality that allows users to quickly find\n columns by typing in the search box. The search automatically expands nested headers to\n show matching columns. Tip: Search is enabled by\n default. Set\n \n searchEnabled: false\n \n in\n \n columnEditorConfig\n \n to disable it. Users can reorder columns by dragging them within the column editor. A visual separator\n line shows where the column will be dropped. This works alongside the existing header\n drag-and-drop functionality. Note: Drag-and-drop in\n the column editor is automatically enabled when\n \n editColumns=\n \n . No additional configuration needed! Simple Table provides powerful API methods for programmatically controlling column\n visibility and the column editor menu. These methods are available through the table ref\n and enable you to build custom column visibility controls, presets, and views. Opens, closes, or toggles the column editor menu programmatically. This gives you full\n control over when the column editor UI is displayed. Programmatically controls which columns are visible in the table. You can pass a partial\n or complete visibility state object to show or hide specific columns. This method is async\n and returns a Promise. Tip: You can combine these\n methods to create powerful column visibility workflows. For example, open the column\n editor programmatically and apply a preset view at the same time. Enable column selection with\n \n selectableColumns=\n \n (required for column editing to work) Enable the column editor with\n \n editColumns=\n \n to show/hide columns and reorder them via drag-and-drop Configure the column editor with\n \n columnEditorConfig\n \n to customize button text, enable search, and more Enable header editing with\n \n enableHeaderEditing= Provide an\n \n onHeaderEdit\n \n callback to handle header changes Use\n \n headerRenderer\n \n to add custom controls like \"Add Column\" buttons Manage column state dynamically to add or remove columns View Presets: Create predefined column visibility configurations like\n \"Basic Info\", \"Contact Details\", \"Financial View\", etc. User Preferences: Save and restore user-specific column visibility\n preferences across sessions Responsive Layouts: Automatically hide less important columns on\n smaller screens Guided Tours: Control column visibility during onboarding or tutorial\n flows Context-Aware Views: Show different columns based on user role,\n permissions, or current workflow ,\n required: true,\n description: ,\n required: false,\n description: and the new label. Angular, Svelte, and Solid adapters use HeaderObject.headerRenderer Custom renderer for column headers. Can be used to add buttons, dropdowns, or other interactive elements to column headers. React.ComponentType flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Enable column selection with{ }\n (required for column editing to work)\n
  • \n
  • \n Enable the column editor with{ }\n to show/hide columns and reorder them via drag-and-drop\n
  • \n
  • \n Configure the column editor with{ >\n columnEditorConfig\n { }\n to customize button text, enable search, and more\n
  • \n
  • \n Enable header editing with{ >\n onHeaderEdit\n { }\n callback to handle header changes\n
  • \n
  • \n Use{ >\n headerRenderer\n { }\n to add custom controls like >\n columnEditorConfig\n { }\n prop covers search, optional pin controls, custom layout, and more — see{ >\n ColumnEditorConfig\n { >Tip: Search is enabled by\n default. Set{ >\n searchEnabled: false\n { >Note: Drag-and-drop in\n the column editor is automatically enabled when{ >\n
  • \n View Presets: Create predefined column visibility configurations like", "section": "Column Features", - "headings": ["Column Editing"] + "headings": [ + "Column Editing", + "Column Search", + "Drag and Drop Column Reordering", + "toggleColumnEditor()", + "applyColumnVisibility()", + "Use Cases" + ] }, { "id": "column-filtering", @@ -282,7 +333,9 @@ ], "content": "Column filtering is enabled by adding the\n \n filterable: true\n \n property to individual column headers. Each column can be independently configured for\n filtering based on its data type. For advanced use cases, you can handle filtering externally - perfect for server-side\n filtering, API integration, or custom filtering logic. This demo shows how to manage\n filtering completely outside the table component with diverse data types and locations. External filtering provides two key benefits: For enum columns with more than 10 options, Simple Table automatically provides a search\n input to help users quickly find and select the desired enum values. This improves\n usability when dealing with large sets of enum options. API Integration: Use\n \n onFilterChange\n \n to trigger server-side filtering while keeping the table's UI filter controls. Complete Control: Use\n \n externalFilterHandling=\n \n to disable all internal filtering and provide your own pre-filtered data. HeaderObject.filterable Enable filtering for a specific column. Each column can be independently configured for filtering based on its data type. Simple Table provides intelligent filtering with different operators for each data type. // String column with filtering (8 operators)\n{ \n accessor: ,\n filterable: true \n}\n\n// Number column with filtering (10 operators)\n{ \n accessor: ,\n filterable: true \n}\n\n// Date column with filtering (8 operators)\n{ \n accessor: ,\n filterable: true \n}\n\n// Boolean column with filtering (3 operators)\n{ \n accessor: ,\n filterable: true \n}\n\n// Enum column with filtering (4 operators)\n// When more than 10 options, search input appears automatically\n{ \n accessor: ,\n filterable: true,\n enumOptions: [\n { label: ,\n required: false,\n description: /docs/api-reference#table-filter-state , filters);\n // Make API call with filter parameters\n // filters is an object where keys are unique filter IDs\n // and values are FilterCondition objects\n}} externalFilterHandling When true, completely disables internal filtering logic. The table will not filter data internally - you must provide pre-filtered data via the rows prop. flex items-center gap-3 mb-6 p-2 bg-green-100 rounded-lg text-green-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Column filtering is enabled by adding the{ >\n filterable: true\n { >\n
  • \n API Integration: Use{ >\n onFilterChange\n { }\n to trigger server-side filtering while keeping the table", "section": "Column Features", - "headings": ["Column Filtering"] + "headings": [ + "Column Filtering" + ] }, { "id": "column-pinning", @@ -301,9 +354,11 @@ "typescript table", "table navigation" ], - "content": "To pin columns, simply add the\n \n pinned\n \n property to your header objects: ,\n required: false,\n description: /docs/api-reference#union-types // Pin to left side\n{ \n accessor: ,\n width: 80\n}\n\n// Pin to right side\n{ \n accessor: >\n To pin columns, simply add the{ >\n pinned\n {", + "content": "To pin columns, simply add the\n \n pinned\n \n property to your header objects: ,\n required: false,\n description: /docs/api-reference#union-types // Pin to left side\n{ \n accessor: ,\n width: 80\n}\n\n// Pin to right side\n{ \n accessor: >\n To pin columns, simply add the{ >\n pinned\n { >\n isEssential\n \n { >\n tableRef.getPinnedState()\n { >\n tableRef.applyPinnedState(...)\n {", "section": "Column Features", - "headings": ["Column Pinning"] + "headings": [ + "Column Pinning" + ] }, { "id": "column-reordering", @@ -322,9 +377,13 @@ "typescript table", "user personalization" ], - "content": "Column reordering is enabled by adding the\n \n columnReordering\n \n prop to the SimpleTable component. Users can drag and drop column headers to rearrange\n them. The\n \n onColumnOrderChange\n \n callback is useful for saving user preferences. You can store the new column order in\n localStorage or in your backend to persist the user's preferred layout across sessions. Column reordering works seamlessly with other Simple Table features. You can combine it\n with column resizing, column visibility, and pinned columns to create a highly\n customizable table experience. When using column reordering with pinned columns, pinned columns will remain in their\n respective pinned areas (left or right) but can be reordered within those areas. ,\n required: false,\n description: , newHeaders);\n // Save to localStorage or backend\n localStorage.setItem( , JSON.stringify(newHeaders));\n }}\n // ... other props\n/> HeaderObject.disableReorder Prevents specific columns from being reordered. Set this on individual header objects to lock them in place. // This column cannot be reordered\n{ \n accessor: , \n disableReorder: true \n}\n\n// This column can be reordered normally\n{ \n accessor: >\n Column reordering is enabled by adding the{ >\n columnReordering\n { >\n onColumnOrderChange\n { }\n callback is useful for saving user preferences. You can store the new column order in\n localStorage or in your backend to persist the user text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 mb-4 bg-green-50 dark:bg-green-900/30 border-l-4 border-green-400 dark:border-green-700 p-4 rounded-lg shadow-sm font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300", + "content": "Column reordering is enabled by adding the\n \n columnReordering\n \n prop to the SimpleTable component. Users can drag and drop column headers to rearrange\n them. The\n \n onColumnOrderChange\n \n callback is useful for saving user preferences. You can store the new column order in\n localStorage or in your backend to persist the user's preferred layout across sessions. Column reordering works seamlessly with other Simple Table features. You can combine it\n with column resizing, column visibility, and pinned columns to create a highly\n customizable table experience. When using column reordering with pinned columns, pinned columns will remain in their\n respective pinned areas (left or right) but can be reordered within those areas. ,\n required: false,\n description: . Angular, Svelte, and Solid adapters use SvelteHeaderObject[] , newHeaders);\n // Save to localStorage or backend\n localStorage.setItem( , JSON.stringify(newHeaders));\n }}\n // ... other props\n/> HeaderObject.disableReorder Prevents specific columns from being reordered. Set this on individual header objects to lock them in place. // This column cannot be reordered\n{ \n accessor: , \n disableReorder: true \n}\n\n// This column can be reordered normally\n{ \n accessor: >\n Column reordering is enabled by adding the{ >\n columnReordering\n { >\n onColumnOrderChange\n { }\n callback is useful for saving user preferences. You can store the new column order in\n localStorage or in your backend to persist the user text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 mb-4 bg-green-50 dark:bg-green-900/30 border-l-4 border-green-400 dark:border-green-700 p-4 rounded-lg shadow-sm font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300", "section": "Column Features", - "headings": ["Column Reordering", "Tip", "Best Practice"] + "headings": [ + "Column Reordering", + "Tip", + "Best Practice" + ] }, { "id": "column-resizing", @@ -343,9 +402,14 @@ "typescript table", "responsive table" ], - "content": "Column resizing is enabled by adding the\n \n columnResizing\n \n prop to the SimpleTable component. Users can resize columns by dragging the column\n dividers in the header row. ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Column resizing is enabled by adding the{ >\n columnResizing\n {", + "content": "Try resizing columns by dragging the dividers or double-clicking them to auto-fit. Your\n column widths are automatically saved to localStorage and will persist when you refresh\n the page! Column resizing is enabled by adding the\n \n columnResizing\n \n prop to the SimpleTable component. Users can resize columns by dragging the column\n dividers in the header row. When column resizing is enabled, users can double-click on any resize handle to\n automatically fit that column to its content width. This provides a quick way to optimize\n column sizes without manual dragging. The auto-size feature calculates the optimal width based on the column's content,\n including both the header text and cell values. This is especially useful for columns\n with varying content lengths. Use the\n \n onColumnWidthChange\n \n callback to save user column width preferences. This callback is triggered whenever\n columns are resized (either by dragging or double-clicking) and receives the updated\n headers array with new width values. When\n \n autoExpandColumns\n \n is enabled, the resize handle is removed from the last column since all columns scale\n proportionally to fill the container width. ,\n required: false,\n description: . Angular, Svelte, and Solid adapters use SvelteHeaderObject[] /docs/api-reference#simple-table-props , JSON.stringify(widths));\n }}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Column resizing is enabled by adding the{ >\n columnResizing\n { >\n The auto-size feature calculates the optimal width based on the column text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 mt-8 text-gray-700 dark:text-gray-300 mb-4 >\n onColumnWidthChange\n { >\n autoExpandColumns\n {", "section": "Column Features", - "headings": ["Column Resizing"] + "headings": [ + "Column Resizing", + "Interactive Demo", + "Tip", + "Note" + ] }, { "id": "column-selection", @@ -365,9 +429,11 @@ "typescript table", "column interaction" ], - "content": "To enable column selection in Simple Table, you need to: Set the\n \n selectableColumns\n \n prop to\n \n true Provide an\n \n onColumnSelect\n \n callback function to handle selection events ,\n required: false,\n description: , header.label);\n console.log( , header.accessor);\n console.log( flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Set the{ >\n selectableColumns\n { }\n prop to{ >\n true\n \n
  • \n
  • \n Provide an{ >\n onColumnSelect\n {", + "content": "To enable column selection in Simple Table, you need to: Set the\n \n selectableColumns\n \n prop to\n \n true Provide an\n \n onColumnSelect\n \n callback function to handle selection events ,\n required: false,\n description: . Angular, Svelte, and Solid adapters use , header.label);\n console.log( , header.accessor);\n console.log( flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Set the{ >\n selectableColumns\n { }\n prop to{ >\n true\n \n
  • \n
  • \n Provide an{ >\n onColumnSelect\n {", "section": "Column Features", - "headings": ["Column Selection"] + "headings": [ + "Column Selection" + ] }, { "id": "column-sorting", @@ -386,9 +452,13 @@ "typescript table", "data management" ], - "content": "To enable sorting for a column, add the\n \n isSortable: true\n \n property to your column definition. Accessors now support nested array paths using bracket notation. This allows you to sort\n by specific array elements without writing custom logic. For complex sorting scenarios, Simple Table provides two powerful options: Use\n \n comparator\n \n when you need to sort based on multiple fields, row metadata, or custom business logic: Use\n \n valueGetter\n \n to extract nested values or compute values for sorting, especially when displaying\n formatted text: Use comparator when you need full control over the sorting logic with\n access to both rows. Use valueGetter when you want the default sorting\n behavior but need to extract or compute the value first. Set the table to load with a default sort applied using\n \n initialSortColumn\n \n and\n \n initialSortDirection\n \n . This is perfect for showing users the most relevant data first, like sorting by date\n (newest first) or revenue (highest first). The table will load with the sort applied, and users can still change the sort by clicking\n column headers. For advanced use cases, you can handle sorting externally - perfect for server-side\n sorting, API integration, or custom sorting logic. This demo shows how to manage sorting\n completely outside the table component. External sorting provides two key benefits: awards[0] -\n Sort by first award albums[0].title\n \n - Sort by first album's title releaseDate[0]\n \n - Sort by first release date Sort by multiple fields with priority (e.g., priority level + performance score) Access metadata or related fields not directly in the column Implement domain-specific sorting rules (e.g., custom status ordering) Sort by computed values derived from multiple row properties Sort by nested/computed values while displaying formatted text Access deeply nested properties (e.g., row.metadata.seniorityLevel) Calculate derived values for sorting operations Combine with valueFormatter to separate sorting logic from display Sort by date to show newest records first Sort by revenue/sales to highlight top performers Sort by priority or status to show critical items first Provide a consistent, predictable initial view for users API Integration: Use\n \n onSortChange\n \n to trigger server-side sorting while keeping the table's UI sorting indicators. Complete Control: Use\n \n externalSortHandling=\n \n to disable all internal sorting and provide your own pre-sorted data. HeaderObject.isSortable Enables sorting functionality for the column. When true, users can click the column header to sort data. , \n isSortable: true \n} HeaderObject.comparator Custom sorting function based on row-level metadata or complex logic. Receives full row objects and sort direction, allowing you to sort by multiple fields, nested properties, or domain-specific rules. /docs/api-reference#comparator-props ? rowA.priority - rowB.priority \n : rowB.priority - rowA.priority;\n }\n return rowB.metadata.score - rowA.metadata.score;\n }\n} HeaderObject.valueGetter Function to extract values from nested objects or compute values dynamically for sorting operations. Useful when the displayed value differs from the sorting value, or when sorting by deeply nested properties. /docs/api-reference#value-getter-props ,\n required: false,\n description: ,\n },\n {\n key: initialSortDirection Sets the sort direction for the initial sort. Defaults to /docs/api-reference#sort-config );\n // Make API call with sort parameters\n }\n}} externalSortHandling When true, completely disables internal sorting logic. The table will not sort data internally - you must provide pre-sorted data via the rows prop. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n To enable sorting for a column, add the{ >\n isSortable: true\n { >\n albums[0].title\n { }\n - Sort by first album bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded >\n comparator\n { >\n valueGetter\n { >\n Set the table to load with a default sort applied using{ >\n initialSortColumn\n { // Show highest revenue first\n // ... other props\n/> text-gray-700 dark:text-gray-300 mb-4 text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 >\n
  • \n API Integration: Use{ >\n onSortChange\n { }\n to trigger server-side sorting while keeping the table", + "content": "To enable sorting for a column, add the\n \n isSortable: true\n \n property to your column definition. Accessors now support nested array paths using bracket notation. This allows you to sort\n by specific array elements without writing custom logic. Customize the sort cycle for individual columns using the\n \n sortingOrder\n \n property. This allows different columns to have different sort behaviors based on their\n data type and expected user interaction patterns. For complex sorting scenarios, Simple Table provides two powerful options: Use\n \n comparator\n \n when you need to sort based on multiple fields, row metadata, or custom business logic: Use\n \n valueGetter\n \n to extract nested values or compute values for sorting, especially when displaying\n formatted text: Use comparator when you need full control over the sorting logic with\n access to both rows. Use valueGetter when you want the default sorting\n behavior but need to extract or compute the value first. Set the table to load with a default sort applied using\n \n initialSortColumn\n \n and\n \n initialSortDirection\n \n . This is perfect for showing users the most relevant data first, like sorting by date\n (newest first) or revenue (highest first). The table will load with the sort applied, and users can still change the sort by clicking\n column headers. For advanced use cases, you can handle sorting externally - perfect for server-side\n sorting, API integration, or custom sorting logic. This demo shows how to manage sorting\n completely outside the table component. External sorting provides two key benefits: awards[0] -\n Sort by first award albums[0].title\n \n - Sort by first album's title releaseDate[0]\n \n - Sort by first release date Numbers/Dates: Use\n \n ['desc', 'asc', null]\n \n to show highest values or most recent dates first Text/Names: Use\n \n ['asc', 'desc', null]\n \n (default) for alphabetical sorting Always Sorted: Use\n \n ['asc', 'desc']\n \n to prevent removing sort (no null state) Single Direction: Use\n \n ['desc', null]\n \n to toggle between descending and no sort Sort by multiple fields with priority (e.g., priority level + performance score) Access metadata or related fields not directly in the column Implement domain-specific sorting rules (e.g., custom status ordering) Sort by computed values derived from multiple row properties Sort by nested/computed values while displaying formatted text Access deeply nested properties (e.g., row.metadata.seniorityLevel) Calculate derived values for sorting operations Combine with valueFormatter to separate sorting logic from display Sort by date to show newest records first Sort by revenue/sales to highlight top performers Sort by priority or status to show critical items first Provide a consistent, predictable initial view for users API Integration: Use\n \n onSortChange\n \n to trigger server-side sorting while keeping the table's UI sorting indicators. Complete Control: Use\n \n externalSortHandling=\n \n to disable all internal sorting and provide your own pre-sorted data. HeaderObject.isSortable Enables sorting functionality for the column. When true, users can click the column header to sort data. , \n isSortable: true \n} HeaderObject.sortingOrder Custom sort order cycle for this column. Defines the sequence of sort states when clicking the column header. Default is [ , null] which cycles through ascending → descending → no sort. Customize per column based on data type - use [ , null] for numbers/dates where descending is more common. // Numbers/dates - descending first\n{ \n accessor: , \n isSortable: true,\n sortingOrder: [ , null]\n}\n\n// Text - ascending first (default)\n{ \n accessor: , null]\n}\n\n// Always keep sort active (no null state)\n{ \n accessor: ,\n },\n {\n key: HeaderObject.comparator Custom sorting function based on row-level metadata or complex logic. Receives full row objects and sort direction, allowing you to sort by multiple fields, nested properties, or domain-specific rules. /docs/api-reference#comparator-props ? rowA.priority - rowB.priority \n : rowB.priority - rowA.priority;\n }\n return rowB.metadata.score - rowA.metadata.score;\n }\n} HeaderObject.valueGetter Function to extract values from nested objects or compute values dynamically for sorting operations. Useful when the displayed value differs from the sorting value, or when sorting by deeply nested properties. /docs/api-reference#value-getter-props ,\n required: false,\n description: initialSortDirection Sets the sort direction for the initial sort. Defaults to /docs/api-reference#sort-config );\n // Make API call with sort parameters\n }\n}} externalSortHandling When true, completely disables internal sorting logic. The table will not sort data internally - you must provide pre-sorted data via the rows prop. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n To enable sorting for a column, add the{ >\n isSortable: true\n { >\n albums[0].title\n { }\n - Sort by first album bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded >\n Customize the sort cycle for individual columns using the{ >\n sortingOrder\n { >\n
  • \n Numbers/Dates: Use{ >\n ['desc', 'asc', null]\n { }\n to show highest values or most recent dates first\n
  • \n
  • \n Text/Names: Use{ >\n ['asc', 'desc', null]\n { }\n (default) for alphabetical sorting\n
  • \n
  • \n Always Sorted: Use{ >\n ['asc', 'desc']\n { }\n to prevent removing sort (no null state)\n
  • \n
  • \n Single Direction: Use{ >\n ['desc', null]\n { >\n comparator\n { >\n valueGetter\n { >\n Set the table to load with a default sort applied using{ >\n initialSortColumn\n { >\n
  • \n API Integration: Use{ >\n onSortChange\n { }\n to trigger server-side sorting while keeping the table", "section": "Column Features", - "headings": ["Column Sorting", "Custom Comparator", "Value Getter"] + "headings": [ + "Column Sorting", + "Custom Comparator", + "Value Getter" + ] }, { "id": "column-visibility", @@ -407,9 +477,11 @@ "typescript table", "data focus" ], - "content": "Column visibility can be controlled using the\n \n hide\n \n property in the header objects and the\n \n editColumns\n \n prop on the SimpleTable component. ,\n required: false,\n description: // Hidden by default\n{ \n accessor: , \n hide: true \n}\n\n// Visible by default (default behavior)\n{ \n accessor: ,\n },\n {\n key: flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Column visibility can be controlled using the{ >\n hide\n { }\n property in the header objects and the{ >\n editColumns\n {", + "content": "Column visibility can be controlled using the\n \n hide\n \n property in the header objects and the\n \n editColumns\n \n prop on the SimpleTable component. customRenderer\n \n replaces the default popout. When the table uses left and right pin regions, the built-in\n layout may show left-pinned, main, and right-pinned columns as separate lists. Set\n \n allowColumnPinning: false\n \n on\n \n columnEditorConfig\n \n to hide pin controls while keeping drag and visibility toggles (\n \n ColumnEditorConfig\n \n ). Besides\n \n searchSection\n \n ,\n \n listSection\n \n , and\n \n resetColumns\n \n , you can receive\n \n pinnedLeftList\n \n ,\n \n unpinnedList\n \n , and\n \n pinnedRightList\n \n when the UI is split by pin section. rowRenderer\n \n controls each row’s layout; props include\n \n panelSection\n \n ,\n \n isEssential\n \n ,\n \n canToggleVisibility\n \n ,\n \n allowColumnPinning\n \n , and\n \n pinControl\n \n . See\n \n ColumnEditorRowRendererProps\n \n . ,\n required: false,\n description: // Hidden by default\n{ \n accessor: , \n hide: true \n}\n\n// Visible by default (default behavior)\n{ \n accessor: ,\n },\n {\n key: onColumnVisibilityChange , visibilityState);\n // Example: { name: true, email: true, phone: false }\n \n // Save to localStorage\n localStorage.setItem( , \n JSON.stringify(visibilityState)\n );\n }}\n // ... other props\n/> HeaderObject.excludeFromRender When true, excludes the column from both the rendered table and the column editor. The column is still included in CSV exports. Useful for ID columns or metadata that should be exported but not displayed or toggled by users. ,\n width: 80,\n excludeFromRender: true // Hidden from table and visibility menu\n} flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Column visibility can be controlled using the{ >\n hide\n { }\n property in the header objects and the{ >\n editColumns\n { >\n customRenderer\n { }\n replaces the default popout. When the table uses left and right pin regions, the built-in\n layout may show left-pinned, main, and right-pinned columns as separate lists. Set{ >\n allowColumnPinning: false\n { >\n columnEditorConfig\n { >\n ColumnEditorConfig\n \n ). Besides{ >\n searchSection\n \n ,{ >\n listSection\n \n , and{ >\n resetColumns\n \n , you can receive{ >\n pinnedLeftList\n \n ,{ >\n unpinnedList\n \n , and{ >\n pinnedRightList\n { >\n rowRenderer\n { }\n controls each row’s layout; props include{ >\n panelSection\n \n ,{ >\n isEssential\n \n ,{ >\n canToggleVisibility\n \n ,{ >\n allowColumnPinning\n \n , and{ >\n pinControl\n \n . See{", "section": "Column Features", - "headings": ["Column Visibility"] + "headings": [ + "Column Visibility" + ] }, { "id": "column-width", @@ -433,9 +505,16 @@ "responsive table", "adaptive columns" ], - "content": "The width\n property is required for every column and accepts two types of values: When you set\n width: \"1fr\",\n the column becomes flexible and shares the available space with other auto-sizing columns: If your table is 1000px wide with: Columns B and C each get: (1000px - 100px - 150px) / 2 = 375px each Auto-sizing columns automatically adapt when: Auto-sizing works great with column resizing! When users manually resize a column, the\n other auto-sizing columns automatically adjust to fill or use the freed space. Try it in\n the demo above by enabling column resizing. The table calculates total available space after subtracting all fixed-width columns Remaining space is divided equally among all columns with\n \n width: \"1fr\" Each auto-sizing column gets an equal share of the available space The\n \n minWidth\n \n property ensures columns don't shrink below a specified size Column A:\n \n width: 100\n \n (fixed) Column B:\n \n width: \"1fr\"\n \n (auto) Column C:\n \n width: \"1fr\"\n \n (auto) Column D:\n \n width: 150\n \n (fixed) The table container is resized Columns are hidden or shown Columns are reordered The viewport size changes ,\n required: true,\n description: for auto-sizing columns that share available space proportionally. /docs/api-reference#header-object // Fixed width in pixels\n{ \n accessor: , \n width: 60 \n}\n\n// Auto-sizing with ,\n },\n {\n key: HeaderObject.minWidth Sets the minimum width constraint for auto-sizing columns (those using ). Prevents columns from becoming too narrow when space is limited. // Auto-sizing column with minimum width\n{ \n accessor: ,\n minWidth: 200 // Won >\n When you set{ >\n
  • \n The table calculates total available space after subtracting all fixed-width columns\n
  • \n
  • \n Remaining space is divided equally among all columns with{ >\n width: \n
  • \n
  • Each auto-sizing column gets an equal share of the available space
  • \n
  • \n The{ >\n minWidth\n { }\n property ensures columns don bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-lg font-semibold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 mb-2 list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1 mb-2 >\n width: 100\n { }\n (fixed)\n
  • \n
  • \n Column B:{ { }\n (auto)\n
  • \n
  • \n Column C:{ }\n (auto)\n
  • \n
  • \n Column D:{ >\n width: 150\n {", + "content": "The\n \n autoExpandColumns\n \n prop makes your table columns automatically fill the entire container width by scaling all\n column widths proportionally. When enabled, the resize handle is removed from the last\n column since all columns scale together to maintain full width: Use\n \n autoExpandColumns\n \n when you want your table to always fill its container width with no horizontal\n scrolling. This is perfect for dashboards, reports, and full-width layouts where the\n table should adapt to any container size. Note: On mobile devices, it's recommended to set\n \n autoExpandColumns=\"}\n \n as horizontal scrolling typically provides a better user experience than cramped columns\n on small screens. The width\n property is required for every column and accepts two types of values: When you set\n width: \"1fr\",\n the column becomes flexible and shares the available space with other auto-sizing columns: If your table is 1000px wide with: Columns B and C each get: (1000px - 100px - 150px) / 2 = 375px each Auto-sizing columns automatically adapt when: Auto-sizing works great with column resizing! When users manually resize a column, the\n other auto-sizing columns automatically adjust to fill or use the freed space. Try it in\n the demo above by enabling column resizing. width: Used as the base for proportional scaling. All column widths\n are multiplied by a scale factor to fill the container. minWidth: NOT enforced during initial scaling - columns can be scaled\n below their minWidth. maxWidth: NOT enforced at all - the property is not checked or used\n in autoExpandColumns mode. The table calculates total available space after subtracting all fixed-width columns Remaining space is divided equally among all columns with\n \n width: \"1fr\" Each auto-sizing column gets an equal share of the available space The\n \n minWidth\n \n property ensures columns don't shrink below a specified size Column A:\n \n width: 100\n \n (fixed) Column B:\n \n width: \"1fr\"\n \n (auto) Column C:\n \n width: \"1fr\"\n \n (auto) Column D:\n \n width: 150\n \n (fixed) The table container is resized Columns are hidden or shown Columns are reordered The viewport size changes ,\n required: false,\n description: s recommended to set this to false on mobile devices (< 768px) as horizontal scrolling provides better UX than cramped columns on small screens. /docs/api-reference#simple-table-props ,\n required: true,\n description: for auto-sizing columns that share available space proportionally. /docs/api-reference#header-object // Fixed width in pixels\n{ \n accessor: , \n width: 60 \n}\n\n// Auto-sizing with ,\n },\n {\n key: HeaderObject.minWidth Sets the minimum width constraint for auto-sizing columns (those using ). Prevents columns from becoming too narrow when space is limited. Note: When autoExpandColumns is enabled, minWidth is NOT enforced during initial scaling. // Auto-sizing column with minimum width\n{ \n accessor: ,\n minWidth: 200 // Won HeaderObject.maxWidth Sets the maximum width constraint for columns. Note: When autoExpandColumns is enabled, maxWidth is NOT enforced - it is completely ignored during proportional scaling. // Column with maximum width\n{ \n accessor: ,\n maxWidth: 400 // Won , and\n proportional scaling with{ >\n autoExpandColumns\n { >\n autoExpandColumns\n { >\n Note: On mobile devices, it }\n { >\n When you set{ >\n
  • \n The table calculates total available space after subtracting all fixed-width columns\n
  • \n
  • \n Remaining space is divided equally among all columns with{ >\n width: \n
  • \n
  • Each auto-sizing column gets an equal share of the available space
  • \n
  • \n The{ >\n minWidth\n { }\n property ensures columns don bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-lg font-semibold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 mb-2 list-disc list-inside text-gray-700 dark:text-gray-300 space-y-1 mb-2 >\n width: 100\n { }\n (fixed)\n
  • \n
  • \n Column B:{ { }\n (auto)\n
  • \n
  • \n Column C:{ }\n (auto)\n
  • \n
  • \n Column D:{ >\n width: 150\n {", "section": "Column Features", - "headings": ["Column Auto-Sizing", "How \"1fr\" Works", "Example Calculation", "Pro Tip"] + "headings": [ + "Column Width", + "How autoExpandColumns Works", + "When to Use", + "How \"1fr\" Works", + "Example Calculation", + "Pro Tip" + ] }, { "id": "custom-icons", @@ -454,9 +533,12 @@ "typescript table", "ui design" ], - "content": "To customize the icons in Simple Table, pass your custom icon components to the respective\n props. You can use any icon library like Font Awesome, Material Icons, or your own custom\n SVG icons. Keep icon sizes consistent for a polished look Use colors that match your application's theme Ensure icons are clear and intuitive for their purpose For row grouping, choose expandIcon that clearly indicate the expand/collapse state Consider using the same icon family throughout your application Test your icons for visibility against different background colors ,\n required: false,\n description: />}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • Keep icon sizes consistent for a polished look
  • \n
  • Use colors that match your application", + "content": "All icon props have been consolidated into a single\n icons object for\n better organization. The new\n drag icon is\n used for the drag handle in the column editor. To customize the icons in Simple Table, pass your custom icon components to the respective\n props. You can use any icon library like Font Awesome, Material Icons, or your own custom\n SVG icons. Keep icon sizes consistent for a polished look Use colors that match your application's theme Ensure icons are clear and intuitive for their purpose For row grouping, choose expand icons that clearly indicate the expand/collapse state For drag handles, use icons like grip-vertical or drag indicators that suggest\n draggability Consider using the same icon family throughout your application Test your icons for visibility against different background colors Use the unified\n icons prop to\n keep all icon configurations in one place ,\n required: false,\n description: icons.headerCollapse Custom icon component for the collapse state of collapsible column headers. icons.pinnedLeftIcon Column editor icon for pinning a column to the left. icons.pinnedRightIcon Column editor icon for pinning a column to the right. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n icons\n { >\n All icon props have been consolidated into a single{ >icons object for\n better organization. The new{ >\n
  • Keep icon sizes consistent for a polished look
  • \n
  • Use colors that match your application", "section": "Customization", - "headings": ["Custom Icons"] + "headings": [ + "Custom Icons", + "New in v2.4.1" + ] }, { "id": "custom-theme", @@ -475,19 +557,35 @@ "typescript table", "responsive design" ], - "content": "To create a custom theme for Simple Table, follow these steps: Here are the CSS variables used to create the custom theme in the demo above: Version 1.8.6 introduces dedicated CSS variables for styling sub-columns (child columns\n within collapsible column groups). These variables give you fine-grained control over the\n appearance of nested column structures. These variables complement the existing sub-column variables: See the\n \n Collapsible Columns\n \n documentation for more details on using these variables. Create a CSS file with your theme variables using the\n \n .theme-custom\n \n class Import the CSS file into your application Apply the theme by passing\n \n theme=\"custom\"\n \n to the SimpleTable component Use the\n \n .theme-custom\n \n class to define your custom theme Define CSS variables with the\n \n --st-\n \n prefix Customize colors, spacing, fonts, and transitions Use direct hex values or color variables for consistent styling Test your theme with different features like column resizing and cell selection --st-sub-cell-hover-background-color\n \n - Background color when hovering over sub-cells --st-dragging-sub-header-background-color\n \n - Background color when dragging sub-headers --st-selected-sub-cell-background-color\n \n - Background color for selected sub-cells --st-selected-sub-cell-color\n \n - Text color for selected sub-cells --st-sub-header-background-color\n \n - Background color for sub-column headers --st-sub-cell-background-color\n \n - Background color for sub-column cells ,\n required: false,\n description: to apply your custom CSS theme. Requires a corresponding CSS file with .theme-custom class and CSS variables. /docs/api-reference#union-types // ... other props\n/>\n\n// With custom CSS file containing:\n// .theme-custom {\n// --st-primary-color: #your-color;\n// --st-background-color: #your-bg;\n// /* ... other CSS variables */\n// } flex items-center gap-3 mb-6 p-2 bg-green-100 rounded-lg text-green-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n
  • \n Create a CSS file with your theme variables using the{ >\n .theme-custom\n { }\n class\n
  • \n
  • Import the CSS file into your application
  • \n
  • \n Apply the theme by passing{ { >\n
  • \n Use the{ >\n .theme-custom\n { }\n class to define your custom theme\n
  • \n
  • \n Define CSS variables with the{ >\n --st-\n { custom-theme/CustomTheme.txt text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 mb-4 bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200 >\n --st-dragging-sub-header-background-color\n { >\n --st-selected-sub-cell-background-color\n { >\n --st-selected-sub-cell-color\n { >\n --st-sub-header-background-color\n { >\n --st-sub-cell-background-color\n { >\n See the{ >\n Collapsible Columns\n {", + "content": "The\n \n customTheme\n \n prop allows you to configure layout dimensions that are critical for the table's\n virtualization engine. These values determine how the table calculates scroll positions,\n visible rows, and layout measurements. Simple Table uses virtualization to efficiently render large datasets by only rendering\n visible rows. To calculate which rows are visible and where to position them, the table\n needs to know exact pixel dimensions before rendering. CSS values can't be read\n synchronously during render, so these dimensions must be provided as props. For a complete reference of all customTheme properties, see the\n \n API Reference\n \n . Beyond the built-in themes, Simple Table allows you to create completely custom themes\n using CSS variables. By defining your own theme with custom colors, spacing, and\n typography, you can perfectly match your application's design system. To create a custom CSS theme for Simple Table, follow these steps: Here are the CSS variables used to create the custom theme in the demo above: You can use both CSS theming and the customTheme prop together for complete control over\n your table's appearance: Use CSS variables for all color and visual styling (backgrounds, borders, fonts,\n shadows), and use the customTheme prop for dimensions that affect layout calculations\n (heights, widths, spacing). This separation ensures optimal performance and\n maintainability. Version 2.4.1 introduces new CSS classes and variables for styling the enhanced column\n editor with drag-and-drop and search functionality. See the\n \n Column Editing\n \n documentation for more details on the column editor features. Dedicated CSS variables for styling sub-columns (child columns within collapsible column\n groups). These variables give you fine-grained control over the appearance of nested\n column structures. These variables complement the existing sub-column variables: See the\n \n Collapsible Columns\n \n documentation for more details on using these variables. CSS Theming - Use CSS variables to customize colors, fonts, and visual\n styles. Perfect for matching your design system. customTheme Prop - Configure layout dimensions (heights, widths,\n spacing) that affect virtualization calculations. These cannot be styled through CSS\n alone. Compact tables - Reduce\n \n rowHeight\n \n to 32px for dense data displays Spacious layouts - Increase\n \n rowHeight\n \n to 56px or more for better readability Nested tables - Configure\n \n nestedGridMaxHeight\n \n to control how tall nested grids can grow Custom borders - Adjust border widths to match your design system Create a CSS file with your theme variables using the\n \n .theme-custom\n \n class Import the CSS file into your application Apply the theme by passing\n \n theme=\"custom\"\n \n to the SimpleTable component Use the\n \n .theme-custom\n \n class to define your custom theme Define CSS variables with the\n \n --st-\n \n prefix Customize colors, spacing, fonts, and transitions Use direct hex values or color variables for consistent styling Test your theme with different features like column resizing and cell selection .st-column-editor-search-wrapper\n \n - Search input container .st-column-editor-search\n \n - Search input wrapper .st-column-editor-list\n \n - Column list container .st-column-label-container\n \n - Column label wrapper .st-drag-icon-container\n \n - Drag handle container .st-column-editor-drag-separator\n \n - Drag drop indicator line --st-drag-separator-color\n \n - Color of the drag drop indicator line (theme-specific) --st-sub-cell-hover-background-color\n \n - Background color when hovering over sub-cells --st-dragging-sub-header-background-color\n \n - Background color when dragging sub-headers --st-selected-sub-cell-background-color\n \n - Background color for selected sub-cells --st-selected-sub-cell-color\n \n - Text color for selected sub-cells --st-sub-header-background-color\n \n - Background color for sub-column headers --st-sub-cell-background-color\n \n - Background color for sub-column cells ,\n required: false,\n description: to apply your custom CSS theme. Requires a corresponding CSS file with .theme-custom class and CSS variables. /docs/api-reference#union-types // ... other props\n/>\n\n// With custom CSS file containing:\n// .theme-custom {\n// --st-primary-color: #your-color;\n// --st-background-color: #your-bg;\n// /* ... other CSS variables */\n// } flex items-center gap-3 mb-6 p-2 bg-green-100 rounded-lg text-green-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n customTheme\n { }\n prop allows you to configure layout dimensions that are critical for the table bg-yellow-50 dark:bg-yellow-900/30 border-l-4 border-yellow-400 dark:border-yellow-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 >\n Simple Table uses virtualization to efficiently render large datasets by only rendering\n visible rows. To calculate which rows are visible and where to position them, the table\n needs to know exact pixel dimensions before rendering. CSS values can customTheme Properties bg-green-50 dark:bg-green-900/30 border-l-4 border-green-400 dark:border-green-700 p-4 rounded-lg shadow-sm mt-6 list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300 >\n rowHeight\n { }\n to 32px for dense data displays\n
  • \n
  • \n Spacious layouts - Increase{ }\n to 56px or more for better readability\n
  • \n
  • \n Nested tables - Configure{ >\n nestedGridMaxHeight\n { >\n For a complete reference of all customTheme properties, see the{ >\n Beyond the built-in themes, Simple Table allows you to create completely custom themes\n using CSS variables. By defining your own theme with custom colors, spacing, and\n typography, you can perfectly match your application >\n
  • \n Create a CSS file with your theme variables using the{ >\n .theme-custom\n { }\n class\n
  • \n
  • Import the CSS file into your application
  • \n
  • \n Apply the theme by passing{ { >\n
  • \n Use the{ >\n .theme-custom\n { }\n class to define your custom theme\n
  • \n
  • \n Define CSS variables with the{ >\n --st-\n { >\n You can use both CSS theming and the customTheme prop together for complete control over\n your table bg-purple-50 dark:bg-purple-900/30 border-l-4 border-purple-400 dark:border-purple-700 p-4 rounded-lg shadow-sm text-gray-700 dark:text-gray-300 text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 mt-8 text-gray-700 dark:text-gray-300 mb-4 bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm mb-6 bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200 >\n .st-column-editor-search\n { >\n .st-column-editor-list\n { >\n .st-column-label-container\n { >\n .st-drag-icon-container\n { >\n .st-column-editor-drag-separator\n { >\n --st-drag-separator-color\n { >\n See the{ >\n Column Editing\n { >\n --st-sub-cell-hover-background-color\n { >\n --st-dragging-sub-header-background-color\n { >\n --st-selected-sub-cell-background-color\n { >\n --st-selected-sub-cell-color\n { >\n --st-sub-header-background-color\n { >\n --st-sub-cell-background-color\n { >\n Collapsible Columns\n {", "section": "Customization", - "headings": ["Custom Theme", "Theme Variable Tips", "New Sub-Column CSS Variables"] + "headings": [ + "Custom Theme", + "Two Types of Customization", + "Why These Can't Be CSS-Only", + "Common Use Cases", + "Theme Variable Tips", + "Best Practice", + "New Column Editor CSS Classes", + "New Sub-Column CSS Variables" + ] }, { "id": "empty-state", "path": "/docs/empty-state", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], "content": "Pass any React component to\n \n tableEmptyStateRenderer\n \n to display custom content when the table has no rows: This prop only affects the table body when there are no rows. For loading states while\n data is being fetched, use the\n \n isLoading\n \n prop instead. No search results — Show a "No results found" message with a\n button to clear filters Empty dataset — Display onboarding guidance like "Add your first\n item" with a call-to-action Permission restricted — Inform users they don't have access to\n view this data Error state — Show an error message with a retry button when data\n fetching fails tableEmptyStateRenderer Custom content to display in the table body when there are no rows to display. This can occur when filters return no results or when no data is provided. flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n Pass any React component to{ >\n tableEmptyStateRenderer\n { >\n This prop only affects the table body when there are no rows. For loading states while\n data is being fetched, use the{ >\n isLoading\n {", "section": "Documentation", - "headings": ["Empty State", "💡 Pro Tip"] + "headings": [ + "Empty State", + "💡 Pro Tip" + ] }, { "id": "footer-renderer", @@ -510,7 +608,11 @@ ], "content": "The\n \n footerRenderer\n \n prop accepts a function that receives pagination state and navigation handlers, returning\n a custom ReactNode to display in the footer area. This completely replaces the default\n footer. Use the\n \n hasPrevPage\n \n and\n \n hasNextPage\n \n properties to properly disable navigation buttons when users reach the first or last\n page. This provides better UX feedback and prevents unnecessary navigation attempts. Footer renderers are ideal for various scenarios: Custom pagination styles - Match your application's design system\n with branded pagination controls Summary statistics - Display totals, averages, or other aggregated\n metrics alongside pagination Row info display - Show detailed information about visible rows and\n total dataset size Action buttons - Add export, refresh, or other bulk action buttons in\n the footer area Advanced navigation - Implement jump-to-page inputs, page size\n selectors, or other custom navigation controls The footer renderer completely replaces the default footer when provided Page numbers are 1-based (first page is 1, not 0) The\n \n onNextPage\n \n function is async and returns a Promise Make sure to handle the\n \n hasPrevPage\n \n and\n \n hasNextPage\n \n flags to disable navigation when appropriate ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n footerRenderer\n { >\n Use the{ >\n hasPrevPage\n { >\n hasNextPage\n { >\n
  • The footer renderer completely replaces the default footer when provided
  • \n
  • Page numbers are 1-based (first page is 1, not 0)
  • \n
  • \n The{ >\n onNextPage\n { }\n function is async and returns a Promise\n
  • \n
  • \n Make sure to handle the{ >\n hasPrevPage\n { }\n and{ >\n hasNextPage\n {", "section": "Advanced Features", - "headings": ["Footer Renderer", "💡 Pro Tip", "Important Notes"] + "headings": [ + "Footer Renderer", + "💡 Pro Tip", + "Important Notes" + ] }, { "id": "header-renderer", @@ -530,9 +632,18 @@ "typescript table", "header customization" ], - "content": "Each column in your table can have its own\n \n headerRenderer\n \n function. This function receives information about the header and returns either a\n ReactNode or a string to be rendered in the header cell. Use the\n \n header\n \n parameter to access all column configuration properties, including label, width,\n sortable status, and more. This allows you to create dynamic headers that adapt based on\n column settings. HeaderObject.headerRenderer Custom function to render header content. Receives header information and returns either a ReactNode or string for display. , \n padding: ,\n borderRadius: }}>\n 🌟 {header.label}\n
  • \n );\n }\n} ,\n required: true,\n description: /docs/api-reference#union-types ,\n },\n {\n key: /docs/api-reference#header-object , etc.\n console.log(header.width); // 150, , etc.\n return
    {header.label}
    ;\n} flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg text-purple-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n Each column in your table can have its own{ >\n headerRenderer\n { >\n Use the{ >\n header\n {", + "content": "If you want to hide the entire header row instead of customizing it, use the\n \n hideHeader\n \n prop on the SimpleTable component. This is useful for creating cleaner data displays or\n implementing custom header implementations outside the table. See the\n \n API Reference\n \n for more details. Each column in your table can have its own\n \n headerRenderer\n \n function. This function receives information about the header and returns either a\n ReactNode or a string to be rendered in the header cell. Use the\n \n header\n \n parameter to access all column configuration properties, including label, width,\n sortable status, and more. This allows you to create dynamic headers that adapt based on\n column settings. The\n \n components\n \n prop gives you complete control over the positioning of header elements. Instead of\n building everything from scratch, you can use the pre-rendered components and arrange them\n however you like. Here's how you can reorder the header elements to put the label first, followed by the\n sort and filter icons: You can also create more complex layouts using CSS: Using the\n \n components\n \n prop has several advantages: sortIcon\n \n - The sort indicator icon (when column is sortable) filterIcon\n \n - The filter icon (when column is filterable) collapseIcon\n \n - The collapse/expand icon (for collapsible columns) labelContent\n \n - The column label text All built-in functionality (sorting, filtering) works automatically Icons respect the table theme and styling No need to reimplement click handlers or state management Easy to reposition elements without losing functionality HeaderObject.headerRenderer Custom function to render header content. Receives header information and returns either a ReactNode or string for display. , \n padding: ,\n borderRadius: }}>\n 🌟 {header.label}\n \n );\n }\n} ,\n required: true,\n description: /docs/api-reference#union-types ,\n },\n {\n key: /docs/api-reference#header-object , etc.\n console.log(header.width); // 150, , etc.\n return
    {header.label}
    ;\n} ,\n required: false,\n description: HeaderRendererComponents /docs/api-reference#header-renderer-props }}>\n {components?.filterIcon}\n {components?.sortIcon}\n {components?.collapseIcon}\n \n \n) flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg text-purple-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n If you want to hide the entire header row instead of customizing it, use the{ >\n hideHeader\n { }\n prop on the SimpleTable component. This is useful for creating cleaner data displays or\n implementing custom header implementations outside the table. See the{ >\n API Reference\n { >\n Each column in your table can have its own{ >\n headerRenderer\n { >\n Use the{ >\n header\n { >\n components\n { >\n sortIcon\n { >\n filterIcon\n { >\n collapseIcon\n { >\n labelContent\n { >\n Using the{ >\n components\n {", "section": "Advanced Features", - "headings": ["Header Renderer", "Header Renderer Parameters", "💡 Pro Tip"] + "headings": [ + "Header Renderer", + "💡 Need to Hide Headers?", + "Header Renderer Parameters", + "💡 Pro Tip", + "🎨 Available Components", + "Example: Custom Icon Positioning", + "Example: Custom Layout with Flexbox", + "✨ Why Use Components?" + ] }, { "id": "infinite-scroll", @@ -554,7 +665,9 @@ ], "content": "To enable infinite scroll in your table, follow these steps: SimpleTable's infinite scroll implementation: Set a fixed height - Use the\n \n height\n \n prop to create a scrollable container Implement the callback - Create an\n \n onLoadMore\n \n function that fetches additional data Update state - Append new data to your existing rows array Scroll Detection - Monitors scroll position within the table container Threshold Triggering - Calls\n \n onLoadMore\n \n when user scrolls near the bottom (typically 100px before the end) Debouncing - Prevents multiple simultaneous requests by debouncing the\n scroll event Smooth Integration - New data is seamlessly appended to the existing\n table ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-purple-100 rounded-lg text-purple-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n
  • \n Set a fixed height - Use the{ >\n height\n { }\n prop to create a scrollable container\n
  • \n
  • \n Implement the callback - Create an{ >\n onLoadMore\n { >\n SimpleTable list-disc pl-5 space-y-3 text-gray-700 dark:text-gray-300 mb-6", "section": "Advanced Features", - "headings": ["Infinite Scroll"] + "headings": [ + "Infinite Scroll" + ] }, { "id": "installation", @@ -573,29 +686,45 @@ "npm setup", "typescript table" ], - "content": "Simple Table requires the following peer dependencies: 🚀 Why Simple Table? Simple Table is a lightweight, feature-rich alternative to expensive enterprise solutions.\n See how we compare:\n \n vs AG Grid\n \n \n \n vs TanStack Table\n \n \n \n All alternatives React 16.8+ (Hooks support) ${TECHNICAL_STRINGS.installation.npm} ${TECHNICAL_STRINGS.installation.yarn} ${TECHNICAL_STRINGS.installation.pnpm} >\n Simple Table is a lightweight, feature-rich alternative to expensive enterprise solutions.\n See how we compare:{ >\n vs AG Grid\n \n { >\n vs TanStack Table\n \n {", + "content": "Simple Table requires the following: Why Simple Table? Simple Table is a lightweight, feature-rich alternative to expensive enterprise solutions.\n See how we compare:\n \n vs AG Grid\n \n \n \n vs TanStack Table >\n Simple Table is a lightweight, feature-rich alternative to expensive enterprise solutions.\n See how we compare:{ >\n vs AG Grid\n \n {", "section": "Getting Started", - "headings": ["Installation"] + "headings": [ + "Installation" + ] }, { "id": "live-update", "path": "/docs/live-update", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": "To enable live updates in your table, follow these steps: The\n \n TableAPI\n \n provides methods to interact with the table. Currently, it offers the\n \n updateData\n \n method: When\n \n cellUpdateFlash\n \n is enabled, cells will momentarily highlight when their value changes, providing a subtle\n visual cue to users: Chart columns work seamlessly with live updates. You can update chart data in real-time\n while maintaining a fixed array length by adding new values and removing old ones. The\n demo above shows this in action with Stock Trend (line chart) and Sales Trend (bar chart)\n columns. Use custom\n \n chartOptions\n \n colors (like green for stock and orange for sales) to make charts stand out against\n theme backgrounds. This is especially important for themes with blue row backgrounds\n where default blue chart colors would blend in. See the\n \n Chart Columns documentation\n \n for more details. Live updates are particularly valuable in these scenarios: Create a table reference - Create a ref using\n \n useRef\n \n and pass it to the\n \n tableRef\n \n prop Enable flash animation - Set the\n \n cellUpdateFlash\n \n prop to\n \n true\n \n to enable visual feedback on cell updates Update data - Call\n \n tableRef.current.updateData()\n \n with the appropriate parameters to update specific cells When updating cells, remember to also update your local state to keep it in sync with\n the table The flash effect is subtle by design to avoid distracting users with too much motion Updates are best used for small, incremental changes rather than complete table\n refreshes Financial dashboards - Display real-time stock price changes and trades Inventory management - Show live updates as items are purchased or\n restocked Analytics dashboards - Present data metrics that update as new\n information arrives Sports statistics - Display live game scores and player statistics IoT monitoring - Show sensor readings that update in real-time ,\n required: false,\n description: React.RefObject ,\n required: true,\n description: tableRef.current?.updateData({\n accessor: ,\n rowIndex: 2,\n newValue: 29.99\n}); ,\n rowIndex: 0, // First row\n newValue: ,\n },\n {\n key: // Update with string\ntableRef.current?.updateData({\n accessor: ,\n rowIndex: 1,\n newValue: });\n\n// Update with number\ntableRef.current?.updateData({\n accessor: ,\n rowIndex: 3,\n newValue: 42\n}); flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n tableRef\n { >\n updateData\n { }\n API, you can update individual cells with smooth visual feedback through the{ >\n cellUpdateFlash\n { >\n
  • \n Create a table reference - Create a ref using{ >\n useRef\n \n and pass it to the{ >\n tableRef\n { }\n prop\n
  • \n
  • \n Enable flash animation - Set the{ >\n cellUpdateFlash\n { }\n prop to{ >\n true\n { }\n to enable visual feedback on cell updates\n
  • \n
  • \n Update data - Call{ >\n TableAPI\n { }\n provides methods to interact with the table. Currently, it offers the{ >\n updateData\n { ,\n rowIndex: 0,\n newValue: 25.99\n });\n \n // Update third row\n tableRef.current?.updateData({\n accessor: , \n rowIndex: 2,\n newValue: >\n cellUpdateFlash\n { bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 list-disc pl-5 space-y-1 text-gray-700 dark:text-gray-300 text-2xl font-bold text-gray-800 dark:text-white mb-4 mt-8 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 ,\n rowIndex: rowIndex,\n newValue: updatedHistory,\n });\n};\n\n// Update both stock value and chart\ntableRef.current?.updateData({\n accessor: ,\n rowIndex: 0,\n newValue: 150,\n});\n\nupdateStockHistory(0, 150); // Add to chart bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-400 dark:border-blue-700 p-4 rounded-lg shadow-sm mb-6 text-gray-700 dark:text-gray-300 >\n chartOptions\n { >\n Chart Columns documentation\n {", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "To enable live updates in your table, follow these steps: The\n \n TableAPI\n \n provides methods to interact with the table. Currently, it offers the\n \n updateData\n \n method: When\n \n cellUpdateFlash\n \n is enabled, cells will momentarily highlight when their value changes, providing a subtle\n visual cue to users: Chart columns work seamlessly with live updates. You can update chart data in real-time\n while maintaining a fixed array length by adding new values and removing old ones. The\n demo above shows this in action with Stock Trend (line chart) and Sales Trend (bar chart)\n columns. Use custom\n \n chartOptions\n \n colors (like green for stock and orange for sales) to make charts stand out against\n theme backgrounds. This is especially important for themes with blue row backgrounds\n where default blue chart colors would blend in. See the\n \n Chart Columns documentation\n \n for more details. Live updates are particularly valuable in these scenarios: Create a table reference - Create a ref using\n \n useRef\n \n and pass it to the\n \n tableRef\n \n prop Enable flash animation - Set the\n \n cellUpdateFlash\n \n prop to\n \n true\n \n to enable visual feedback on cell updates Update data - Call\n \n tableRef.current.updateData()\n \n with the appropriate parameters to update specific cells When updating cells, remember to also update your local state to keep it in sync with\n the table The flash effect is subtle by design to avoid distracting users with too much motion Updates are best used for small, incremental changes rather than complete table\n refreshes Financial dashboards - Display real-time stock price changes and trades Inventory management - Show live updates as items are purchased or\n restocked Analytics dashboards - Present data metrics that update as new\n information arrives Sports statistics - Display live game scores and player statistics IoT monitoring - Show sensor readings that update in real-time ,\n required: false,\n description: React.RefObject ,\n required: true,\n description: tableRef.current?.updateData({\n accessor: ,\n rowIndex: 2,\n newValue: 29.99\n}); ,\n rowIndex: 0, // First row\n newValue: ,\n },\n {\n key: // Update with string\ntableRef.current?.updateData({\n accessor: ,\n rowIndex: 1,\n newValue: });\n\n// Update with number\ntableRef.current?.updateData({\n accessor: ,\n rowIndex: 3,\n newValue: 42\n}); flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n tableRef\n { >\n updateData\n { }\n API, you can update individual cells with smooth visual feedback through the{ >\n cellUpdateFlash\n { >\n
  • \n Create a table reference - Create a ref using{ >\n useRef\n \n and pass it to the{ >\n tableRef\n { }\n prop\n
  • \n
  • \n Enable flash animation - Set the{ >\n cellUpdateFlash\n { }\n prop to{ >\n true\n { }\n to enable visual feedback on cell updates\n
  • \n
  • \n Update data - Call{ >\n TableAPI\n { }\n provides methods to interact with the table. Currently, it offers the{ >\n updateData\n { >\n cellUpdateFlash\n { >\n Use custom{ >\n chartOptions\n { >\n Chart Columns documentation\n {", "section": "Documentation", - "headings": ["Live Updates", "Important Notes", "💡 Pro Tip"] + "headings": [ + "Live Updates", + "Important Notes", + "💡 Pro Tip" + ] }, { "id": "loading-state", "path": "/docs/loading-state", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": "To enable the loading state, simply pass the\n \n isLoading\n \n prop and set it to\n \n true\n \n while your data is loading. When the data arrives, set it back to\n \n false\n \n . You can customize the appearance of the loading skeleton using CSS. The skeleton elements\n have the\n \n st-loading-skeleton\n \n class which you can target in your CSS. You can customize the skeleton background color using the CSS variable: Or target the skeleton elements directly using the\n \n st-loading-skeleton\n \n class: /* Or use the class directly */\n.st-loading-skeleton `}\n \n \n\n \n 💡 Pro Tip\n \n Combine the loading state with server-side pagination or infinite scroll to provide\n feedback during data fetches. See the\n \n Pagination\n \n documentation for an example of using\n \n isLoading\n \n with page changes. ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n To enable the loading state, simply pass the{ >\n isLoading\n { }\n prop and set it to{ >\n true\n { }\n while your data is loading. When the data arrives, set it back to{ >\n You can customize the appearance of the loading skeleton using CSS. The skeleton elements\n have the{ >\n st-loading-skeleton\n { >\n Or target the skeleton elements directly using the{ >\n st-loading-skeleton\n { .theme-custom {\n /* Change skeleton background color */\n --st-loading-skeleton-bg-color: var(--st-blue-200);\n}\n\n/* Or use the class directly */\n.st-loading-skeleton {\n background-color: #e0e7ff;\n /* Add custom animations or styles */\n} bg-yellow-50 dark:bg-yellow-900/30 border-l-4 border-yellow-400 dark:border-yellow-700 p-4 rounded-lg shadow-sm font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >\n Pagination\n { }\n documentation for an example of using{ >\n isLoading\n {", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "To enable the loading state, simply pass the\n \n isLoading\n \n prop and set it to\n \n true\n \n while your data is loading. When the data arrives, set it back to\n \n false\n \n . You can customize the appearance of the loading skeleton using CSS. The skeleton elements\n have the\n \n st-loading-skeleton\n \n class which you can target in your CSS. You can customize the skeleton background color using the CSS variable: Or target the skeleton elements directly using the\n \n st-loading-skeleton\n \n class: Combine the loading state with server-side pagination or infinite scroll to provide\n feedback during data fetches. See the\n \n Pagination\n \n documentation for an example of using\n \n isLoading\n \n with page changes. ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-indigo-100 rounded-lg text-indigo-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white >\n To enable the loading state, simply pass the{ >\n isLoading\n { }\n prop and set it to{ >\n true\n { }\n while your data is loading. When the data arrives, set it back to{ >\n You can customize the appearance of the loading skeleton using CSS. The skeleton elements\n have the{ >\n st-loading-skeleton\n { >\n Or target the skeleton elements directly using the{ >\n st-loading-skeleton\n { >\n Combine the loading state with server-side pagination or infinite scroll to provide\n feedback during data fetches. See the{ >\n Pagination\n { }\n documentation for an example of using{ >\n isLoading\n {", "section": "Documentation", - "headings": ["Loading State"] + "headings": [ + "Loading State" + ] }, { "id": "nested-headers", @@ -616,7 +745,28 @@ ], "content": "To create nested headers, add a\n \n children\n \n array to your parent header object, containing the child column definitions: Parent headers serve as container columns and typically don't display cell data Child columns can have all the same properties as regular columns (sorting, filtering,\n custom renderers, etc.) You can have multiple levels of nesting by adding children to child columns Width of the parent column will automatically adjust to fit all child columns Grouped Data Categories: Organize related columns like \"Personal\n Information\", \"Contact Details\", etc. Time-based Data: Group columns by time periods (Q1, Q2, Q3, Q4) with\n child columns for specific metrics Financial Reports: Group columns for \"Revenue\", \"Expenses\", \"Profit\"\n with subcategories Test Scores: As shown in the example, group different subject scores\n under a single parent HeaderObject.children Array of child column definitions that will be grouped under this parent header. Creates a hierarchical column structure. /docs/api-reference#header-object // Parent header with child columns\n{\n label: ,\n children: [\n { \n accessor: , \n width: 80,\n type: },\n { \n accessor: >\n To create nested headers, add a{ >\n children\n { >\n
  • Parent headers serve as container columns and typically don text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 list-disc pl-5 space-y-2 text-gray-700 dark:text-gray-300 Personal\n Information , etc.\n
  • \n
  • \n Time-based Data: Group columns by time periods (Q1, Q2, Q3, Q4) with\n child columns for specific metrics\n
  • \n
  • \n Financial Reports: Group columns for", "section": "Column Features", - "headings": ["Nested Headers", "Key Points About Nested Headers"] + "headings": [ + "Nested Headers", + "Key Points About Nested Headers" + ] + }, + { + "id": "nested-tables", + "path": "/docs/nested-tables", + "title": "Simple Table Documentation", + "description": "Documentation for Simple Table React Grid", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "This example demonstrates nested tables with all data pre-loaded. Expand companies to see\n their divisions with detailed metrics including revenue, profit margin, headcount, and\n location. This example shows lazy-loading nested tables where divisions are fetched on-demand when\n you expand company rows. The\n onRowGroupExpand\n handler manages dynamic data loading with built-in loading states. Nested tables extend the\n \n row grouping\n \n feature by allowing each level of your hierarchy to have its own independent table\n structure. While row grouping shows child rows with the same columns as the parent, nested\n tables let you define completely different columns for each level. To enable nested tables, add the\n \n nestedTable\n \n property to your expandable column's HeaderObject. This property defines the column\n structure and configuration for the child table that appears when a row is expanded. The demos above show a two-level nested table structure with completely different columns\n at each level: High-level company overview with industry, headquarters, market cap, CEO, revenue, and\n employee count. Division-level details including division ID, division name, revenue, profit margin,\n headcount, and location. These demos show two levels of nesting, but nested tables support unlimited nesting\n depth. You can create three, four, or more levels by adding\n nestedTable\n configurations at each level (e.g., Companies → Divisions → Teams → Members → Projects). The data structure for nested tables is the same as row grouping - use nested arrays with\n property names matching your\n \n rowGrouping\n \n configuration: For consistency and convenience, certain props are automatically inherited from the parent\n table and cannot be overridden in nested tables. This ensures a unified experience across\n all nesting levels. These props are automatically inherited and should not be specified in\n the\n nestedTable\n config: As shown in the dynamic demo above, nested tables support lazy-loading at each level using\n the\n onRowGroupExpand\n callback. You can specify different handlers for each nesting level, allowing you to fetch\n data on demand when users expand rows. Nested tables are fully-featured SimpleTable instances, meaning they can receive most of\n the same props and configuration options as a regular table (except for inherited props).\n Each nested table can be configured independently with its own settings: The\n nestedTable\n config accepts any SimpleTable prop, allowing complete customization at each nesting\n level. Common configurations include: See the demos above for complete implementation examples of both static and dynamic nested\n tables. Row Grouping: All levels share the same column structure. Child rows\n display the same columns as parent rows. Nested Tables: Each level has its own independent column structure.\n Parent and child levels can have completely different columns. You must still use the\n rowGrouping\n prop to define the hierarchy (e.g.,\n \n '}\n \n ) The\n expandable\n property must be set to\n true on the\n column Each level can have its own\n nestedTable\n configuration for multi-level nesting Recommended: Use\n getRowId\n (e.g.,\n \n ) => row.id as string}\"}\n \n ) for stable row identification, especially with sorting or dynamic data rows - Data\n is automatically provided from the parent row's nested array loadingStateRenderer\n \n ,\n \n errorStateRenderer\n \n ,\n \n emptyStateRenderer\n \n ,\n \n tableEmptyStateRenderer\n \n - State renderers are shared across all levels Icon props (\n icons\n (expand, filter, sortUp, sortDown, etc.) , etc.) - Icons are consistent across all\n nesting levels Faster initial page load - only fetch top-level data Reduced memory usage - load child data only when needed Better performance with large hierarchical datasets Independent handlers for each nesting level Built-in loading, error, and empty state management Static/Pre-loaded: All data is loaded upfront in the initial\n rows prop.\n Best for smaller datasets or when all data is readily available. See the first demo\n above. Dynamic/Lazy-loaded: Child data is fetched on-demand when users\n expand rows using\n \n onRowGroupExpand\n \n handlers. Best for large datasets, API-driven data, or when initial load time matters.\n See the second demo above for implementation details. Row Selection: Enable\n \n enableRowSelection\n \n to allow selection at that nesting level Column Resizing: Set\n columnResizing\n to enable/disable resizing independently Auto Expand Columns: Use\n \n autoExpandColumns\n \n to make columns fill available width Sorting & Filtering: Configure\n \n defaultSortConfig\n \n ,\n \n enableColumnFiltering\n \n , and other sorting/filtering options per level Themes: Apply different\n theme or\n customTheme\n settings to each nested table (see\n \n Custom Theme\n \n ) Cell Renderers: Use custom\n cellRenderer,\n valueFormatter\n , and\n headerRenderer\n functions with level-specific logic Pagination: Enable\n shouldPaginate\n with custom\n pageSize for\n nested tables Callbacks: Add\n onCellClick,\n onCellChange,\n and other event handlers specific to that level And more: Any prop from the\n \n API Reference\n \n can be used in the nested table configuration Row Grouping\n \n - Learn about the foundation of hierarchical data display Aggregate Functions\n \n - Calculate summaries for grouped data Programmatic Control\n \n - Control expansion and collapse programmatically HeaderObject.nestedTable Configuration for nested tables that allows each level of row grouping to have its own independent grid structure with different columns and settings. When a row is expanded, its child data is displayed in a completely separate table with its own headers, column configuration, and features. The nested table config accepts all SimpleTable props, allowing complete customization at each nesting level. NestedTableConfig (extends all SimpleTable props) ,\n width: 200,\n expandable: true,\n nestedTable: {\n // Required: Column structure\n defaultHeaders: divisionHeaders,\n \n // Optional: Any SimpleTable props\n autoExpandColumns: true,\n enableRowSelection: true,\n columnResizing: false,\n theme: ,\n shouldPaginate: true,\n pageSize: 10,\n // ... any other SimpleTable prop\n }\n} NestedTableConfig.defaultHeaders Array of HeaderObject definitions that define the column structure for the nested table. This allows each nesting level to have completely different columns than its parent. /docs/api-reference#header-object , width: 200, expandable: true, nestedTable: {...} },\n { accessor: , width: 120 },\n { accessor: , width: 200 },\n { accessor: , width: 130 },\n { accessor: , width: 110 },\n { accessor: ,\n },\n {\n key: NestedTableConfig.* (Most SimpleTable Props) Most SimpleTable props (excludes inherited props) /docs/api-reference#simple-table-props nestedTable: {\n defaultHeaders: divisionHeaders,\n \n // Examples of commonly used props:\n autoExpandColumns: true,\n enableRowSelection: true,\n columnResizing: false,\n theme: ,\n customTheme: { rowHeight: 32 },\n shouldPaginate: true,\n pageSize: 10,\n defaultSortConfig: { accessor: flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg flex flex-col gap-4 mb-8 text-xl font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 mb-4 >\n This example shows lazy-loading nested tables where divisions are fetched on-demand when\n you expand company rows. The{ >onRowGroupExpand{ >\n Nested tables extend the{ text-blue-600 dark:text-blue-400 hover:underline >\n To enable nested tables, add the{ >\n nestedTable\n { }\n property to your expandable column bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-400 dark:border-amber-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 list-disc pl-5 space-y-1 text-gray-700 dark:text-gray-300 >rowGrouping{ }\n prop to define the hierarchy (e.g.,{ }\n \n )\n
  • \n
  • \n The{ }\n property must be set to{ >true on the\n column\n
  • \n
  • \n Each level can have its own{ >nestedTable{ }\n configuration for multi-level nesting\n
  • \n
  • \n Recommended: Use{ Nested Table Configuration text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 >\n These demos show two levels of nesting, but nested tables support unlimited nesting\n depth. You can create three, four, or more levels by adding{ >\n The data structure for nested tables is the same as row grouping - use nested arrays with\n property names matching your{ >\n rowGrouping\n { >\n These props are automatically inherited and should not be specified in\n the{ >rows - Data\n is automatically provided from the parent row bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded >\n errorStateRenderer\n \n ,{ >\n emptyStateRenderer\n \n ,{ >\n tableEmptyStateRenderer\n { >\n As shown in the dynamic demo above, nested tables support lazy-loading at each level using\n the{ >\n
  • \n Static/Pre-loaded: All data is loaded upfront in the initial{ >rows prop.\n Best for smaller datasets or when all data is readily available. See the first demo\n above.\n
  • \n
  • \n Dynamic/Lazy-loaded: Child data is fetched on-demand when users\n expand rows using{ >\n onRowGroupExpand\n { >\n
  • \n Row Selection: Enable{ >\n enableRowSelection\n { }\n to allow selection at that nesting level\n
  • \n
  • \n Column Resizing: Set{ >columnResizing{ }\n to enable/disable resizing independently\n
  • \n
  • \n Auto Expand Columns: Use{ >\n autoExpandColumns\n { }\n to make columns fill available width\n
  • \n
  • \n Sorting & Filtering: Configure{ >\n defaultSortConfig\n \n ,{ >\n enableColumnFiltering\n \n , and other sorting/filtering options per level\n
  • \n
  • \n Themes: Apply different{ >customTheme{ }\n settings to each nested table (see{ >\n Custom Theme\n \n )\n
  • \n
  • \n Cell Renderers: Use custom{ >cellRenderer,{ >valueFormatter\n , and{ >headerRenderer{ }\n functions with level-specific logic\n
  • \n
  • \n Pagination: Enable{ >shouldPaginate{ }\n with custom{ >pageSize for\n nested tables\n
  • \n
  • \n Callbacks: Add{ >onCellClick,{ >onCellChange,\n and other event handlers specific to that level\n
  • \n
  • \n And more: Any prop from the{ >\n API Reference\n { >\n Row Grouping\n { >\n Aggregate Functions\n { >\n Programmatic Control\n {", + "section": "Documentation", + "headings": [ + "Nested Tables", + "Static Nested Tables (Pre-loaded Data)", + "Dynamic Nested Tables (Lazy Loading)" + ] }, { "id": "pagination", @@ -635,19 +785,46 @@ "typescript table", "data navigation" ], - "content": "To enable pagination in Simple Table, you need to add the\n \n shouldPaginate\n \n prop to your SimpleTable component. This will automatically handle pagination of your\n data. When pagination is enabled, Simple Table provides: For large datasets, you can implement server-side pagination where data is fetched from\n the server one page at a time. This improves performance by only loading the data needed\n for the current page. To enable server-side pagination, use these props: When using server-side pagination, make sure to update your\n \n rows\n \n prop with the new page data inside your\n \n onPageChange\n \n callback. The table will display whatever data you provide in the\n \n rows\n \n prop. Combine pagination with the\n \n isLoading\n \n prop to show skeleton loaders while fetching new page data. This provides better user\n feedback during page transitions. See the\n \n Loading State\n \n documentation for more details. When pagination is enabled without a specified height, the table will automatically\n adjust to show all rows on the current page with visible overflow (no internal\n scrolling). This provides a cleaner user experience for paginated data. While Simple Table provides a default pagination footer, you can completely customize its\n appearance and functionality using the\n \n footerRenderer\n \n prop. This allows you to: Learn how to create custom pagination footers with complete control over appearance and\n behavior. Visit the\n \n Footer Renderer\n \n documentation for detailed examples and API reference. Automatic page navigation controls Page size selection Current page indicator Total pages display serverSidePagination=\"}\n \n - Disables internal pagination slicing totalRowCount=\"}\n \n - Total rows available on the server onPageChange=`}\n \n - Callback to fetch data for the new page Match your application's design system Add custom pagination controls and navigation Display summary statistics or totals Include action buttons or additional functionality ,\n required: false,\n description: serverSidePagination Flag to disable internal pagination slicing. When true, the table expects you to provide pre-paginated data via the rows prop. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n To enable pagination in Simple Table, you need to add the{ >\n shouldPaginate\n { }\n { >\n When using server-side pagination, make sure to update your{ >\n rows\n { }\n prop with the new page data inside your{ >\n onPageChange\n { }\n callback. The table will display whatever data you provide in the{ >\n Combine pagination with the{ >\n isLoading\n { }\n prop to show skeleton loaders while fetching new page data. This provides better user\n feedback during page transitions. See the{ >\n Loading State\n { >\n While Simple Table provides a default pagination footer, you can completely customize its\n appearance and functionality using the{ >\n footerRenderer\n { >\n Learn how to create custom pagination footers with complete control over appearance and\n behavior. Visit the{ >\n Footer Renderer\n {", + "content": "To enable pagination in Simple Table, you need to add the\n \n shouldPaginate\n \n prop to your SimpleTable component. This will automatically handle pagination of your\n data. When pagination is enabled, Simple Table provides: For large datasets, you can implement server-side pagination where data is fetched from\n the server one page at a time. This improves performance by only loading the data needed\n for the current page. To enable server-side pagination, use these props: When using server-side pagination, make sure to update your\n \n rows\n \n prop with the new page data inside your\n \n onPageChange\n \n callback. The table will display whatever data you provide in the\n \n rows\n \n prop. Combine pagination with the\n \n isLoading\n \n prop to show skeleton loaders while fetching new page data. This provides better user\n feedback during page transitions. See the\n \n Loading State\n \n documentation for more details. When pagination is enabled without a specified height, the table will automatically\n adjust to show all rows on the current page with visible overflow (no internal\n scrolling). This provides a cleaner user experience for paginated data. While Simple Table provides a default pagination footer, you can completely customize its\n appearance and functionality using the\n \n footerRenderer\n \n prop. This allows you to: Learn how to create custom pagination footers with complete control over appearance and\n behavior. Visit the\n \n Footer Renderer\n \n documentation for detailed examples and API reference. Automatic page navigation controls Page size selection Current page indicator Total pages display Smart page number display with first page button - when navigating to far pages, the\n footer shows the first page with ellipsis (e.g., "1 ... 78 79 80") for quick\n access to the beginning serverSidePagination=\"}\n \n - Disables internal pagination slicing totalRowCount=\"}\n \n - Total rows available on the server onPageChange=`}\n \n - Callback to fetch data for the new page Match your application's design system Add custom pagination controls and navigation Display summary statistics or totals Include action buttons or additional functionality ,\n required: false,\n description: serverSidePagination Flag to disable internal pagination slicing. When true, the table expects you to provide pre-paginated data via the rows prop. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n To enable pagination in Simple Table, you need to add the{ >\n shouldPaginate\n { }\n { >\n When using server-side pagination, make sure to update your{ >\n rows\n { }\n prop with the new page data inside your{ >\n onPageChange\n { }\n callback. The table will display whatever data you provide in the{ >\n Combine pagination with the{ >\n isLoading\n { }\n prop to show skeleton loaders while fetching new page data. This provides better user\n feedback during page transitions. See the{ >\n Loading State\n { >\n While Simple Table provides a default pagination footer, you can completely customize its\n appearance and functionality using the{ >\n footerRenderer\n { >\n Learn how to create custom pagination footers with complete control over appearance and\n behavior. Visit the{ >\n Footer Renderer\n {", "section": "Advanced Features", - "headings": ["Pagination", "💡 Advanced Customization"] + "headings": [ + "Pagination", + "💡 Advanced Customization" + ] }, { "id": "programmatic-control", "path": "/docs/programmatic-control", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": ">\n tableRef\n {", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "Programmatically control row grouping expansion with depth-based methods. Perfect for\n implementing custom expand/collapse controls, saving expansion state, or creating\n progressive disclosure patterns. >\n tableRef\n { applyColumnVisibility", + "section": "Documentation", + "headings": [ + "Programmatic Control API" + ] + }, + { + "id": "quick-filter", + "path": "/docs/quick-filter", + "title": "Simple Table Documentation", + "description": "Documentation for Simple Table React Grid", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "Enable quick filter by passing a\n \n quickFilter\n \n prop with a\n \n QuickFilterConfig\n \n object. The quick filter applies before column-specific filters in the filter chain. Simple mode performs basic text matching using a \"contains\" search. It's straightforward\n and intuitive for users who want to quickly find data without learning special syntax. Example: Searching for\n engineering\n will match any row containing \"engineering\" in any searchable column. Smart mode provides advanced search capabilities with multiple operators for power users: Customize how individual columns behave in quick filter searches using these HeaderObject\n properties: The\n \n quickFilterGetter\n \n function receives these properties: Control the quick filter programmatically using the\n \n tableRef.setQuickFilter()\n \n method. This is useful for implementing custom search UI, keyboard shortcuts, or\n integrating with external search components. For more details, see the\n \n Programmatic Control\n \n documentation. Multi-word (AND logic):\n \n alice engineering\n \n → matches rows containing both \"alice\" AND \"engineering\" Phrase search:\n \n \"alice johnson\"\n \n → matches exact phrase \"alice johnson\" Negation:\n -inactive →\n excludes rows containing \"inactive\" Column-specific search:\n \n department:engineering\n \n → searches only in the department column Combine operators:\n \n engineering -inactive location:new\n \n → complex queries with multiple conditions ,\n required: false,\n description: #quick-filter-config , text),\n }}\n // ... other props\n/> ,\n required: true,\n description: ,\n },\n {\n key: for basic contains matching, or for advanced search with multi-word AND logic, phrase search (quotes), negation (minus), and column-specific search (column:value). Defaults to , \n caseSensitive: true \n}} // Search formatted values like , \n useFormattedValue: true \n}} , text);\n trackSearchAnalytics(text);\n }\n}} HeaderObject.quickFilterable Controls whether this column should be included in quick filter searches. Set to false to exclude a column from global search. Defaults to true. // Exclude ID column from quick filter\n{ \n accessor: , \n quickFilterable: false \n}\n\n// Include in quick filter (default)\n{ \n accessor: , \n quickFilterable: true \n} HeaderObject.quickFilterGetter Custom function to extract the searchable value for this column. Useful for complex data structures, computed values, or custom search logic. Receives the row and returns a string or string array to search. #quick-filter-getter // Search nested object\n{ \n accessor: }\n\n// Search multiple fields\n{ \n accessor: string | number | boolean | string[] | number[] | null | undefined >\n Enable quick filter by passing a{ >\n quickFilter\n { }\n prop with a{ >\n QuickFilterConfig\n { >\n Simple mode performs basic text matching using a >\n Example: Searching for{ >engineering{ }\n will match any row containing >\n
  • \n Multi-word (AND logic):{ >\n alice engineering\n { }\n → matches rows containing both
  • \n
  • \n Phrase search:{ { }\n → matches exact phrase
  • \n
  • \n Negation:{ >-inactive →\n excludes rows containing
  • \n
  • \n Column-specific search:{ >\n department:engineering\n { }\n → searches only in the department column\n
  • \n
  • \n Combine operators:{ >\n engineering -inactive location:new\n { >\n quickFilterGetter\n { >\n Control the quick filter programmatically using the{ >\n tableRef.setQuickFilter()\n { >\n For more details, see the{ >\n Programmatic Control\n {", "section": "Documentation", - "headings": ["Programmatic Control API"] + "headings": [ + "Quick Filter / Global Search", + "Simple Mode", + "Smart Mode" + ] }, { "id": "quick-start", @@ -666,9 +843,16 @@ "typescript table", "setup guide" ], - "content": "The\n \n height\n \n prop determines how Simple Table handles vertical scrolling: For most applications, specifying a height is recommended. Learn more in the\n \n Table Height documentation\n \n . Simple Table automatically handles the styling of alternating rows, borders, and hover\n states. You can customize these later with themes, but the defaults look great out of the\n box! With height: The table has a fixed height and handles scrolling\n internally. The header stays visible while scrolling through rows. Without height: The table expands to show all rows and overflows its\n parent container. Use this when you want the parent or page to handle scrolling. ,\n required: true,\n description: /docs/api-reference#header-object ,\n },\n {\n key: /docs/api-reference#union-types , age: 30 },\n { id: 2, name: // If your data has an ,\n required: false,\n description: ,\n backgroundColor: >\n height\n { >\n For most applications, specifying a height is recommended. Learn more in the{ text-blue-600 dark:text-blue-400 hover:underline text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700", + "content": "The\n \n accessor\n \n property in your column definitions creates a direct link between your data and what\n displays in each column. Here's how it works: Each\n \n accessor\n \n value must match a property name in your data objects: 💡 Pro Tip: The\n \n label\n \n property in your column definition is what users see in the header, while\n \n accessor\n \n is the technical property name used to retrieve data. They can be completely different! The\n \n height\n \n and\n \n maxHeight\n \n props determine how Simple Table handles vertical scrolling: For most applications, specifying a height or maxHeight is recommended. Learn more in the\n \n Table Height documentation\n \n . While not required, providing a\n \n getRowId\n \n function helps optimize your table by enabling stable row identification: Example:\n \n ) => row.id as string}\"} By default, Simple Table uses the\n \n width\n \n values you specify for each column. For tables that should always fill their container\n width, use the\n \n autoExpandColumns\n \n prop: Learn more in the\n \n Column Width documentation\n \n . Simple Table automatically handles the styling of alternating rows, borders, and hover\n states. You can customize these later with themes, but the defaults look great out of the\n box! Data must be an array of objects - Each object represents one row Property names must match accessors exactly - Case-sensitive and must\n be valid JavaScript property names All rows should have the same structure - While missing properties\n won't break the table, they'll display as empty cells Nested properties are supported - Use dot notation like\n \n "user.name"\n \n or\n \n "address.city" With height: The table has a fixed height and handles scrolling\n internally. The header stays visible while scrolling through rows. With maxHeight: Works like height, but the table shrinks if there are\n fewer rows. Great for adaptive layouts. Without height: The table expands to show all rows and overflows its\n parent container. Use this when you want the parent or page to handle scrolling. What it does: Function that returns a unique identifier from your row\n data (like\n \n row.id\n \n or\n \n row.uuid\n \n ) When to use: Especially important for tables with sorting, filtering,\n row grouping, or frequently changing data Benefits: Maintains row state (like expansion) across data updates and\n improves performance All column widths are automatically scaled proportionally to fill the table container Your width values serve as the basis for proportional distribution Perfect for responsive tables that adapt to different screen sizes Recommended: Disable on mobile devices (< 768px) for better UX ,\n required: true,\n description: /docs/api-reference#header-object ,\n },\n {\n key: /docs/api-reference#union-types , age: 30 },\n { id: 2, name: ,\n required: false,\n description: /docs/api-reference#simple-table-props ,\n backgroundColor: text-2xl font-bold text-gray-800 dark:text-white mb-3 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline >\n accessor\n { >\n accessor\n { >\n "user.name"\n { >\n 💡 Pro Tip: The{ >\n label\n { }\n property in your column definition is what users see in the header, while{ >\n height\n { >\n maxHeight\n { >\n For most applications, specifying a height or maxHeight is recommended. Learn more in the{ bg-purple-50 dark:bg-purple-900/20 border-l-4 border-purple-500 p-4 rounded-lg shadow-sm mb-8 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 mb-2 >\n getRowId\n { >\n
  • \n What it does: Function that returns a unique identifier from your row\n data (like{ >\n row.id\n { >\n Example:{ bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500 p-4 rounded-lg shadow-sm mb-8 >\n width\n { }\n values you specify for each column. For tables that should always fill their container\n width, use the{ >\n autoExpandColumns\n { >\n Learn more in the{ text-2xl font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700", "section": "Getting Started", - "headings": ["Quick Start", "Understanding the Height Prop", "Pro Tip"] + "headings": [ + "Quick Start", + "🔑 Understanding Accessor and Data Format", + "Understanding the Height Prop", + "🔑 Optional but Recommended: getRowId", + "Auto-Expanding Columns", + "Pro Tip" + ] }, { "id": "row-grouping", @@ -687,42 +871,10 @@ "typescript table", "data organization" ], - "content": "To enable row grouping, add the\n \n expandable: true\n \n property to your column header, structure your data with nested arrays, and specify the\n grouping hierarchy. For large datasets, use the\n \n onRowGroupExpand\n \n callback to load nested data on-demand. This example demonstrates a three-level hierarchy\n (Regions → Stores → Products) where child rows are fetched from an API only when their\n parent is expanded. The callback provides powerful helper functions like\n \n setLoading\n \n ,\n \n setError\n \n , and\n \n setEmpty\n \n for state management, plus\n \n rowIndexPath\n \n for easy nested data updates. Organize teams by department and show individual members Display projects with their milestones and tasks Show product categories with subcategories and items Group transactions by account, invoice, and line items Faster initial load - only top-level rows are fetched Reduced memory usage - child data loaded as needed Better performance with large hierarchical datasets Seamless integration with server-side APIs Built-in state management with setLoading, setError, and setEmpty helpers Simple nested data updates using rowIndexPath array HeaderObject.expandable Makes a column expandable for grouping. This allows users to expand/collapse hierarchical data in that column. , \n expandable: true \n} ,\n required: false,\n description: ]}\n // ... other props\n/> /docs/api-reference#on-row-group-expand-props );\n return;\n }\n \n // Update using rowIndexPath\n // e.g., [0, loadingStateRenderer Custom content to render when a row is in loading state (set via setLoading helper in onRowGroupExpand). Can be a string or React component. If not provided, a default skeleton loading state will be shown automatically. >\n Loading...\n \n} >\n Failed to load data\n \n} >\n No data found\n \n} || row.isPublic;\n }}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg flex flex-col gap-4 mb-8 >\n To enable row grouping, add the{ >\n expandable: true\n { >\n For large datasets, use the{ >\n onRowGroupExpand\n { }\n callback to load nested data on-demand. This example demonstrates a three-level hierarchy\n (Regions → Stores → Products) where child rows are fetched from an API only when their\n parent is expanded. The callback provides powerful helper functions like{ >\n setLoading\n \n ,{ >\n setError\n \n , and{ >\n setEmpty\n { }\n for state management, plus{ >\n rowIndexPath\n {", - "section": "Row Features", - "headings": ["Row Grouping"] - }, - { - "id": "nested-tables", - "path": "/docs/nested-tables", - "title": "Nested Tables in Simple Table React Grid", - "description": "Create nested tables with independent column structures at each level in your react-table with Simple Table. Display hierarchical data with different columns for parent and child rows in your datagrid or data table with TypeScript support.", - "keywords": [ - "simple-table", - "react-table", - "react-grid", - "data-grid", - "datagrid", - "data table", - "nested tables", - "nested grids", - "hierarchical data", - "multi-level tables", - "independent columns", - "typescript table", - "data organization" - ], - "content": "Nested tables allow each level of hierarchical data to have its own independent grid structure with completely different columns, enabling you to display different information at each nesting level. Nested tables extend the row grouping feature by allowing each level of your hierarchy to have its own independent table structure. While row grouping shows child rows with the same columns as the parent, nested tables let you define completely different columns for each level. Row Grouping: All levels share the same column structure. Child rows display the same columns as parent rows. Nested Tables: Each level has its own independent column structure. Parent and child levels can have completely different columns. Companies → Divisions → Teams: Show company overview (9 columns), division metrics (3 columns), and detailed team information (19 columns) Orders → Line Items → Shipments: Display order summary, item details, and tracking information with different data at each level Projects → Milestones → Tasks: Show project overview, milestone progress, and task details with appropriate columns for each level Accounts → Transactions → Line Items: Present account summaries, transaction details, and itemized breakdowns with level-specific columns To enable nested tables, add the nestedTable property to your expandable column's HeaderObject. This property defines the column structure and configuration for the child table that appears when a row is expanded. You must still use the rowGrouping prop to define the hierarchy (e.g., rowGrouping={['divisions', 'teams']}) The expandable property must be set to true on the column Each level can have its own nestedTable configuration for multi-level nesting The demo shows a three-level nested table structure with completely different columns at each level: Level 0: Companies (9 columns) - High-level company overview with industry, headquarters, market cap, CEO, etc. Level 1: Divisions (3 columns) - Simplified division-level metrics focused on financial performance. Level 2: Teams (19 columns) - Detailed team information including manager, budget, headcount, skills, and more. Nested tables support unlimited nesting depth for four, five, or more levels. The data structure for nested tables is the same as row grouping - use nested arrays with property names matching your rowGrouping configuration. Inherited Props: Certain props are automatically inherited from the parent table for consistency: rows data is provided from parent nested array, loadingStateRenderer errorStateRenderer emptyStateRenderer tableEmptyStateRenderer are shared across levels, icon props expandIcon filterIcon sortUpIcon sortDownIcon are consistent across nesting levels. Dynamic Nested Tables: Support dynamic loading at each level using onRowGroupExpand callback. Each nesting level can have its own handler in the nestedTable config enabling complex lazy-loading patterns with independent handlers for each level. Benefits include faster initial load, reduced memory usage, better performance with large datasets, and built-in loading error empty state management. Nested tables are fully-featured SimpleTable instances that can receive most props except inherited ones. Each nested table can be configured independently with its own settings including: Row Selection enableRowSelection, Column Resizing columnResizing, Auto Expand Columns autoExpandColumns, Sorting and Filtering defaultSortConfig enableColumnFiltering, Themes theme customTheme, Cell Renderers cellRenderer valueFormatter headerRenderer, Pagination shouldPaginate pageSize, Callbacks onCellClick onCellChange onRowGroupExpand, and most other SimpleTable props from the API Reference. The nestedTable config accepts most SimpleTable props allowing complete customization at each nesting level.", + "content": "To enable row grouping, add the\n \n expandable: true\n \n property to your column header, structure your data with nested arrays, and specify the\n grouping hierarchy. When using row grouping with external sorting or dynamic data, it's highly recommended\n to provide the\n getRowId prop.\n This ensures stable row identification across data updates: Example:\n \n ) => row.id as string}\"}\n \n or\n \n ) => row.uuid as string}\"} Row grouping shows child rows with the same columns as parent rows. If you need each\n level to have its own independent column structure (e.g., companies with 9 columns,\n divisions with 6 columns, teams with 19 columns), check out\n \n Nested Tables\n \n . Version 2.1.0 introduces powerful programmatic control over row grouping expansion. You\n can now control which hierarchy levels are expanded or collapsed using the table ref API.\n These methods give you fine-grained control over the visibility of nested data. The\n \n enableStickyParents\n \n prop is a beta feature that makes parent rows stick to the top while scrolling through\n their children. This helps maintain context when navigating deep hierarchical data\n structures. This feature is currently in beta and defaults to\n false. While\n it works well in most scenarios, there may be edge cases that need refinement. Use with\n caution in production environments. For large datasets, use the\n \n onRowGroupExpand\n \n callback to load nested data on-demand. This example demonstrates a three-level hierarchy\n (Regions → Stores → Products) where child rows are fetched from an API only when their\n parent is expanded. The callback provides powerful helper functions like\n \n setLoading\n \n ,\n \n setError\n \n , and\n \n setEmpty\n \n for state management, plus\n \n rowIndexPath\n \n (v2.2.9+: contains only numeric indices) and\n \n rowIdPath\n \n (when getRowId is provided) for easy nested data updates. Organize teams by department and show individual members Display projects with their milestones and tasks Show product categories with subcategories and items Group transactions by account, invoice, and line items Maintains correct expansion state when data is sorted or filtered Provides stable\n rowIdPath in\n onRowGroupExpand Prevents row group collapse when row order changes Essential for tables with external sorting enabled expandAll()\n - Expand all rows at all depths collapseAll()\n \n - Collapse all rows at all depths expandDepth(depth)\n \n - Expand all rows at a specific depth (0-indexed) collapseDepth(depth)\n \n - Collapse all rows at a specific depth (0-indexed) toggleDepth(depth)\n \n - Toggle expansion for a specific depth setExpandedDepths(depths)\n \n - Set which depths are expanded (replaces current state) getExpandedDepths()\n \n - Get currently expanded depths as a Set getGroupingProperty(depth)\n \n - Get the grouping property name for a depth index getGroupingDepth(property)\n \n - Get the depth index for a grouping property name Faster initial load - only top-level rows are fetched Reduced memory usage - child data loaded as needed Better performance with large hierarchical datasets Seamless integration with server-side APIs Built-in state management with setLoading, setError, and setEmpty helpers Simple nested data updates using rowIndexPath array HeaderObject.expandable Makes a column expandable for grouping. This allows users to expand/collapse hierarchical data in that column. , \n expandable: true \n} ,\n required: false,\n description: ]}\n // ... other props\n/> /docs/api-reference#on-row-group-expand-props loadingStateRenderer Custom content to render when a row is in loading state (set via setLoading helper in onRowGroupExpand). Can be a string or React component. If not provided, a default skeleton loading state will be shown automatically. >\n Loading...\n \n} >\n Failed to load data\n \n} >\n No data found\n \n} || row.isPublic;\n }}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg flex flex-col gap-4 mb-8 >\n To enable row grouping, add the{ >\n expandable: true\n { >\n When using row grouping with external sorting or dynamic data, it >\n
  • Maintains correct expansion state when data is sorted or filtered
  • \n
  • \n Provides stable{ >\n Example:{ bg-purple-50 dark:bg-purple-900/30 border-l-4 border-purple-400 dark:border-purple-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >expandAll(){ >\n collapseAll()\n { >\n expandDepth(depth)\n { >\n collapseDepth(depth)\n { >\n toggleDepth(depth)\n { >\n setExpandedDepths(depths)\n { >\n getExpandedDepths()\n { >\n getGroupingProperty(depth)\n { >\n getGroupingDepth(property)\n { >\n enableStickyParents\n { >\n This feature is currently in beta and defaults to{ >\n For large datasets, use the{ >\n onRowGroupExpand\n { }\n callback to load nested data on-demand. This example demonstrates a three-level hierarchy\n (Regions → Stores → Products) where child rows are fetched from an API only when their\n parent is expanded. The callback provides powerful helper functions like{ >\n setLoading\n \n ,{ >\n setError\n \n , and{ >\n setEmpty\n { }\n for state management, plus{ >\n rowIndexPath\n { }\n (v2.2.9+: contains only numeric indices) and{ >\n rowIdPath\n {", "section": "Row Features", "headings": [ - "Nested Tables", - "What Are Nested Tables?", - "Basic Setup", - "Example: Three-Level Hierarchy", - "Data Structure", - "Inherited Props", - "Dynamic Nested Tables", - "Combining with Other Features", - "Related Features" + "Row Grouping" ] }, { @@ -742,9 +894,12 @@ "typescript table", "responsive table" ], - "content": "You can specify the height of rows in a Simple Table using the\n \n customTheme.rowHeight\n \n property. This property accepts a numeric value representing the height in pixels and is passed via the customTheme prop. ,\n required: false,\n description: flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n You can specify the height of rows in a Simple Table using the{ >\n customTheme.rowHeight\n {", + "content": "You can specify the height of rows in a Simple Table using the\n \n customTheme.rowHeight\n \n property. This property accepts a numeric value representing the height in pixels and is passed via the\n \n customTheme\n \n prop. The customTheme prop controls layout dimensions that affect the table's virtualization engine. For a complete guide to all customTheme properties and how they work, see the\n \n Custom Theme\n \n documentation. customTheme.rowHeight Sets the height of all rows in the table. Accepts a numeric value representing pixels. Passed via the customTheme prop. flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n You can specify the height of rows in a Simple Table using the{ >\n customTheme.rowHeight\n { }\n property. This property accepts a numeric value representing the height in pixels and is passed via the{ >\n customTheme\n { >customTheme prop controls layout dimensions that affect the table >\n Custom Theme\n {", "section": "Row Features", - "headings": ["Row Height"] + "headings": [ + "Row Height", + "Learn More" + ] }, { "id": "row-selection", @@ -765,17 +920,23 @@ "typescript table", "interactive table" ], - "content": "Enable row selection by adding the\n \n enableRowSelection\n \n prop to your SimpleTable component. This adds checkboxes to each row and the header for\n easy selection management. When row selection is enabled, users can: Use the\n \n onRowSelectionChange\n \n callback to respond to selection changes and implement your business logic: ) => \\`);\n } else \\`);\n }\n \n // Convert Set to Array for further processing\n const selectedRowsArray = Array.from(selectedRows);\n console.log(\\`Total selected: \\$\\`);\n \n // Implement your business logic here\n handleBulkOperations(selectedRowsArray);\n};`}\n \n \n \n\n \n \n Common Use Cases\n \n\n \n \n Row selection is essential for many interactive table scenarios: The\n \n selectedRows\n \n parameter uses a Set for optimal performance when dealing with large datasets. Convert\n to an Array only when needed for your specific operations. Row selection checkboxes are fully accessible with proper ARIA labels and keyboard\n navigation support. Users can navigate using Tab/Shift+Tab and select/deselect using\n Space or Enter keys. Click individual row checkboxes to select/deselect specific rows Click the header checkbox to select or deselect all rows at once Use keyboard navigation to interact with checkboxes Maintain selection state during sorting, filtering, and pagination Bulk Operations: Delete, update, or process multiple records\n simultaneously Data Export: Allow users to select specific rows for export to CSV,\n Excel, or other formats Batch Actions: Apply actions like status changes, category assignments,\n or approvals to multiple items Comparison Views: Select multiple items to compare their properties\n side-by-side Workflow Management: Move selected items through different stages of a\n process ,\n required: false,\n description: onRowSelectionChange Callback function triggered when row selection changes. Receives information about the selected row and current selection state. , { row, isSelected, selectedRows });\n }}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Enable row selection by adding the{ >\n enableRowSelection\n { >\n Use the{ >\n onRowSelectionChange\n { >\n { Selected: \\${row.name}\\ Deselected: \\${row.name}\\ Total selected: \\${selectedRowsArray.length}\\ >\n selectedRows\n {", + "content": "Enable row selection by adding the\n \n enableRowSelection\n \n prop to your SimpleTable component. This adds checkboxes to each row and the header for\n easy selection management. When row selection is enabled, users can: Use the\n \n onRowSelectionChange\n \n callback to respond to selection changes and implement your business logic: Row selection is essential for many interactive table scenarios: The\n \n selectedRows\n \n parameter uses a Set for optimal performance when dealing with large datasets. Convert\n to an Array only when needed for your specific operations. Row selection checkboxes are fully accessible with proper ARIA labels and keyboard\n navigation support. Users can navigate using Tab/Shift+Tab and select/deselect using\n Space or Enter keys. Click individual row checkboxes to select/deselect specific rows Click the header checkbox to select or deselect all rows at once Use keyboard navigation to interact with checkboxes Maintain selection state during sorting, filtering, and pagination Bulk Operations: Delete, update, or process multiple records\n simultaneously Data Export: Allow users to select specific rows for export to CSV,\n Excel, or other formats Batch Actions: Apply actions like status changes, category assignments,\n or approvals to multiple items Comparison Views: Select multiple items to compare their properties\n side-by-side Workflow Management: Move selected items through different stages of a\n process ,\n required: false,\n description: onRowSelectionChange Callback function triggered when row selection changes. Receives information about the selected row and current selection state. , { row, isSelected, selectedRows });\n }}\n // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n Enable row selection by adding the{ >\n enableRowSelection\n { >\n Use the{ >\n onRowSelectionChange\n { >\n selectedRows\n {", "section": "Row Features", - "headings": ["Row Selection"] + "headings": [ + "Row Selection" + ] }, { "id": "table-height", "path": "/docs/table-height", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": "The\n \n height\n \n and\n \n maxHeight\n \n props are optional and determine how the table handles vertical space: Simple Table creates a scrollable container at the specified height. The header\n remains fixed while the body scrolls. This is ideal when you have many rows and want\n to keep the table contained within a specific area of your layout. The table will\n always be the specified height, even if there are few rows. Works the same as height except that the table will shrink if there are fewer rows.\n This provides adaptive behavior where the table grows up to the maximum height but\n remains compact when displaying less data. Virtualization is still enabled for\n performance. If both height and maxHeight are defined, height will be ignored. When neither prop is specified, the table expands to show all rows and will overflow\n its parent container. This is useful when you want to handle scrolling yourself (e.g.,\n page-level scrolling) or when embedding the table in a container that manages its own\n overflow. The\n \n headerHeight\n \n and\n \n footerHeight\n \n props are rarely needed and should almost never be used. They are only useful for\n pagination tables with custom footers where the table needs to know the footer height\n before rendering to properly calculate when the body will scroll. In most cases, the\n table handles these calculations automatically. The table has a fixed height and handles its own scrolling: Use maxHeight when you want the table to shrink with fewer rows but grow up to a maximum\n height. Perfect for tables with variable data amounts: Use viewport units for responsive heights that adapt to screen size: Use percentage height when the parent container has a defined height: Omit height when you want the parent to handle scrolling: For most applications, specifying either\n \n height\n \n or\n \n maxHeight\n \n is recommended. Use\n \n height\n \n for consistent sizing or\n \n maxHeight\n \n for adaptive behavior. Both enable virtualization for large datasets and keep the header\n visible while scrolling through data. ,\n required: false,\n description: // ... other props\n/>\n\n// No height - table overflows parent\n t fill the maximum height. If both height and maxHeight are defined, height will be ignored. // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n height\n { >\n maxHeight\n { >\n height\n { >\n maxHeight\n { >\n headerHeight\n { >\n footerHeight\n { // No height prop - table overflows parent\n />\n >\n For most applications, specifying either{ }\n is recommended. Use{ }\n for consistent sizing or{", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "The\n \n height\n \n and\n \n maxHeight\n \n props are optional and determine how the table handles vertical space: Simple Table creates a scrollable container at the specified height. The header\n remains fixed while the body scrolls. This is ideal when you have many rows and want\n to keep the table contained within a specific area of your layout. The table will\n always be the specified height, even if there are few rows. Works the same as height except that the table will shrink if there are fewer rows.\n This provides adaptive behavior where the table grows up to the maximum height but\n remains compact when displaying less data. Virtualization is still enabled for\n performance. If both height and maxHeight are defined, height will be ignored. When neither prop is specified, the table expands to show all rows and will overflow\n its parent container. This is useful when you want to handle scrolling yourself (e.g.,\n page-level scrolling) or when embedding the table in a container that manages its own\n overflow. The\n \n customTheme.headerHeight\n \n and\n \n customTheme.footerHeight\n \n properties are rarely needed and should almost never be used. They are only useful for\n pagination tables with custom footers where the table needs to know the footer height\n before rendering to properly calculate when the body will scroll. In most cases, the\n table handles these calculations automatically. These are passed via the\n \n customTheme\n \n prop. For more details, see the\n \n Custom Theme\n \n documentation. The table has a fixed height and handles its own scrolling. Use maxHeight when you want the table to shrink with fewer rows but grow up to a maximum\n height. Perfect for tables with variable data amounts. Use viewport units for responsive heights that adapt to screen size. Use percentage height when the parent container has a defined height. Omit height when you want the parent to handle scrolling. For most applications, specifying either\n \n height\n \n or\n \n maxHeight\n \n is recommended. Use\n \n height\n \n for consistent sizing or\n \n maxHeight\n \n for adaptive behavior. Both enable virtualization for large datasets and keep the header\n visible while scrolling through data. ,\n required: false,\n description: // ... other props\n/>\n\n// No height - table overflows parent\n t fill the maximum height. If both height and maxHeight are defined, height will be ignored. // ... other props\n/> flex items-center gap-3 mb-6 p-2 bg-blue-100 rounded-lg text-blue-600 text-2xl text-3xl font-bold text-gray-800 dark:text-white text-gray-700 dark:text-gray-300 mb-6 text-lg >\n height\n { >\n maxHeight\n { >\n height\n { >\n maxHeight\n { >\n customTheme.headerHeight\n { >\n customTheme.footerHeight\n { }\n properties are rarely needed and should almost never be used. They are only useful for\n pagination tables with custom footers where the table needs to know the footer height\n before rendering to properly calculate when the body will scroll. In most cases, the\n table handles these calculations automatically. These are passed via the{ >\n customTheme\n { }\n prop. For more details, see the{ >\n Custom Theme\n { >\n For most applications, specifying either{ }\n is recommended. Use{ }\n for consistent sizing or{", "section": "Documentation", "headings": [ "Table Height", @@ -804,27 +965,40 @@ "typescript table", "responsive design" ], - "content": "To apply a theme to Simple Table, simply pass the\n \n theme\n \n prop with one of the available theme options: In addition to themes, Simple Table provides several boolean flags that control specific\n aspects of table appearance: You can use these flags together with any theme to control the visual presentation of your\n table: When creating custom themes, you can customize the colors used for these styling\n features using CSS variables like\n \n --st-hover-row-background-color\n \n and\n \n --st-odd-row-background-color\n \n . See the\n \n Custom Theme\n \n documentation for details. ,\n required: false,\n description: /docs/api-reference#union-types // ... other props\n/> useHoverRowBackground Enables a background color change when hovering over a row. This provides better visual feedback for users when interacting with the table. useOddEvenRowBackground Applies alternating background colors to odd and even rows. This makes it easier to distinguish between adjacent rows, especially in tables with many columns. useOddColumnBackground Applies alternating background colors to odd and even columns. This can help differentiate between adjacent columns in tables with many columns or narrow columns. >\n customizing your React table\n { }\n or explore{ >\n To apply a theme to Simple Table, simply pass the{ >\n theme\n { @simple-table/react/styles.css bg-yellow-50 dark:bg-yellow-900/30 border-l-4 border-yellow-400 dark:border-yellow-700 p-4 rounded-lg shadow-sm mb-6 font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 >\n --st-hover-row-background-color\n { >\n --st-odd-row-background-color\n \n . See the{ >\n Custom Theme\n {", + "content": "To apply a theme to Simple Table, simply pass the\n \n theme\n \n prop with one of the available theme options. The new\n \n modern-light\n \n and\n \n modern-dark\n \n themes feature a clean, minimal design that works great for modern applications: In addition to themes, Simple Table provides several boolean flags that control specific\n aspects of table appearance: You can use these flags together with any theme to control the visual presentation of your\n table: When creating custom themes, you can customize the colors used for these styling\n features using CSS variables like\n \n --st-hover-row-background-color\n \n and\n \n --st-odd-row-background-color\n \n . See the\n \n Custom Theme\n \n documentation for details. ,\n required: false,\n description: /docs/api-reference#union-types // ... other props\n/> useHoverRowBackground Enables a background color change when hovering over a row. This provides better visual feedback for users when interacting with the table. useOddEvenRowBackground Applies alternating background colors to odd and even rows. This makes it easier to distinguish between adjacent rows, especially in tables with many columns. useOddColumnBackground Applies alternating background colors to odd and even columns. This can help differentiate between adjacent columns in tables with many columns or narrow columns. >\n customizing your table\n { }\n or explore{ >\n To apply a theme to Simple Table, simply pass the{ >\n theme\n { }\n prop with one of the available theme options. The new{ >\n modern-light\n { >\n modern-dark\n { >\n When creating custom themes, you can customize the colors used for these styling\n features using CSS variables like{ >\n --st-hover-row-background-color\n { >\n --st-odd-row-background-color\n \n . See the{ >\n Custom Theme\n {", "section": "Customization", - "headings": ["Themes", "Tip"] + "headings": [ + "Themes", + "Tip" + ] }, { "id": "tooltip", "path": "/docs/tooltip", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], "content": "To add a tooltip to a column header, simply include the\n \n tooltip\n \n property in your column definition. The tooltip will appear when users hover over the\n column header. HeaderObject.tooltip Tooltip text that appears when hovering over the column header. Provides helpful context about the column Current retail price in USD >\n To add a tooltip to a column header, simply include the{ >\n tooltip\n {", "section": "Documentation", - "headings": ["Tooltips"] + "headings": [ + "Tooltips" + ] }, { "id": "value-formatter", "path": "/docs/value-formatter", "title": "Simple Table Documentation", "description": "Documentation for Simple Table React Grid", - "keywords": ["simple-table", "react-table", "documentation"], - "content": "Each column in your table can have its own\n \n valueFormatter\n \n function. This function receives information about the cell and returns a formatted string\n or number for display. Use\n \n valueFormatter\n \n for simple text formatting (currency, dates, percentages). Use\n \n cellRenderer\n \n when you need React components, custom styling, or interactive elements. Format numeric values as currency with proper locale formatting: Format date strings or timestamps into readable dates: Display decimal values as percentages: Format values differently based on conditions: Access other column values from the same row for combined formatting: Control how formatted values are copied to clipboard and exported to CSV files. Starting\n in v1.8.6, both\n \n useFormattedValueForClipboard\n \n and\n \n useFormattedValueForCSV\n \n default to\n \n true\n \n when a\n \n valueFormatter\n \n exists, reducing boilerplate code. When you define a\n \n valueFormatter\n \n , formatted values are automatically used for clipboard copy and CSV exports. You no\n longer need to explicitly set these flags to\n \n true\n \n ! To use raw values: Explicitly set\n \n useFormattedValueForClipboard: false\n \n or\n \n useFormattedValueForCSV: false With a\n \n valueFormatter\n \n , formatted values are automatically copied when users press Ctrl+C or Cmd+C: Control CSV export values with two options: When a valueFormatter is defined, CSV exports automatically use the formatted value: Provide completely custom values for CSV export: Here's the same formatting task using both approaches: Both approaches display the same text, but\n \n cellRenderer\n \n allows for custom styling and React components at the cost of slightly more complexity. Clipboard: useFormattedValueForClipboard + valueFormatter → Chart\n formatting (comma-separated) → Raw value CSV Export: exportValueGetter → useFormattedValueForCSV +\n valueFormatter → Raw value Currency formatting ($1,234.56) Date formatting (Dec 25, 2024) Percentage display (15.5%) Number formatting (1,000,000) Simple text transformations Concatenating values from the same row Better performance for simple formatting React components (badges, buttons, icons) Custom styling and colors Interactive elements (clicks, hovers) Complex layouts (flexbox, grids) Images and media Progress bars, charts, visualizations Array or object data rendering valueFormatter\n \n only affects display - the underlying data remains unchanged If both\n \n valueFormatter\n \n and\n \n cellRenderer\n \n are provided,\n \n cellRenderer\n \n takes precedence valueFormatter\n \n is more performant for simple text formatting Return values must be strings or numbers, not React components HeaderObject.valueFormatter Function to format the cell value for display without affecting the underlying data. Returns a string or number that will be displayed in the cell. Useful for currency, dates, percentages, and other formatted text. /docs/api-reference#value-formatter-props ,\n },\n {\n key: HeaderObject.useFormattedValueForClipboard When true, cells copy the formatted value (with symbols, formatting) when users press Ctrl+C/Cmd+C. Defaults to true when valueFormatter exists (v1.8.6+), or false if no valueFormatter. Useful for copying currency with $ symbols, percentages with %, or formatted dates. ,\n // useFormattedValueForClipboard: true // Defaults to true since v1.8.6\n // Set to false to override: useFormattedValueForClipboard: false\n} useFormattedValueForCSV HeaderObject.useFormattedValueForCSV When true, CSV exports use the formatted value from valueFormatter instead of raw data. Defaults to true when valueFormatter exists (v1.8.6+), or false if no valueFormatter. Perfect for human-readable reports and spreadsheets. Note: exportValueGetter takes precedence if provided. ,\n // useFormattedValueForCSV: true // Defaults to true since v1.8.6\n // Set to false to override: useFormattedValueForCSV: false\n} HeaderObject.exportValueGetter Custom function to provide completely different values for CSV export. Takes precedence over useFormattedValueForCSV. Useful for adding codes, identifiers, or transforming data specifically for spreadsheet compatibility. /docs/api-reference#export-value-props \\${capitalize(value)} (\\${codes[value]})\\ text-blue-600 hover:underline >\n Each column in your table can have its own{ >\n valueFormatter\n { >\n valueFormatter\n { }\n for simple text formatting (currency, dates, percentages). Use{ >\n cellRenderer\n { , {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}\\ ,\n });\n }\n}\n\n// Display: Dec 25, 2024 (from raw value: ;\n }\n}\n\n// Display: 15.5% (from raw value: 0.155) text-xl font-semibold text-gray-800 dark:text-white mb-4 mt-8 text-gray-700 dark:text-gray-300 mb-4 bg-gray-800 text-white p-4 rounded-md mb-6 overflow-x-auto shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] ;\n if (balance < 0) return \\ $\\${balance.toFixed(2)}\\ ;\n }\n}\n\n// Display: John Doe (from firstName: >\n Control how formatted values are copied to clipboard and exported to CSV files. Starting\n in v1.8.6, both{ >\n useFormattedValueForClipboard\n { >\n useFormattedValueForCSV\n { }\n default to{ >\n true\n { >\n When you define a{ >\n valueFormatter\n \n , formatted values are automatically used for clipboard copy and CSV exports. You no\n longer need to explicitly set these flags to{ >\n To use raw values: Explicitly set{ >\n useFormattedValueForClipboard: false\n { // useFormattedValueForClipboard: true (automatically defaults to true)\n}\n\n// User copies cell: Gets instead of 85000\n// Perfect for pasting into reports or presentations\n\n// To copy raw values instead, explicitly set to false:\n// useFormattedValueForClipboard: false // useFormattedValueForCSV: true (automatically defaults to true)\n}\n// CSV exports: instead of 0.925\n\n// To export raw values, explicitly set to false:\n// useFormattedValueForCSV: false bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 p-4 rounded-lg font-bold text-gray-800 dark:text-white mb-2 text-gray-700 dark:text-gray-300 mb-3 bg-gray-800 text-white p-3 rounded-md overflow-x-auto >\n valueFormatter\n { }\n only affects display - the underlying data remains unchanged\n
  • \n
  • \n If both{ }\n and{ >\n cellRenderer\n { }\n are provided,{ ,\n fontWeight: }}>\n \\${price.toFixed(2)}\n \n );\n }\n} text-gray-700 dark:text-gray-300 >\n cellRenderer\n {", + "keywords": [ + "simple-table", + "react-table", + "documentation" + ], + "content": "Each column in your table can have its own\n \n valueFormatter\n \n function. This function receives information about the cell and returns a formatted string\n or number for display. Use\n \n valueFormatter\n \n for simple text formatting (currency, dates, percentages). Use\n \n cellRenderer\n \n when you need React components, custom styling, or interactive elements. Format numeric values as currency with proper locale formatting. Format date strings or timestamps into readable dates. Display decimal values as percentages. Format values differently based on conditions. Access other column values from the same row for combined formatting. Control how formatted values are copied to clipboard and exported to CSV files. Starting\n in v1.8.6, both\n \n useFormattedValueForClipboard\n \n and\n \n useFormattedValueForCSV\n \n default to\n \n true\n \n when a\n \n valueFormatter\n \n exists, reducing boilerplate code. When you define a\n \n valueFormatter\n \n , formatted values are automatically used for clipboard copy and CSV exports. You no\n longer need to explicitly set these flags to\n \n true\n \n ! To use raw values: Explicitly set\n \n useFormattedValueForClipboard: false\n \n or\n \n useFormattedValueForCSV: false With a\n \n valueFormatter\n \n , formatted values are automatically copied when users press Ctrl+C or Cmd+C. Control CSV export values with two options: When a valueFormatter is defined, CSV exports automatically use the formatted value. Provide completely custom values for CSV export. Here's the same formatting task using both approaches: Both approaches display the same text, but\n \n cellRenderer\n \n allows for custom styling and React components at the cost of slightly more complexity. Clipboard: useFormattedValueForClipboard + valueFormatter → Chart\n formatting (comma-separated) → Raw value CSV Export: exportValueGetter → useFormattedValueForCSV +\n valueFormatter → Raw value Currency formatting ($1,234.56) Date formatting (Dec 25, 2024) Percentage display (15.5%) Number formatting (1,000,000) Simple text transformations Concatenating values from the same row Better performance for simple formatting React components (badges, buttons, icons) Custom styling and colors Interactive elements (clicks, hovers) Complex layouts (flexbox, grids) Images and media Progress bars, charts, visualizations Array or object data rendering valueFormatter\n \n only affects display - the underlying data remains unchanged If both\n \n valueFormatter\n \n and\n \n cellRenderer\n \n are provided,\n \n cellRenderer\n \n takes precedence valueFormatter\n \n is more performant for simple text formatting Return values must be strings or numbers, not React components HeaderObject.valueFormatter Function to format the cell value for display without affecting the underlying data. Returns a string or number that will be displayed in the cell. Useful for currency, dates, percentages, and other formatted text. /docs/api-reference#value-formatter-props ,\n },\n {\n key: HeaderObject.useFormattedValueForClipboard When true, cells copy the formatted value (with symbols, formatting) when users press Ctrl+C/Cmd+C. Defaults to true when valueFormatter exists (v1.8.6+), or false if no valueFormatter. Useful for copying currency with $ symbols, percentages with %, or formatted dates. ,\n // useFormattedValueForClipboard: true // Defaults to true since v1.8.6\n // Set to false to override: useFormattedValueForClipboard: false\n} useFormattedValueForCSV HeaderObject.useFormattedValueForCSV When true, CSV exports use the formatted value from valueFormatter instead of raw data. Defaults to true when valueFormatter exists (v1.8.6+), or false if no valueFormatter. Perfect for human-readable reports and spreadsheets. Note: exportValueGetter takes precedence if provided. ,\n // useFormattedValueForCSV: true // Defaults to true since v1.8.6\n // Set to false to override: useFormattedValueForCSV: false\n} HeaderObject.exportValueGetter Custom function to provide completely different values for CSV export. Takes precedence over useFormattedValueForCSV. Useful for adding codes, identifiers, or transforming data specifically for spreadsheet compatibility. /docs/api-reference#export-value-props \\${capitalize(value)} (\\${codes[value]})\\ text-blue-600 hover:underline >\n Each column in your table can have its own{ >\n valueFormatter\n { >\n valueFormatter\n { }\n for simple text formatting (currency, dates, percentages). Use{ >\n cellRenderer\n { >\n Control how formatted values are copied to clipboard and exported to CSV files. Starting\n in v1.8.6, both{ >\n useFormattedValueForClipboard\n { >\n useFormattedValueForCSV\n { }\n default to{ >\n true\n { >\n When you define a{ >\n valueFormatter\n \n , formatted values are automatically used for clipboard copy and CSV exports. You no\n longer need to explicitly set these flags to{ >\n To use raw values: Explicitly set{ >\n useFormattedValueForClipboard: false\n { >\n valueFormatter\n { }\n only affects display - the underlying data remains unchanged\n
  • \n
  • \n If both{ }\n and{ >\n cellRenderer\n { }\n are provided,{ >\n Both approaches display the same text, but{ >\n cellRenderer\n {", "section": "Documentation", "headings": [ "Value Formatter", @@ -840,9 +1014,7 @@ "CSV Export Formatting", "✓\n Use valueFormatter for:", "→\n Use cellRenderer for:", - "Important Notes", - "Using valueFormatter (Recommended for simple text)", - "Using cellRenderer (For custom styling)" + "Important Notes" ] } -] +] \ No newline at end of file diff --git a/apps/marketing/src/constants/packageInfo.ts b/apps/marketing/src/constants/packageInfo.ts index e059fdc91..429b54310 100644 --- a/apps/marketing/src/constants/packageInfo.ts +++ b/apps/marketing/src/constants/packageInfo.ts @@ -29,7 +29,7 @@ export interface PackageInfo { export const SIMPLE_TABLE_INFO: PackageInfo = { name: "Simple Table", npmPackage: "simple-table-core", - version: "2.2.7", + version: "3.1.0", bundleSizeMinGzip: "62.4 kB", bundleSizeMinGzipKB: 62.4, bundlePhobiaUrl: "https://bundlephobia.com/package/simple-table-core", diff --git a/apps/marketing/src/constants/propDefinitions/configProps.ts b/apps/marketing/src/constants/propDefinitions/configProps.ts index 165b7e6cb..d30f08418 100644 --- a/apps/marketing/src/constants/propDefinitions/configProps.ts +++ b/apps/marketing/src/constants/propDefinitions/configProps.ts @@ -1,5 +1,43 @@ import type { PropInfo } from "./types"; +export const ANIMATIONS_CONFIG_PROPS: PropInfo[] = [ + { + key: "enabled", + name: "enabled", + required: false, + description: + "Master toggle for animations. When false, no other field has effect and cells snap to their new positions instantly. Defaults to true.", + type: "boolean", + example: `// Disable animations entirely +animations={{ enabled: false }}`, + }, + { + key: "duration", + name: "duration", + required: false, + description: + "Animation duration in milliseconds. Applies to every animated cell. Defaults to 240ms.", + type: "number", + example: `// Snappier 160ms transitions +animations={{ duration: 160 }} + +// Slower, more dramatic 400ms transitions +animations={{ duration: 400 }}`, + }, + { + key: "easing", + name: "easing", + required: false, + description: + "CSS easing function applied to the transform transition. Accepts any valid CSS timing function (cubic-bezier, ease, linear, etc.). Defaults to cubic-bezier(0.2, 0.8, 0.2, 1).", + type: "string", + example: `animations={{ + duration: 280, + easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" // bouncy +}}`, + }, +]; + export const ENUM_OPTION_PROPS: PropInfo[] = [ { key: "label", diff --git a/apps/marketing/src/constants/propDefinitions/index.ts b/apps/marketing/src/constants/propDefinitions/index.ts index 9f8a7dc65..0f3f28cfb 100644 --- a/apps/marketing/src/constants/propDefinitions/index.ts +++ b/apps/marketing/src/constants/propDefinitions/index.ts @@ -27,6 +27,7 @@ export { // Config props (aggregation, sort, filter, chart) export { + ANIMATIONS_CONFIG_PROPS, ENUM_OPTION_PROPS, AGGREGATION_CONFIG_PROPS, CHART_OPTIONS_PROPS, diff --git a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts index c0e364e60..315885ccd 100644 --- a/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts +++ b/apps/marketing/src/constants/propDefinitions/simpleTableProps.ts @@ -66,6 +66,26 @@ maxHeight="80vh"`, customTheme={{ rowHeight: 32, }}`, + }, + { + key: "animations", + name: "animations", + required: false, + description: + "Configures animations on sort, programmatic column reorder, drag-driven column reorder, and column visibility changes. Animations are enabled by default with sensible motion settings and automatically respect the user's prefers-reduced-motion setting. Pass an object to override the duration, easing, or to disable animations entirely.", + type: "AnimationsConfig", + link: "#animations-config", + example: `// Default behavior (animations on, 240ms, smooth easing) +// No prop required. + +// Custom timing +animations={{ + duration: 320, + easing: "ease-out", +}} + +// Disable animations +animations={{ enabled: false }}`, }, { key: "cellUpdateFlash", diff --git a/apps/marketing/src/constants/strings/seo.ts b/apps/marketing/src/constants/strings/seo.ts index b6a5ced65..996403f0c 100644 --- a/apps/marketing/src/constants/strings/seo.ts +++ b/apps/marketing/src/constants/strings/seo.ts @@ -994,6 +994,13 @@ export const SEO_STRINGS = { keywords: "simple-table, data-grid, datagrid, data table, column sorting, table sorting, typescript table, data management, javascript data grid", }, + animations: { + title: "Animations in Simple Table Data Grid", + description: + "Smooth animations on sort, column reorder, and column visibility changes, on by default in Simple Table. GPU-accelerated, virtualization-aware, and respects prefers-reduced-motion. Customize duration and easing or disable entirely.", + keywords: + "simple-table, data-grid, datagrid, data table, animations, table animations, sort animations, column reorder animations, prefers-reduced-motion, typescript table, javascript data grid", + }, columnVisibility: { title: "Column Visibility in Simple Table Data Grid", description: diff --git a/packages/examples/angular/src/demos/animations/animations-demo.component.ts b/packages/examples/angular/src/demos/animations/animations-demo.component.ts new file mode 100644 index 000000000..f309769b5 --- /dev/null +++ b/packages/examples/angular/src/demos/animations/animations-demo.component.ts @@ -0,0 +1,34 @@ +import { Component, Input } from "@angular/core"; +import { SimpleTableComponent } from "@simple-table/angular"; +import type { AngularHeaderObject, Row, Theme } from "@simple-table/angular"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/angular/styles.css"; + +@Component({ + selector: "animations-demo", + standalone: true, + imports: [SimpleTableComponent], + template: ` + + `, +}) +export class AnimationsDemoComponent { + @Input() height: string | number = "400px"; + @Input() theme?: Theme; + + readonly rows: Row[] = animationsConfig.rows; + headers: AngularHeaderObject[] = [...animationsConfig.headers]; + + onColumnOrderChange(newHeaders: AngularHeaderObject[]): void { + this.headers = newHeaders; + } +} diff --git a/packages/examples/angular/src/demos/animations/animations.demo-data.ts b/packages/examples/angular/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..1d706874b --- /dev/null +++ b/packages/examples/angular/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { AngularHeaderObject } from "@simple-table/angular"; + + +export const animationsHeaders: AngularHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/packages/examples/react/src/demos/animations/AnimationsDemo.tsx b/packages/examples/react/src/demos/animations/AnimationsDemo.tsx new file mode 100644 index 000000000..4d64adb61 --- /dev/null +++ b/packages/examples/react/src/demos/animations/AnimationsDemo.tsx @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { SimpleTable } from "@simple-table/react"; +import type { Theme, ReactHeaderObject } from "@simple-table/react"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/react/styles.css"; + +const AnimationsDemo = ({ + height = "400px", + theme, +}: { + height?: string | number; + theme?: Theme; +}) => { + const [headers, setHeaders] = useState(() => [...animationsConfig.headers]); + + const handleColumnOrderChange = (newHeaders: ReactHeaderObject[]) => { + setHeaders(newHeaders); + }; + + return ( + + ); +}; + +export default AnimationsDemo; diff --git a/packages/examples/react/src/demos/animations/animations.demo-data.ts b/packages/examples/react/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..9ec00bcf9 --- /dev/null +++ b/packages/examples/react/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { ReactHeaderObject } from "@simple-table/react"; + + +export const animationsHeaders: ReactHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/packages/examples/solid/src/demos/animations/AnimationsDemo.tsx b/packages/examples/solid/src/demos/animations/AnimationsDemo.tsx new file mode 100644 index 000000000..df8ebe957 --- /dev/null +++ b/packages/examples/solid/src/demos/animations/AnimationsDemo.tsx @@ -0,0 +1,22 @@ +import { createSignal } from "solid-js"; +import { SimpleTable } from "@simple-table/solid"; +import type { Theme } from "@simple-table/solid"; +import { animationsConfig } from "./animations.demo-data"; +import "@simple-table/solid/styles.css"; + +export default function AnimationsDemo(props: { height?: string | number; theme?: Theme }) { + const [headers, setHeaders] = createSignal([...animationsConfig.headers]); + + return ( + + ); +} diff --git a/packages/examples/solid/src/demos/animations/animations.demo-data.ts b/packages/examples/solid/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..23a754a20 --- /dev/null +++ b/packages/examples/solid/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { SolidHeaderObject } from "@simple-table/solid"; + + +export const animationsHeaders: SolidHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/packages/examples/svelte/src/demos/animations/AnimationsDemo.svelte b/packages/examples/svelte/src/demos/animations/AnimationsDemo.svelte new file mode 100644 index 000000000..225e79c84 --- /dev/null +++ b/packages/examples/svelte/src/demos/animations/AnimationsDemo.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/examples/svelte/src/demos/animations/animations.demo-data.ts b/packages/examples/svelte/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..2e5957913 --- /dev/null +++ b/packages/examples/svelte/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { SvelteHeaderObject } from "@simple-table/svelte"; + + +export const animationsHeaders: SvelteHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/packages/examples/vanilla/src/demos/animations/AnimationsDemo.ts b/packages/examples/vanilla/src/demos/animations/AnimationsDemo.ts new file mode 100644 index 000000000..886acabee --- /dev/null +++ b/packages/examples/vanilla/src/demos/animations/AnimationsDemo.ts @@ -0,0 +1,20 @@ +import { SimpleTableVanilla } from "simple-table-core"; +import type { Theme } from "simple-table-core"; +import { animationsConfig } from "./animations.demo-data"; +import "simple-table-core/styles.css"; + +export function renderAnimationsDemo( + container: HTMLElement, + options?: { height?: string | number; theme?: Theme } +): SimpleTableVanilla { + const table = new SimpleTableVanilla(container, { + defaultHeaders: animationsConfig.headers, + rows: animationsConfig.rows, + height: options?.height ?? "400px", + theme: options?.theme, + columnReordering: true, + editColumns: true, + editColumnsInitOpen: true, + }); + return table; +} diff --git a/packages/examples/vanilla/src/demos/animations/animations.demo-data.ts b/packages/examples/vanilla/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..24266c254 --- /dev/null +++ b/packages/examples/vanilla/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { HeaderObject } from "simple-table-core"; + + +export const animationsHeaders: HeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; diff --git a/packages/examples/vue/src/demos/animations/AnimationsDemo.vue b/packages/examples/vue/src/demos/animations/AnimationsDemo.vue new file mode 100644 index 000000000..ab7f18c07 --- /dev/null +++ b/packages/examples/vue/src/demos/animations/AnimationsDemo.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/examples/vue/src/demos/animations/animations.demo-data.ts b/packages/examples/vue/src/demos/animations/animations.demo-data.ts new file mode 100644 index 000000000..871d6fc16 --- /dev/null +++ b/packages/examples/vue/src/demos/animations/animations.demo-data.ts @@ -0,0 +1,40 @@ +// Self-contained demo table setup for this example. +import type { VueHeaderObject } from "@simple-table/vue"; + + +export const animationsHeaders: VueHeaderObject[] = [ + { accessor: "id", label: "ID", width: 60, isSortable: true, type: "number" }, + { accessor: "name", label: "Name", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { accessor: "age", label: "Age", width: 80, align: "right", isSortable: true, type: "number" }, + { accessor: "role", label: "Role", minWidth: 140, width: "1fr", isSortable: true, type: "string" }, + { + accessor: "department", + disableReorder: true, + isSortable: true, + label: "Department", + minWidth: 140, + width: "1fr", + type: "string", + }, +]; + +export const animationsData = [ + { id: 1, name: "Captain Stella Vega", age: 38, role: "Mission Commander", department: "Flight Operations" }, + { id: 2, name: "Dr. Cosmos Rivera", age: 34, role: "Astrophysicist", department: "Science" }, + { id: 3, name: "Commander Nebula Johnson", age: 42, role: "Operations Director", department: "Mission Control" }, + { id: 4, name: "Cadet Orbit Williams", age: 26, role: "Flight Engineer", department: "Engineering" }, + { id: 5, name: "Dr. Galaxy Chen", age: 31, role: "Life Support Specialist", department: "Engineering" }, + { id: 6, name: "Lt. Meteor Lee", age: 29, role: "Navigation Officer", department: "Flight Operations" }, + { id: 7, name: "Dr. Comet Hassan", age: 33, role: "Mission Planner", department: "Planning" }, + { id: 8, name: "Major Pulsar White", age: 36, role: "Communications Director", department: "Communications" }, + { id: 9, name: "Specialist Quasar Black", age: 28, role: "Systems Analyst", department: "Technology" }, + { id: 10, name: "Engineer Supernova Blue", age: 35, role: "Propulsion Engineer", department: "Engineering" }, + { id: 11, name: "Dr. Aurora Kumar", age: 30, role: "Planetary Geologist", department: "Science" }, + { id: 12, name: "Admiral Cosmos Silver", age: 45, role: "Program Director", department: "Leadership" }, +]; + +export const animationsConfig = { + headers: animationsHeaders, + rows: animationsData, + tableProps: { columnReordering: true, editColumns: true, editColumnsInitOpen: true }, +} as const; From 60e2a6cdd5623a3a011b0556b7764a879976378a Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:34:36 -0500 Subject: [PATCH 14/20] More animation bug fixes --- packages/core/src/utils/rowFlattening.ts | 44 +++++---- packages/core/src/utils/rowUtils.ts | 92 ++++++++++++------- .../core/stories/examples/BasicExample.ts | 2 +- 3 files changed, 82 insertions(+), 56 deletions(-) diff --git a/packages/core/src/utils/rowFlattening.ts b/packages/core/src/utils/rowFlattening.ts index 11d4d6c98..129f50ffd 100644 --- a/packages/core/src/utils/rowFlattening.ts +++ b/packages/core/src/utils/rowFlattening.ts @@ -70,17 +70,16 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, groupingKey: undefined, }); - const stableRowKey = - generateStableRowKey({ - getRowId, - row, - depth: 0, - index, - rowPath, - rowIndexPath, - groupingKey: undefined, - parentStableKey: null, - }) ?? undefined; + const stableRowKey = generateStableRowKey({ + getRowId, + row, + depth: 0, + index, + rowPath, + rowIndexPath, + groupingKey: undefined, + parentStableKey: null, + }); return { row, @@ -138,17 +137,16 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { rowIndexPath, groupingKey: currentGroupingKey, }); - const stableRowKey = - generateStableRowKey({ - getRowId, - row, - depth: currentDepth, - index, - rowPath, - rowIndexPath, - groupingKey: currentGroupingKey, - parentStableKey, - }) ?? undefined; + const stableRowKey = generateStableRowKey({ + getRowId, + row, + depth: currentDepth, + index, + rowPath, + rowIndexPath, + groupingKey: currentGroupingKey, + parentStableKey, + }); const currentRowIndex = result.length; @@ -285,7 +283,7 @@ export function flattenRows(config: FlattenRowsConfig): FlattenRowsResult { nestedIdPath, nestedIndexPath, [...parentIndices, currentRowIndex], - stableRowKey ?? null + stableRowKey ); } } diff --git a/packages/core/src/utils/rowUtils.ts b/packages/core/src/utils/rowUtils.ts index 5671d2876..465144062 100644 --- a/packages/core/src/utils/rowUtils.ts +++ b/packages/core/src/utils/rowUtils.ts @@ -292,7 +292,34 @@ export const rowIdToString = (rowId: (string | number)[]): string => { }; /** - * Generate a position-independent stable row key when `getRowId` is provided. + * Fallback stable-key store used when the consumer does not supply `getRowId`. + * + * Keyed on the row object reference, so any two renders/sorts that share the + * same row instance will resolve to the same key. `[...rows].sort(...)` and + * filter operations both preserve references, so this is sufficient to keep + * cell identity (and therefore FLIP sort animations) stable without requiring + * the consumer to provide `getRowId`. + * + * Entries are weakly held; they are cleaned up automatically when the row + * object is no longer referenced. + */ +const fallbackStableKeyByRow = new WeakMap(); +let fallbackStableKeyCounter = 0; + +const getFallbackStableKey = (row: Row): string => { + if (row && typeof row === "object") { + const existing = fallbackStableKeyByRow.get(row); + if (existing !== undefined) return existing; + const next = `__row_${++fallbackStableKeyCounter}`; + fallbackStableKeyByRow.set(row, next); + return next; + } + // Primitive rows are unusual but tolerate them by stringifying. + return `__row_p_${String(row)}`; +}; + +/** + * Generate a position-independent stable row key. * * Unlike `generateRowId`, the stable key never includes positional indices, so * it survives sort/filter operations. It is used as the basis for the cell DOM @@ -300,11 +327,11 @@ export const rowIdToString = (rowId: (string | number)[]): string => { * element to be reused for the same logical row across re-orders (enabling * FLIP-based sort animations). * - * For nested rows the parent's stable key is included as a prefix so siblings - * of different parents do not collide. - * - * Returns `null` when `getRowId` is not provided (callers fall back to the - * positional rowId string in that case). + * When `getRowId` is provided, the key is derived from the user-supplied id. + * When it is not, the key falls back to the row object's identity (via a + * WeakMap) so animations still work for plain row arrays. For nested rows the + * parent's stable key is included as a prefix so siblings of different parents + * do not collide. */ export const generateStableRowKey = (params: { getRowId?: GetRowId; @@ -315,24 +342,26 @@ export const generateStableRowKey = (params: { rowIndexPath: number[]; groupingKey?: string; parentStableKey?: string | null; -}): string | null => { +}): string => { const { getRowId } = params; - if (!getRowId) return null; - - const customId = getRowId({ - row: params.row, - depth: params.depth, - index: params.index, - rowPath: params.rowPath, - rowIndexPath: params.rowIndexPath, - groupingKey: params.groupingKey, - }); - - const customIdStr = String(customId); + + const baseKey = getRowId + ? String( + getRowId({ + row: params.row, + depth: params.depth, + index: params.index, + rowPath: params.rowPath, + rowIndexPath: params.rowIndexPath, + groupingKey: params.groupingKey, + }), + ) + : getFallbackStableKey(params.row); + const parts: string[] = []; if (params.parentStableKey) parts.push(params.parentStableKey); if (params.groupingKey && params.depth > 0) parts.push(params.groupingKey); - parts.push(customIdStr); + parts.push(baseKey); return parts.join("/"); }; @@ -465,17 +494,16 @@ export const flattenRowsWithGrouping = ({ groupingKey: currentGroupingKey, }); - const stableRowKey = - generateStableRowKey({ - getRowId, - row, - depth: currentDepth, - index, - rowPath, - rowIndexPath, - groupingKey: currentGroupingKey, - parentStableKey, - }) ?? undefined; + const stableRowKey = generateStableRowKey({ + getRowId, + row, + depth: currentDepth, + index, + rowPath, + rowIndexPath, + groupingKey: currentGroupingKey, + parentStableKey, + }); // Determine if this is the last row in a group const isLastGroupRow = currentDepth === 0 && index === currentRows.length - 1; @@ -636,7 +664,7 @@ export const flattenRowsWithGrouping = ({ nestedIdPath, nestedIndexPath, [...parentIndices, currentRowIndex], - stableRowKey ?? null, + stableRowKey, ); } } diff --git a/packages/core/stories/examples/BasicExample.ts b/packages/core/stories/examples/BasicExample.ts index f4d21eddb..0e73e98bf 100644 --- a/packages/core/stories/examples/BasicExample.ts +++ b/packages/core/stories/examples/BasicExample.ts @@ -42,7 +42,7 @@ export function renderBasicExample(args?: Partial): HTMLEl { accessor: "role", label: "Role", width: 150, isSortable: true, filterable: true }, ]; const options = { ...defaultVanillaArgs, ...basicExampleDefaults, ...args }; - const { wrapper, h2 } = renderVanillaTable(headers, createBasicData(100), { + const { wrapper, h2 } = renderVanillaTable(headers, createBasicData(10), { ...options, getRowId: (params: { row?: { id?: unknown } }) => String(params.row?.id), }); From db93d2c886cc12016efa413c95b482c620332931 Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:37:51 -0500 Subject: [PATCH 15/20] Cell css fix for animations --- packages/core/src/styles/base.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index 49c9592ff..382b7dadd 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -632,6 +632,12 @@ input { .st-cell { border: var(--st-border-width) solid transparent; + /* Stack above .st-row-separator so a cell mid-FLIP slides in front of the + static border lines instead of being bisected by them. Both elements are + position:absolute siblings with z-index:auto, so DOM order otherwise puts + separators on top. Statically the separator never overlaps a cell, so + this only changes paint during animation. */ + z-index: 1; } /* Remove browser default focus ring when Tab moves focus to a cell; selection styling indicates focus */ From 391c73e3bd8461334fe544e4f136f207a7abfa6e Mon Sep 17 00:00:00 2001 From: peter <20213436+petera2c@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:05:39 -0500 Subject: [PATCH 16/20] Marketing improvements --- README.md | 18 + apps/marketing/package.json | 5 +- apps/marketing/public/llms.txt | 71 ++ apps/marketing/public/sitemap.xml | 578 ---------------- apps/marketing/sandbox-links.json | 33 +- apps/marketing/scripts/generate-sitemap.js | 170 ----- .../src/app/blog/BlogSegmentChrome.tsx | 51 +- .../page.tsx | 135 ++++ .../page.tsx | 135 ++++ .../angular-data-grid-simple-table/page.tsx | 276 ++++++-- .../page.tsx | 90 +++ .../page.tsx | 90 +++ .../angular-grid-row-selection-guide/page.tsx | 90 +++ .../page.tsx | 97 +++ .../blog/angular-tree-data-tables/page.tsx | 90 +++ .../best-free-svelte-data-grid-2026/page.tsx | 124 ++++ .../blog/best-solidjs-data-grid-2026/page.tsx | 123 ++++ .../best-vanilla-js-data-grid-2026/page.tsx | 127 ++++ .../simple-table-vs-ag-grid-angular/page.tsx | 138 ++++ .../page.tsx | 124 ++++ .../page.tsx | 117 ++++ .../page.tsx | 116 ++++ .../app/blog/simple-table-vs-grid-js/page.tsx | 117 ++++ .../page.tsx | 117 ++++ .../simple-table-vs-jspreadsheet/page.tsx | 116 ++++ .../page.tsx | 126 ++++ .../simple-table-vs-kobalte-grid/page.tsx | 114 ++++ .../simple-table-vs-naive-ui-table/page.tsx | 116 ++++ .../simple-table-vs-ngx-datatable/page.tsx | 126 ++++ .../simple-table-vs-primeng-table/page.tsx | 125 ++++ .../page.tsx | 127 ++++ .../simple-table-vs-svar-datagrid/page.tsx | 115 ++++ .../page.tsx | 125 ++++ .../page.tsx | 118 ++++ .../page.tsx | 124 ++++ .../simple-table-vs-vue-good-table/page.tsx | 123 ++++ .../page.tsx | 126 ++++ .../solidjs-data-grid-simple-table/page.tsx | 264 ++++++-- .../page.tsx | 84 +++ .../page.tsx | 84 +++ .../page.tsx | 84 +++ .../solidjs-grid-row-selection-guide/page.tsx | 84 +++ .../blog/solidjs-tree-data-tables/page.tsx | 84 +++ .../page.tsx | 85 +++ .../page.tsx | 85 +++ .../page.tsx | 85 +++ .../svelte-grid-row-selection-guide/page.tsx | 85 +++ .../app/blog/svelte-tree-data-tables/page.tsx | 85 +++ .../page.tsx | 269 ++++++-- .../src/app/blog/topic/[framework]/page.tsx | 71 ++ .../page.tsx | 86 +++ .../page.tsx | 86 +++ .../page.tsx | 86 +++ .../page.tsx | 86 +++ .../blog/vanilla-js-tree-data-tables/page.tsx | 86 +++ .../page.tsx | 269 ++++++-- .../vue-grid-column-pinning-tutorial/page.tsx | 86 +++ .../page.tsx | 86 +++ .../vue-grid-row-selection-guide/page.tsx | 86 +++ .../vue-nuxt-data-grid-simple-table/page.tsx | 273 ++++++-- .../vue-table-column-resizing-guide/page.tsx | 86 +++ .../app/blog/vue-tree-data-tables/page.tsx | 86 +++ .../simple-table-vs-ag-grid-angular/page.tsx | 191 ++++++ .../simple-table-vs-ngx-datatable/page.tsx | 165 +++++ .../simple-table-vs-primeng-table/page.tsx | 88 +++ .../page.tsx | 85 +++ .../page.tsx | 85 +++ .../page.tsx | 84 +++ .../simple-table-vs-vuetify/page.tsx | 87 +++ apps/marketing/src/app/docs/layout.tsx | 8 +- .../examples/[framework]/[example]/page.tsx | 215 ++++++ apps/marketing/src/app/examples/layout.tsx | 42 ++ apps/marketing/src/app/examples/metadata.ts | 23 - .../src/app/frameworks/[framework]/page.tsx | 50 +- .../migrations/from-ag-grid-angular/page.tsx | 103 +++ .../src/app/migrations/from-grid-js/page.tsx | 97 +++ .../migrations/from-ngx-datatable/page.tsx | 99 +++ .../from-primevue-datatable/page.tsx | 98 +++ .../from-svelte-headless-table/page.tsx | 97 +++ .../app/migrations/from-tabulator/page.tsx | 99 +++ .../from-vuetify-data-table/page.tsx | 98 +++ apps/marketing/src/app/sitemap.ts | 157 +++++ .../src/components/AIVisibilityEnhancer.tsx | 79 ++- .../src/components/OtherFrameworksCallout.tsx | 39 +- .../components/blog/CompetitorBlogLayout.tsx | 355 ++++++++++ .../blog/FrameworkTutorialLayout.tsx | 427 ++++++++++++ .../blog/PillarPositioningArticle.tsx | 524 +++++++++++++++ .../FrameworkVsCompetitorLayout.tsx | 364 +++++++++++ .../FromCompetitorMigrationLayout.tsx | 300 +++++++++ .../src/components/pages/BlogPageContent.tsx | 129 +++- .../src/components/pages/HomeContent.tsx | 36 +- .../src/components/seo/DocsJsonLd.tsx | 102 +++ apps/marketing/src/constants/blogPosts.ts | 615 +++++++++++++++++- .../src/constants/frameworkCompetitors.ts | 69 ++ .../src/constants/frameworkIntegrationHub.ts | 19 + apps/marketing/src/utils/structuredData.ts | 21 + package.json | 2 +- packages/angular/README.md | 4 +- packages/angular/package.json | 30 +- packages/core/README.md | 22 + packages/core/package.json | 28 +- packages/examples/angular/README.md | 32 + packages/examples/react/README.md | 34 + packages/examples/solid/README.md | 32 + packages/examples/svelte/README.md | 32 + packages/examples/vanilla/README.md | 32 + packages/examples/vue/README.md | 32 + packages/react/README.md | 3 +- packages/react/package.json | 27 +- packages/solid/README.md | 5 +- packages/solid/package.json | 25 +- packages/svelte/README.md | 3 +- packages/svelte/package.json | 26 +- packages/vue/README.md | 5 +- packages/vue/package.json | 34 +- pnpm-lock.yaml | 105 --- 116 files changed, 11751 insertions(+), 1276 deletions(-) create mode 100644 apps/marketing/public/llms.txt delete mode 100644 apps/marketing/public/sitemap.xml delete mode 100644 apps/marketing/scripts/generate-sitemap.js create mode 100644 apps/marketing/src/app/blog/ag-grid-alternatives-free-angular-data-grids-2026/page.tsx create mode 100644 apps/marketing/src/app/blog/ag-grid-alternatives-free-vue-data-grids-2026/page.tsx create mode 100644 apps/marketing/src/app/blog/angular-grid-column-pinning-tutorial/page.tsx create mode 100644 apps/marketing/src/app/blog/angular-grid-filtering-implementation/page.tsx create mode 100644 apps/marketing/src/app/blog/angular-grid-row-selection-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/angular-table-column-resizing-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/angular-tree-data-tables/page.tsx create mode 100644 apps/marketing/src/app/blog/best-free-svelte-data-grid-2026/page.tsx create mode 100644 apps/marketing/src/app/blog/best-solidjs-data-grid-2026/page.tsx create mode 100644 apps/marketing/src/app/blog/best-vanilla-js-data-grid-2026/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-ag-grid-angular/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-angular-material-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-element-plus-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-flowbite-svelte-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-grid-js/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-handsontable-vanilla/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-jspreadsheet/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-kendo-grid-angular/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-kobalte-grid/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-naive-ui-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-ngx-datatable/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-primeng-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-primevue-datatable/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-svar-datagrid/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-svelte-headless-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-tabulator-vanilla-js/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-tanstack-solid-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-vue-good-table/page.tsx create mode 100644 apps/marketing/src/app/blog/simple-table-vs-vuetify-data-table/page.tsx create mode 100644 apps/marketing/src/app/blog/solidjs-grid-column-pinning-tutorial/page.tsx create mode 100644 apps/marketing/src/app/blog/solidjs-grid-column-resizing-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/solidjs-grid-filtering-implementation/page.tsx create mode 100644 apps/marketing/src/app/blog/solidjs-grid-row-selection-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/solidjs-tree-data-tables/page.tsx create mode 100644 apps/marketing/src/app/blog/svelte-grid-column-pinning-tutorial/page.tsx create mode 100644 apps/marketing/src/app/blog/svelte-grid-column-resizing-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/svelte-grid-filtering-implementation/page.tsx create mode 100644 apps/marketing/src/app/blog/svelte-grid-row-selection-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/svelte-tree-data-tables/page.tsx create mode 100644 apps/marketing/src/app/blog/topic/[framework]/page.tsx create mode 100644 apps/marketing/src/app/blog/vanilla-js-grid-column-pinning-tutorial/page.tsx create mode 100644 apps/marketing/src/app/blog/vanilla-js-grid-column-resizing-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/vanilla-js-grid-filtering-implementation/page.tsx create mode 100644 apps/marketing/src/app/blog/vanilla-js-grid-row-selection-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/vanilla-js-tree-data-tables/page.tsx create mode 100644 apps/marketing/src/app/blog/vue-grid-column-pinning-tutorial/page.tsx create mode 100644 apps/marketing/src/app/blog/vue-grid-filtering-implementation/page.tsx create mode 100644 apps/marketing/src/app/blog/vue-grid-row-selection-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/vue-table-column-resizing-guide/page.tsx create mode 100644 apps/marketing/src/app/blog/vue-tree-data-tables/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-ag-grid-angular/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-ngx-datatable/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-primeng-table/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-primevue-datatable/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-svelte-headless-table/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-tabulator-vanilla/page.tsx create mode 100644 apps/marketing/src/app/comparisons/simple-table-vs-vuetify/page.tsx create mode 100644 apps/marketing/src/app/examples/[framework]/[example]/page.tsx delete mode 100644 apps/marketing/src/app/examples/metadata.ts create mode 100644 apps/marketing/src/app/migrations/from-ag-grid-angular/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-grid-js/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-ngx-datatable/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-primevue-datatable/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-svelte-headless-table/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-tabulator/page.tsx create mode 100644 apps/marketing/src/app/migrations/from-vuetify-data-table/page.tsx create mode 100644 apps/marketing/src/app/sitemap.ts create mode 100644 apps/marketing/src/components/blog/CompetitorBlogLayout.tsx create mode 100644 apps/marketing/src/components/blog/FrameworkTutorialLayout.tsx create mode 100644 apps/marketing/src/components/blog/PillarPositioningArticle.tsx create mode 100644 apps/marketing/src/components/comparisons/FrameworkVsCompetitorLayout.tsx create mode 100644 apps/marketing/src/components/migrations/FromCompetitorMigrationLayout.tsx create mode 100644 apps/marketing/src/components/seo/DocsJsonLd.tsx create mode 100644 apps/marketing/src/constants/frameworkCompetitors.ts create mode 100644 packages/examples/angular/README.md create mode 100644 packages/examples/react/README.md create mode 100644 packages/examples/solid/README.md create mode 100644 packages/examples/svelte/README.md create mode 100644 packages/examples/vanilla/README.md create mode 100644 packages/examples/vue/README.md diff --git a/README.md b/README.md index 358c558c4..99118b6c2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,25 @@ Simple Table provides first-class adapters for the most popular frameworks: ## Quick Start +Pick the install for your stack: + ```bash +# React +npm install @simple-table/react + +# Vue 3 / Nuxt +npm install @simple-table/vue + +# Angular 17+ +npm install @simple-table/angular + +# Svelte / SvelteKit +npm install @simple-table/svelte + +# Solid / Solid Start +npm install @simple-table/solid + +# Vanilla JS / TypeScript / web components npm install simple-table-core ``` diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 4257606ab..5f4ce911c 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -13,7 +13,6 @@ "analyze": "ANALYZE=true next build --webpack", "start": "next start", "lint": "next lint", - "generate-sitemap": "node scripts/generate-sitemap.js", "generate-sales-data": "tsx scripts/generate-sales-data.ts", "generate-billing-data": "tsx scripts/generate-billing-data.ts", "generate-manufacturing-data": "tsx scripts/generate-manufacturing-data.ts", @@ -24,7 +23,7 @@ "generate-all-data": "pnpm run generate-sales-data && pnpm run generate-billing-data && pnpm run generate-manufacturing-data && pnpm run generate-infrastructure-data && pnpm run generate-hr-data && pnpm run generate-crm-data && pnpm run generate-music-data", "copy-to-txt": "node scripts/copy-to-txt.js", "generate-search-index": "node scripts/generate-search-index.cjs", - "publish": "pnpm install && pnpm run copy-to-txt && pnpm run generate-sitemap && git add . && git commit -m \"$npm_config_message\" && git push" + "publish": "pnpm install && pnpm run copy-to-txt && git add . && git commit -m \"$npm_config_message\" && git push" }, "dependencies": { "@ant-design/cssinjs": "^1.23.0", @@ -74,9 +73,7 @@ "eslint": "^9", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", - "glob": "^11.0.3", "node-fetch": "^3.3.2", - "sitemap": "^8.0.0", "ts-node": "^10.9.2", "tsx": "^4.19.4", "typescript-eslint": "^8.24.1" diff --git a/apps/marketing/public/llms.txt b/apps/marketing/public/llms.txt new file mode 100644 index 000000000..c297cbcdb --- /dev/null +++ b/apps/marketing/public/llms.txt @@ -0,0 +1,71 @@ +# Simple Table + +> A free, MIT-licensed data grid for React, Vue, Angular, Svelte, Solid, and vanilla TypeScript. One shared simple-table-core engine; six official npm adapters. Ships virtualization for 1M+ rows, column pinning, row grouping with aggregations, inline cell editing, custom renderers, and themeable CSS variables. + +Simple Table is positioned as a feature-complete free alternative to AG Grid (Enterprise), MUI X Data Grid (Pro), Handsontable, Tabulator, and TanStack Table. The core engine is framework-agnostic and runs the same logic across every adapter. + +## Frameworks and packages + +- React — `@simple-table/react` — peer: React 18+, works with Next.js (App Router and Pages), Remix, Vite. https://www.simple-table.com/frameworks/react +- Vue — `@simple-table/vue` — peer: Vue 3+, works with Nuxt 3/4 and Vite. https://www.simple-table.com/frameworks/vue +- Angular — `@simple-table/angular` — peer: Angular 17+ (standalone components), works with Angular CLI and Analog. https://www.simple-table.com/frameworks/angular +- Svelte — `@simple-table/svelte` — peer: Svelte 4 / Svelte 5 (runes), works with SvelteKit and Vite. https://www.simple-table.com/frameworks/svelte +- Solid — `@simple-table/solid` — peer: solid-js 1+, works with Solid Start and Vite. https://www.simple-table.com/frameworks/solid +- Vanilla TypeScript / Web Components — `simple-table-core` — no peer; bring-your-own DOM glue. https://www.simple-table.com/frameworks/vanilla + +## Install + +- npm install @simple-table/react +- npm install @simple-table/vue +- npm install @simple-table/angular +- npm install @simple-table/svelte +- npm install @simple-table/solid +- npm install simple-table-core + +## Pillar guides + +- React — Best free React data grid 2026: https://www.simple-table.com/blog/best-free-react-data-grid-2026 +- Vue / Nuxt — https://www.simple-table.com/blog/vue-nuxt-data-grid-simple-table +- Angular — https://www.simple-table.com/blog/angular-data-grid-simple-table +- SvelteKit — https://www.simple-table.com/blog/sveltekit-data-table-simple-table +- Solid.js — https://www.simple-table.com/blog/solidjs-data-grid-simple-table +- Vanilla TypeScript — https://www.simple-table.com/blog/vanilla-typescript-data-grid-simple-table-core + +## Comparisons (React-focused, vs. cross-stack incumbents) + +- vs AG Grid: https://www.simple-table.com/comparisons/simple-table-vs-ag-grid +- vs Handsontable: https://www.simple-table.com/comparisons/simple-table-vs-handsontable +- vs TanStack Table: https://www.simple-table.com/comparisons/simple-table-vs-tanstack +- vs Material React Table: https://www.simple-table.com/comparisons/simple-table-vs-material-react +- vs Ant Design Table: https://www.simple-table.com/comparisons/simple-table-vs-ant-design +- vs Tabulator: https://www.simple-table.com/comparisons/simple-table-vs-tabulator +- vs Syncfusion Grid: https://www.simple-table.com/comparisons/simple-table-vs-syncfusion + +## Stack-native alternatives Simple Table competes with + +- React: AG Grid React, TanStack Table, MUI X Data Grid, Material React Table, react-data-grid, Handsontable React, Kendo React Grid +- Vue: Vuetify v-data-table, PrimeVue DataTable, Vue Good Table, Element Plus el-table, Naive UI n-data-table, Quasar QTable, AG Grid Vue +- Angular: AG Grid Angular, ngx-datatable, PrimeNG Table, Angular Material MatTable, Kendo UI for Angular Grid, DevExtreme Angular Grid +- Svelte: svelte-headless-table, SVAR DataGrid, Flowbite-Svelte Table, Carbon Components Svelte DataTable +- Solid: TanStack Solid Table, Kobalte Table primitive +- Vanilla: Tabulator, Grid.js, jSpreadsheet, Handsontable Community, Webix DataTable, DataTables (jQuery) + +## License + +- Source-available, MIT for pre-revenue and bootstrapped projects. +- Pro / Enterprise tiers available for revenue-generating teams. +- License: https://www.simple-table.com/legal/license +- Pricing: https://www.simple-table.com/pricing + +## Documentation + +- Quick start: https://www.simple-table.com/docs/quick-start +- Installation: https://www.simple-table.com/docs/installation +- Full feature list: https://www.simple-table.com/docs/api-reference +- Examples (CRM, HR, billing, manufacturing, infrastructure, music, sales): https://www.simple-table.com/examples +- Theme builder: https://www.simple-table.com/theme-builder + +## Source + +- GitHub: https://github.com/petera2c/simple-table +- npm org: https://www.npmjs.com/org/simple-table diff --git a/apps/marketing/public/sitemap.xml b/apps/marketing/public/sitemap.xml deleted file mode 100644 index a28a10eae..000000000 --- a/apps/marketing/public/sitemap.xml +++ /dev/null @@ -1,578 +0,0 @@ - - - - https://www.simple-table.com/ - daily - 1.0 - - - https://www.simple-table.com/blog - weekly - 0.6 - - - https://www.simple-table.com/blog/ag-grid-alternatives-free-react-data-grids - monthly - 0.8 - - - https://www.simple-table.com/blog/ag-grid-pricing-license-breakdown-2026 - monthly - 0.8 - - - https://www.simple-table.com/blog/angular-data-grid-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/ant-design-table-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/auto-expand-columns-react-tables - monthly - 0.8 - - - https://www.simple-table.com/blog/best-free-react-data-grid-2026 - monthly - 0.8 - - - https://www.simple-table.com/blog/best-react-table-libraries-2026 - monthly - 0.8 - - - https://www.simple-table.com/blog/custom-footer-renderers-react-tables - monthly - 0.8 - - - https://www.simple-table.com/blog/custom-icons-react-data-grids - monthly - 0.8 - - - https://www.simple-table.com/blog/customizing-data-grids-styling-easy - monthly - 0.8 - - - https://www.simple-table.com/blog/customizing-react-table-look-simple-table-themes - monthly - 0.8 - - - https://www.simple-table.com/blog/devextreme-grid-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/editable-react-data-grids-in-cell-vs-form-editing - monthly - 0.8 - - - https://www.simple-table.com/blog/free-alternative-to-ag-grid - monthly - 0.8 - - - https://www.simple-table.com/blog/handling-one-million-rows - monthly - 0.8 - - - https://www.simple-table.com/blog/handsontable-alternatives-free-react - monthly - 0.8 - - - https://www.simple-table.com/blog/handsontable-pricing-breakdown-2026 - monthly - 0.8 - - - https://www.simple-table.com/blog/ka-table-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/kendoreact-grid-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/mantine-datatable-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/material-react-table-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/mit-licensed-react-tables-accessibility-keyboard-navigation - monthly - 0.8 - - - https://www.simple-table.com/blog/mobile-compatibility-react-tables - monthly - 0.8 - - - https://www.simple-table.com/blog/mui-datatables-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/nested-headers-react-tables - monthly - 0.8 - - - https://www.simple-table.com/blog/nested-tables-react-hierarchical-data - monthly - 0.8 - - - https://www.simple-table.com/blog/react-data-grid-bundle-size-comparison - monthly - 0.8 - - - https://www.simple-table.com/blog/react-data-table-component-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/react-grid-column-pinning-tutorial - monthly - 0.8 - - - https://www.simple-table.com/blog/react-grid-filtering-implementation - monthly - 0.8 - - - https://www.simple-table.com/blog/react-table-column-resizing-guide - monthly - 0.8 - - - https://www.simple-table.com/blog/react-table-library-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/react-table-row-selection-guide - monthly - 0.8 - - - https://www.simple-table.com/blog/react-tree-data-hierarchical-tables - monthly - 0.8 - - - https://www.simple-table.com/blog/replicating-gojiberry-ui-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/rsuite-table-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/smart-grid-vs-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/solidjs-data-grid-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/sveltekit-data-table-simple-table - monthly - 0.8 - - - https://www.simple-table.com/blog/tabulator-alternatives-react-2026 - monthly - 0.8 - - - https://www.simple-table.com/blog/tanstack-table-vs-ag-grid-comparison - monthly - 0.8 - - - https://www.simple-table.com/blog/tanstack-table-vs-simple-table-headless-batteries-included - monthly - 0.8 - - - https://www.simple-table.com/blog/vanilla-typescript-data-grid-simple-table-core - monthly - 0.8 - - - https://www.simple-table.com/blog/vue-nuxt-data-grid-simple-table - monthly - 0.8 - - - https://www.simple-table.com/case-studies/chartmetric - monthly - 0.9 - - - https://www.simple-table.com/changelog - weekly - 0.6 - - - https://www.simple-table.com/comparisons/simple-table-vs-ag-grid - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-ant-design - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-handsontable - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-material-react - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-syncfusion - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-tabulator - weekly - 0.9 - - - https://www.simple-table.com/comparisons/simple-table-vs-tanstack - weekly - 0.9 - - - https://www.simple-table.com/docs/aggregate-functions - weekly - 0.8 - - - https://www.simple-table.com/docs/cell-clicking - weekly - 0.8 - - - https://www.simple-table.com/docs/cell-editing - weekly - 0.8 - - - https://www.simple-table.com/docs/cell-highlighting - weekly - 0.8 - - - https://www.simple-table.com/docs/cell-renderer - weekly - 0.8 - - - https://www.simple-table.com/docs/chart-columns - weekly - 0.8 - - - https://www.simple-table.com/docs/collapsible-columns - weekly - 0.8 - - - https://www.simple-table.com/docs/column-alignment - weekly - 0.8 - - - https://www.simple-table.com/docs/column-editing - weekly - 0.8 - - - https://www.simple-table.com/docs/column-filtering - weekly - 0.8 - - - https://www.simple-table.com/docs/column-pinning - weekly - 0.8 - - - https://www.simple-table.com/docs/column-reordering - weekly - 0.8 - - - https://www.simple-table.com/docs/column-resizing - weekly - 0.8 - - - https://www.simple-table.com/docs/column-selection - weekly - 0.8 - - - https://www.simple-table.com/docs/column-sorting - weekly - 0.8 - - - https://www.simple-table.com/docs/column-visibility - weekly - 0.8 - - - https://www.simple-table.com/docs/column-width - weekly - 0.8 - - - https://www.simple-table.com/docs/csv-export - weekly - 0.8 - - - https://www.simple-table.com/docs/custom-icons - weekly - 0.8 - - - https://www.simple-table.com/docs/custom-theme - weekly - 0.8 - - - https://www.simple-table.com/docs/empty-state - weekly - 0.8 - - - https://www.simple-table.com/docs/footer-renderer - weekly - 0.8 - - - https://www.simple-table.com/docs/header-renderer - weekly - 0.8 - - - https://www.simple-table.com/docs/infinite-scroll - weekly - 0.8 - - - https://www.simple-table.com/docs/installation - weekly - 0.8 - - - https://www.simple-table.com/docs/live-updates - weekly - 0.8 - - - https://www.simple-table.com/docs/loading-state - weekly - 0.8 - - - https://www.simple-table.com/docs/nested-headers - weekly - 0.8 - - - https://www.simple-table.com/docs/nested-tables - weekly - 0.8 - - - https://www.simple-table.com/docs/pagination - weekly - 0.8 - - - https://www.simple-table.com/docs/programmatic-control - weekly - 0.8 - - - https://www.simple-table.com/docs/quick-filter - weekly - 0.8 - - - https://www.simple-table.com/docs/quick-start - weekly - 0.8 - - - https://www.simple-table.com/docs/row-grouping - weekly - 0.8 - - - https://www.simple-table.com/docs/row-height - weekly - 0.8 - - - https://www.simple-table.com/docs/row-selection - weekly - 0.8 - - - https://www.simple-table.com/docs/table-height - weekly - 0.8 - - - https://www.simple-table.com/docs/themes - weekly - 0.8 - - - https://www.simple-table.com/docs/tooltips - weekly - 0.8 - - - https://www.simple-table.com/docs/value-formatter - weekly - 0.8 - - - https://www.simple-table.com/examples/billing - weekly - 0.7 - - - https://www.simple-table.com/examples/crm - weekly - 0.7 - - - https://www.simple-table.com/examples/hr - weekly - 0.7 - - - https://www.simple-table.com/examples/infrastructure - weekly - 0.7 - - - https://www.simple-table.com/examples/manufacturing - weekly - 0.7 - - - https://www.simple-table.com/examples/music - weekly - 0.7 - - - https://www.simple-table.com/examples/sales - weekly - 0.7 - - - https://www.simple-table.com/frameworks - weekly - 0.8 - - - https://www.simple-table.com/frameworks/angular - weekly - 0.8 - - - https://www.simple-table.com/frameworks/react - weekly - 0.8 - - - https://www.simple-table.com/frameworks/solid - weekly - 0.8 - - - https://www.simple-table.com/frameworks/svelte - weekly - 0.8 - - - https://www.simple-table.com/frameworks/vanilla - weekly - 0.8 - - - https://www.simple-table.com/frameworks/vue - weekly - 0.8 - - - https://www.simple-table.com/legal/eula - monthly - 0.5 - - - https://www.simple-table.com/legal/license - monthly - 0.5 - - - https://www.simple-table.com/migrations/v2-4-1 - weekly - 0.6 - - - https://www.simple-table.com/migrations/v3 - weekly - 0.6 - - - https://www.simple-table.com/pricing - weekly - 0.6 - - - https://www.simple-table.com/theme-builder - weekly - 0.6 - - \ No newline at end of file diff --git a/apps/marketing/sandbox-links.json b/apps/marketing/sandbox-links.json index aac49ab5b..bbbbc6f06 100644 --- a/apps/marketing/sandbox-links.json +++ b/apps/marketing/sandbox-links.json @@ -1,5 +1,34 @@ [ { - "name": "quick-start" + "name": "quick-start", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "billing", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "crm", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "hr", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "infrastructure", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "manufacturing", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "music", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] + }, + { + "name": "sales", + "frameworks": ["react", "vue", "angular", "svelte", "solid", "vanilla"] } -] \ No newline at end of file +] diff --git a/apps/marketing/scripts/generate-sitemap.js b/apps/marketing/scripts/generate-sitemap.js deleted file mode 100644 index 9aaa118b0..000000000 --- a/apps/marketing/scripts/generate-sitemap.js +++ /dev/null @@ -1,170 +0,0 @@ -import { SitemapStream, streamToPromise } from "sitemap"; -import { createWriteStream } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, resolve } from "path"; -import { glob } from "glob"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const baseUrl = "https://www.simple-table.com"; - -// Static routes under src/app (including blog/*/page.tsx) are discovered automatically. -// Dynamic segments like [framework] are excluded here and merged in via frameworkHubRoutes. -async function getNextJsRoutes() { - const appDir = resolve(__dirname, "../src/app"); - const routes = await glob("**/page.{tsx,jsx,js,ts}", { cwd: appDir }); - - const frameworkHubRoutes = ["react", "vue", "angular", "svelte", "solid", "vanilla"].map( - (id) => `frameworks/${id}` - ); - - const processedRoutes = routes - .map((route) => { - // Convert file path to URL path - return route - .replace(/\/page\.[jt]sx?$/, "") // Remove page.tsx - .replace(/\/\([^)]+\)/g, "") // Remove route groups - .replace(/index$/, "") // Remove index from path - .replace(/^page\.tsx$/, ""); // Handle root page.tsx - }) - .filter((route) => { - // Filter out special Next.js routes, dynamic routes, and unwanted pages - return ( - !route.startsWith("_") && - !route.includes("api") && - !route.includes("[") && // Filter out dynamic routes - !route.includes("not-found") && // Filter out error pages - !route.includes("mobile-unsupported") // Filter out mobile unsupported page - ); - }) - .sort(); // Sort routes alphabetically - - // Always include the root route - if (!processedRoutes.includes("")) { - processedRoutes.unshift(""); // Add root route at the beginning - } - - const merged = [...new Set([...processedRoutes, ...frameworkHubRoutes])]; - return merged; -} - -// Function to format XML with proper indentation -function formatXML(xml) { - // Remove any existing XML declaration - const xmlWithoutDeclaration = xml.replace(/<\?xml[^>]*\?>\s*/, ""); - - const formatted = xmlWithoutDeclaration - .replace(//g, "\n ") - .replace(/<\/url>/g, "\n ") - .replace(//g, "\n ") - .replace(/<\/loc>/g, "") - .replace(//g, "\n ") - .replace(/<\/changefreq>/g, "") - .replace(//g, "\n ") - .replace(/<\/priority>/g, "") - .replace(//g, "\n ") - .replace(/<\/lastmod>/g, "") - .replace(//g, "\n"); - - // Add XML declaration at the start - return '\n' + formatted; -} - -async function generateSitemap() { - try { - const sitemap = new SitemapStream({ - hostname: baseUrl, - xmlns: { - news: true, - xhtml: true, - image: true, - video: true, - }, - }); - - // Get all routes - const nextJsRoutes = await getNextJsRoutes(); - - // Extract route categories for reporting - const blogRoutes = nextJsRoutes.filter((route) => route.startsWith("blog/")).sort(); - const examplesRoutes = nextJsRoutes.filter((route) => route.startsWith("examples/")).sort(); - const docsRoutes = nextJsRoutes.filter((route) => route.startsWith("docs/")).sort(); - - // Create and sort all sitemap entries to ensure consistent order - const sitemapEntries = []; - - // Add Next.js routes with appropriate priorities - nextJsRoutes.forEach((route) => { - const routeConfig = { - url: route || "/", // Handle root route - changefreq: "weekly", - priority: 0.6, - }; - - if (route === "") { - routeConfig.priority = 1.0; - routeConfig.changefreq = "daily"; - } else if (route.startsWith("blog/")) { - routeConfig.priority = 0.8; - routeConfig.changefreq = "monthly"; - } else if (route.startsWith("comparisons/")) { - routeConfig.priority = 0.9; - routeConfig.changefreq = "weekly"; - } else if (route.startsWith("case-studies/")) { - routeConfig.priority = 0.9; - routeConfig.changefreq = "monthly"; - } else if (route.startsWith("docs/")) { - routeConfig.priority = 0.8; - routeConfig.changefreq = "weekly"; - } else if (route === "frameworks" || route.startsWith("frameworks/")) { - routeConfig.priority = 0.75; - routeConfig.changefreq = "weekly"; - } else if (route.startsWith("examples/")) { - routeConfig.priority = 0.7; - routeConfig.changefreq = "weekly"; - } else if (route.startsWith("legal/")) { - routeConfig.priority = 0.5; - routeConfig.changefreq = "monthly"; - } - - sitemapEntries.push(routeConfig); - }); - - // Sort all entries by URL for consistent ordering - sitemapEntries.sort((a, b) => { - // Always put homepage first - if (a.url === "/" || a.url === "") return -1; - if (b.url === "/" || b.url === "") return 1; - - return a.url.localeCompare(b.url); - }); - - // Write sorted entries to sitemap - sitemapEntries.forEach((entry) => { - sitemap.write(entry); - }); - - sitemap.end(); - - // Generate the sitemap XML - const xml = await streamToPromise(sitemap); - const formattedXML = formatXML(xml.toString()); - - // Write the sitemap to the public directory - const outputPath = resolve(__dirname, "../public/sitemap.xml"); - createWriteStream(outputPath).write(formattedXML); - - console.log("✅ Sitemap generated successfully!"); - console.log(`📊 Found ${nextJsRoutes.length} total routes`); - console.log( - `📊 Blog posts: ${blogRoutes.length}, Examples: ${examplesRoutes.length}, Docs: ${docsRoutes.length}` - ); - } catch (error) { - console.error("❌ Error generating sitemap:", error); - process.exit(1); - } -} - -generateSitemap(); diff --git a/apps/marketing/src/app/blog/BlogSegmentChrome.tsx b/apps/marketing/src/app/blog/BlogSegmentChrome.tsx index 7a4cceb06..a7a83ba84 100644 --- a/apps/marketing/src/app/blog/BlogSegmentChrome.tsx +++ b/apps/marketing/src/app/blog/BlogSegmentChrome.tsx @@ -2,19 +2,66 @@ import { usePathname } from "next/navigation"; import OtherFrameworksCallout from "@/components/OtherFrameworksCallout"; -import { shouldShowOtherFrameworksCallout } from "@/constants/blogPosts"; +import { + getBlogPostBySlug, + getPostFrameworkId, + shouldShowOtherFrameworksCallout, +} from "@/constants/blogPosts"; +import { FRAMEWORK_HUB_PILLAR_BLOG_SLUG } from "@/constants/frameworkPillarBlogs"; +import { + buildBreadcrumbListJsonLd, + buildTechArticleJsonLd, +} from "@/utils/structuredData"; + +const PILLAR_SLUGS_WITH_OWN_JSONLD = new Set( + Object.entries(FRAMEWORK_HUB_PILLAR_BLOG_SLUG) + .filter(([id]) => id !== "react") + .map(([, slug]) => slug) +); export default function BlogSegmentChrome({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const segment = pathname.replace(/^\/?blog\/?/, "").split("/")[0] ?? ""; const showCallout = shouldShowOtherFrameworksCallout(segment); + const frameworkId = getPostFrameworkId(segment); + + const post = segment ? getBlogPostBySlug(segment) : undefined; + const shouldEmitJsonLd = !!post && !PILLAR_SLUGS_WITH_OWN_JSONLD.has(segment); + const article = shouldEmitJsonLd + ? buildTechArticleJsonLd({ + title: post.title, + description: post.description, + canonicalPath: `/blog/${post.slug}`, + datePublished: post.createdAt, + dateModified: post.updatedAt, + }) + : null; + const breadcrumbs = shouldEmitJsonLd + ? buildBreadcrumbListJsonLd([ + { name: "Home", url: "/" }, + { name: "Blog", url: "/blog" }, + { name: post.title, url: `/blog/${post.slug}` }, + ]) + : null; return ( <> + {article ? ( +