1052 lines
41 KiB
TypeScript
1052 lines
41 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useId,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
import type { ReactNode } from "react"
|
|
import {
|
|
mergeDataGridI18n,
|
|
type DataGridI18nConfig,
|
|
type DataGridI18nOverrides,
|
|
} from "@/components/reui/data-grid/data-grid-i18n"
|
|
import {
|
|
cellSelectionFeature,
|
|
columnFacetingFeature,
|
|
columnFilteringFeature,
|
|
columnOrderingFeature,
|
|
columnPinningFeature,
|
|
columnResizingFeature,
|
|
columnSizingFeature,
|
|
columnVisibilityFeature,
|
|
createExpandedRowModel,
|
|
createFacetedRowModel,
|
|
createFacetedUniqueValues,
|
|
createFilteredRowModel,
|
|
createPaginatedRowModel,
|
|
createSortedRowModel,
|
|
globalFilteringFeature,
|
|
metaHelper,
|
|
rowExpandingFeature,
|
|
rowPaginationFeature,
|
|
rowPinningFeature,
|
|
rowSelectionFeature,
|
|
rowSortingFeature,
|
|
sortFn_alphanumeric,
|
|
sortFn_alphanumericCaseSensitive,
|
|
sortFn_basic,
|
|
sortFn_datetime,
|
|
sortFn_text,
|
|
sortFn_textCaseSensitive,
|
|
tableFeatures,
|
|
} from "@tanstack/react-table"
|
|
import type {
|
|
Cell,
|
|
Column,
|
|
ColumnFiltersState,
|
|
ReactTable,
|
|
RowData,
|
|
SortingState,
|
|
Table,
|
|
TableFeatures,
|
|
} from "@tanstack/react-table"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/**
|
|
* Per-column extras the grid reads off `columnDef.meta`.
|
|
*
|
|
* TanStack v9 resolves this through the `columnMeta` slot on the feature
|
|
* bundle below instead of a global `declare module` augmentation, so
|
|
* installing the data grid no longer widens `ColumnMeta` for every other
|
|
* table in the consuming app.
|
|
*/
|
|
/**
|
|
* Per-column write contract for the spreadsheet operations (paste, fill,
|
|
* clear, cut). Presence marks the column writable; every write still flows
|
|
* through `onCellsChange`, so the consumer's state stays the only data owner.
|
|
*/
|
|
export interface DataGridColumnCellEdit<TData> {
|
|
/**
|
|
* Set false for a column that formats clipboard output but is never
|
|
* written; a function decides per row, so locked rows (archived, another
|
|
* user's, a totals row) reject like any read-only cell. Defaults to true.
|
|
*/
|
|
editable?: boolean | ((row: TData) => boolean)
|
|
/**
|
|
* Clipboard or fill string to typed value. Return undefined to reject the
|
|
* cell into `rejected`. Without it the raw string passes through unchanged -
|
|
* a deliberate non-coercion, so a number column receives strings only when
|
|
* the consumer chose not to parse.
|
|
*/
|
|
parse?: (raw: string, row: TData) => unknown
|
|
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
|
|
format?: (value: unknown, row: TData) => string
|
|
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
|
|
clearValue?: unknown
|
|
/**
|
|
* Opt into the grid's built-in free-text editor: an overlay input (or
|
|
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
|
|
* typing, or double-click, committing through `onCellsChange` as source
|
|
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
|
|
* via `onCellEditRequest` or their own cell renderers.
|
|
*/
|
|
control?: "text" | "textarea"
|
|
}
|
|
|
|
export interface DataGridColumnMeta<TData> {
|
|
headerTitle?: string
|
|
headerClassName?: string
|
|
cellClassName?: string
|
|
skeleton?: ReactNode
|
|
expandedContent?: (row: TData) => ReactNode
|
|
autoSize?: boolean
|
|
cellEdit?: DataGridColumnCellEdit<TData>
|
|
/**
|
|
* Under `columnsResizable`, this column absorbs the free space the filler
|
|
* strip would otherwise hold, so the grid always reads full-width and the
|
|
* built-in editor covers the whole cell. One column per grid; while free
|
|
* space remains, manual resizing of this column is visually a no-op (the
|
|
* absorbed space compensates), the flex-column trade-off.
|
|
*/
|
|
fillWidth?: boolean
|
|
}
|
|
|
|
/**
|
|
* The batteries-included feature bundle every ReUI data-grid example builds
|
|
* on. v9 requires each table to declare its features up front, and the grid's
|
|
* render path needs the ones registered here: `columnVisibilityFeature` alone
|
|
* gates `row.getVisibleCells()`, so even a grid that never hides a column
|
|
* needs it to render at all.
|
|
*
|
|
* Pass it straight through for the full grid:
|
|
*
|
|
* ```tsx
|
|
* const table = useTable({ features: dataGridFeatures, columns, data })
|
|
* ```
|
|
*
|
|
* Extend it when a grid needs more, keeping each prerequisite feature ahead of
|
|
* the slot that depends on it:
|
|
*
|
|
* ```tsx
|
|
* const features = tableFeatures({
|
|
* ...dataGridFeatures,
|
|
* columnGroupingFeature,
|
|
* groupedRowModel: createGroupedRowModel(),
|
|
* })
|
|
* ```
|
|
*
|
|
* Or drop it entirely and hand `<DataGrid>` a leaner table - the components
|
|
* accept any bundle, so you keep full ownership of the TanStack core.
|
|
*/
|
|
export const dataGridFeatures = tableFeatures({
|
|
columnVisibilityFeature,
|
|
columnOrderingFeature,
|
|
columnPinningFeature,
|
|
columnSizingFeature,
|
|
// columnResizingFeature requires columnSizingFeature, declared above.
|
|
columnResizingFeature,
|
|
columnFilteringFeature,
|
|
// Powers DataGridColumnFilter's column.getFacetedUniqueValues(). On v8 an
|
|
// unregistered facet silently returned an empty map; on v9 the method would
|
|
// not exist at all, so the faceted row models below are required, not
|
|
// optional.
|
|
columnFacetingFeature,
|
|
// globalFilteringFeature requires columnFilteringFeature, declared above.
|
|
globalFilteringFeature,
|
|
rowSortingFeature,
|
|
rowPaginationFeature,
|
|
rowSelectionFeature,
|
|
rowExpandingFeature,
|
|
rowPinningFeature,
|
|
// Registration alone is inert: it seeds a `cellSelection: []` slice and
|
|
// prototype methods but binds no DOM handlers, so grids without
|
|
// `tableLayout.cellSelection` render and behave exactly as before.
|
|
cellSelectionFeature,
|
|
sortedRowModel: createSortedRowModel(),
|
|
filteredRowModel: createFilteredRowModel(),
|
|
paginatedRowModel: createPaginatedRowModel(),
|
|
expandedRowModel: createExpandedRowModel(),
|
|
facetedRowModel: createFacetedRowModel(),
|
|
facetedUniqueValues: createFacetedUniqueValues(),
|
|
// Every built-in v9 ships. A string `sortFn` resolves against this map
|
|
// alone, and `sortFn: "auto"` infers a name ("alphanumeric", "text" or
|
|
// "datetime") from the first row's value - so a partial map makes auto
|
|
// sorting warn and silently fall back on ordinary string columns.
|
|
sortFns: {
|
|
alphanumeric: sortFn_alphanumeric,
|
|
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
|
|
basic: sortFn_basic,
|
|
datetime: sortFn_datetime,
|
|
text: sortFn_text,
|
|
textCaseSensitive: sortFn_textCaseSensitive,
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
columnMeta: metaHelper<DataGridColumnMeta<any>>(),
|
|
})
|
|
|
|
/** The feature set `dataGridFeatures` registers. */
|
|
export type DataGridFeatures = typeof dataGridFeatures
|
|
|
|
/**
|
|
* The grid's internal view of the table.
|
|
*
|
|
* `TFeatures` is invariant in v9 and an unresolved generic one collapses to a
|
|
* union that includes the bare core arm, so no generic signature can call
|
|
* `getVisibleCells()`, `getStartVisibleLeafColumns()` and friends. The public
|
|
* components stay generic so consumers can pass any bundle they like; the
|
|
* table is widened to this concrete type exactly once, on the way into
|
|
* context, and every internal component reads it from there.
|
|
*/
|
|
export type DataGridTableInstance<TData extends object> = ReactTable<
|
|
DataGridFeatures,
|
|
TData
|
|
>
|
|
|
|
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
|
export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
|
column: Column<DataGridFeatures, TData, TValue>
|
|
): string {
|
|
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
|
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
|
const defHeader = column.columnDef.header
|
|
if (typeof defHeader === "string") return defHeader
|
|
return String(column.id)
|
|
}
|
|
|
|
/**
|
|
* The td contract for spreadsheet selection. Call inside a
|
|
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
|
|
* builder calls whose state dependency React Compiler cannot see, exactly the
|
|
* trap the row-selection checkbox documents.
|
|
*/
|
|
export function getDataGridCellSelectionCellAttrs<TData extends object>(
|
|
cell: Cell<DataGridFeatures, TData, unknown>
|
|
): {
|
|
"aria-selected": boolean
|
|
"data-col-id": string
|
|
"data-cell-selected": true | undefined
|
|
"data-cell-focused": true | undefined
|
|
"data-cell-edge-top": true | undefined
|
|
"data-cell-edge-right": true | undefined
|
|
"data-cell-edge-bottom": true | undefined
|
|
"data-cell-edge-left": true | undefined
|
|
} {
|
|
const selected = cell.getIsSelected()
|
|
const edges = cell.getSelectionEdges()
|
|
return {
|
|
"aria-selected": selected,
|
|
"data-col-id": cell.column.id,
|
|
"data-cell-selected": selected || undefined,
|
|
"data-cell-focused": cell.getIsFocused() || undefined,
|
|
"data-cell-edge-top": edges.top || undefined,
|
|
"data-cell-edge-right": edges.right || undefined,
|
|
"data-cell-edge-bottom": edges.bottom || undefined,
|
|
"data-cell-edge-left": edges.left || undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Selection chrome for a body td, activated by the data attributes above plus
|
|
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
|
|
* outside React like the resize indicator). A ::before overlay, not outline
|
|
* or box-shadow: an outline ring hugs the cell box and cannot line up with
|
|
* the gridlines the perimeter paints on, and the pinned-column dividers
|
|
* already own the cell's shadow slot (tailwind-merge would collapse a second
|
|
* shadow-[...] into it).
|
|
*/
|
|
export const dataGridCellSelectionCellClasses = cn(
|
|
"relative select-none",
|
|
// A light tint: the selection must read as a range without drowning the
|
|
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
|
|
// cells must stay OPAQUE (they hide scrolled content), so they get the
|
|
// tint pre-mixed over the background, and the focused pinned cell keeps
|
|
// a solid background instead of turning transparent.
|
|
"data-[cell-selected]:bg-primary/4",
|
|
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
|
|
"data-[cell-focused]:bg-transparent!",
|
|
"data-[cell-focused]:data-pinned:bg-background!",
|
|
// Every selection line is one layout-free ::before overlay per cell. It
|
|
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
|
|
// gridline it replaces: the range perimeter and the interior dividers
|
|
// share this geometry, so where they meet they coincide instead of
|
|
// stacking into a wider edge, every side stays exactly 1px, and
|
|
// selecting never adds real borders that shift layout. At the table's
|
|
// own boundary there is no shared gridline and the overshoot leaves the
|
|
// scrollable content box, where it is CLIPPED - the perimeter simply
|
|
// vanished on the first column, last column and last row. So every
|
|
// overshoot that can hit a boundary goes through a variable the
|
|
// boundary cells zero out. Longhand inset utilities only, logical
|
|
// start/end
|
|
// so RTL mirrors for free; a separate override rule cannot do this,
|
|
// equal-specificity variants leave shorthand-vs-longhand to sort order.
|
|
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
|
|
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
|
|
// Interior dividers: selected cells repaint their own end and bottom
|
|
// gridlines on the overlay, so gridline-less grids still divide a range.
|
|
// The gray color is guarded by not- variants (higher specificity), so a
|
|
// side that belongs to the range perimeter deterministically stays
|
|
// primary.
|
|
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
|
|
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
|
|
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
|
|
// The Sheets model: a single selected cell IS its own range, so the four
|
|
// edge attributes below already draw its full primary box; inside a
|
|
// larger range the anchor reads by its unfilled background alone. The
|
|
// explicit ring only covers a focused cell with no selection at all.
|
|
// Edge sides are logical (border-e/border-s): the feature derives edges
|
|
// from display-order column indexes, which mirror in RTL.
|
|
"data-[cell-focused]:not-data-[cell-selected]:before:border",
|
|
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
|
|
// While a fill drag is live the preview border is the ONE painter for
|
|
// the whole pending region, so the source cells' own chrome rests -
|
|
// otherwise its lines double against the region border at every shared
|
|
// edge. display is the no-cascade-fight switch (see below).
|
|
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
|
|
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
|
|
// While the built-in editor is open its outline is the one border; the
|
|
// edited cell's own overlay chrome hides so nothing peeks around the
|
|
// flush editor. display is the switch because no other before: rule
|
|
// sets it, so there is no cascade order to lose.
|
|
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
|
|
// Clamped to the cell edge at the grid's own boundary, the overlay's
|
|
// lines sit flush against the container border instead of being clipped
|
|
// away outside the scrollable content. A custom property has no cascade
|
|
// fight to lose. First/last-child cover the boundary columns: when a
|
|
// filler td follows the last data column, that cell's overshoot lands
|
|
// INSIDE the table and needs no clamp, and the td:first/last-child of a
|
|
// row are its logical start/end in RTL too.
|
|
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
|
|
// The top overshoot lands under a sticky header, which is positioned and
|
|
// paints over it - so the first row's perimeter lost its top line. Clamp
|
|
// inside the cell there, exactly like the other three boundaries.
|
|
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
|
|
"first:[--data-grid-overlay-start:0px]",
|
|
"last:[--data-grid-overlay-end:0px]",
|
|
// A pinned column is a boundary like the grid's own edges: the sticky
|
|
// neighbor would cover an on-gridline overlay line, and re-painting it
|
|
// from the pinned side doubles into a 2px blur whenever subpixel
|
|
// positions disagree (browser zoom, fractional fill widths). One
|
|
// painter instead: the overlay clamps inside its own cell there, the
|
|
// same custom-property clamp the first and last cells use.
|
|
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
|
|
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
|
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
|
|
// Tint only: the dashed region outline is one overlay element drawn by
|
|
// the fill session, so neighboring target cells never double their edges.
|
|
"data-[cell-fill-target]:bg-primary/4",
|
|
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
|
|
)
|
|
|
|
export type DataGridApiFetchParams = {
|
|
pageIndex: number
|
|
pageSize: number
|
|
sorting?: SortingState
|
|
filters?: ColumnFiltersState
|
|
searchQuery?: string
|
|
}
|
|
|
|
export type DataGridApiResponse<T> = {
|
|
data: T[]
|
|
empty: boolean
|
|
pagination: {
|
|
total: number
|
|
page: number
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Everything `<DataGrid>` accepts except the two props the provider consumes
|
|
* itself. Kept feature-agnostic: layout and messaging never depend on which
|
|
* TanStack features the consumer registered.
|
|
*/
|
|
export type DataGridLayoutProps<TData extends object> = Omit<
|
|
DataGridProps<TableFeatures, TData>,
|
|
"table" | "children"
|
|
>
|
|
|
|
export interface DataGridContextProps<TData extends object> {
|
|
props: DataGridLayoutProps<TData>
|
|
table: DataGridTableInstance<TData>
|
|
recordCount: number
|
|
isLoading: boolean
|
|
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
|
|
gridId: string
|
|
/** The merged label set; components read `i18n.labels.<key>`. */
|
|
i18n: DataGridI18nConfig
|
|
/**
|
|
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
|
* so every table variant and viewport instance shares one application state.
|
|
*/
|
|
autoSize?: DataGridAutoSizeController
|
|
}
|
|
|
|
/**
|
|
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
|
|
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
|
|
* renderers always agree on the same encoding.
|
|
*/
|
|
export function toDataGridDomId(value: string): string {
|
|
return value.replace(/[^\w-]/g, "_")
|
|
}
|
|
|
|
export type DataGridAutoSizeController = {
|
|
/**
|
|
* Feeds the controller one viewport measurement: the SIGNED free space
|
|
* between the scroll container and the table, negative while the table
|
|
* overflows. The first sight of a `meta.autoSize` column absorbs the
|
|
* free space into its width; after that, every measurement REFLOWS the
|
|
* same growth - a wider window widens the column, a narrower one gives
|
|
* the space back down to the column's `minSize` (or its starting width
|
|
* when none is set) - so the grid tracks its container live. Commits
|
|
* are coalesced for performance: sub-pixel deltas are dropped, a
|
|
* streaming resize gets one leading commit plus one trailing commit at
|
|
* settle, and a width the user dragged is never touched. Safe to call
|
|
* from every viewport measurement; returns true when a sizing update
|
|
* was dispatched.
|
|
*/
|
|
apply: (freeSpace: number) => boolean
|
|
}
|
|
|
|
type DataGridAutoSizeReflowState = {
|
|
applied: { columnId: string; base: number; grown: number } | null
|
|
settleTimer: ReturnType<typeof setTimeout> | null
|
|
pendingFreeSpace: number
|
|
lastCommitAt: number
|
|
}
|
|
|
|
/**
|
|
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
|
|
* StrictMode double-mounts the tree, so two controller instances serve one
|
|
* page and only the first would remember what it absorbed - the second
|
|
* reads the grown width as a user drag and freezes the column. The store
|
|
* object is the one identity that provably survives that dance (the
|
|
* absorbed sizing rides inside it), and the WeakMap lets a discarded
|
|
* table take its bookkeeping with it.
|
|
*/
|
|
const dataGridAutoSizeStates = new WeakMap<
|
|
object,
|
|
DataGridAutoSizeReflowState
|
|
>()
|
|
|
|
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
|
|
let state = dataGridAutoSizeStates.get(store)
|
|
if (!state) {
|
|
state = {
|
|
applied: null,
|
|
settleTimer: null,
|
|
pendingFreeSpace: 0,
|
|
lastCommitAt: 0,
|
|
}
|
|
dataGridAutoSizeStates.set(store, state)
|
|
}
|
|
return state
|
|
}
|
|
|
|
function createDataGridAutoSizeController<TData extends object>(
|
|
/**
|
|
* A getter, not the table itself.
|
|
*
|
|
* v8 handed back one stable table whose state mutated in place, so a
|
|
* controller could close over it. v9 returns a NEW table wrapper on every
|
|
* state change, and a captured one keeps reporting the state it was built
|
|
* with - here that meant `columnSizing` looked permanently empty, the
|
|
* applied-once guard re-armed on every measurement, and the fill overwrote
|
|
* whatever width the user had just dragged the column to.
|
|
*/
|
|
getTable: () => DataGridTableInstance<TData>
|
|
): DataGridAutoSizeController {
|
|
// Reflow coalescing: a live window drag streams a measurement per frame,
|
|
// and every accepted commit re-renders the table. One leading commit
|
|
// keeps single snaps (a maximize) instant; the rest of the stream parks
|
|
// its latest value behind one trailing timer, so a continuous drag costs
|
|
// two commits instead of sixty a second. The trailing timer may fire
|
|
// after the grid unmounts; it re-reads the table then, and a sizing
|
|
// write to the consumer's store is inert once nothing renders it.
|
|
const SETTLE_MS = 150
|
|
|
|
const reflow = (freeSpace: number): boolean => {
|
|
const table = getTable()
|
|
const state = getDataGridAutoSizeState(table.store)
|
|
// LIVE state, never the render snapshot: a measurement can run between
|
|
// our own sizing commit and React's re-render, and the stale snapshot
|
|
// would hide the width this controller just wrote.
|
|
const columnSizing =
|
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
|
if (!state.applied) return false
|
|
const autoSizeColumn = table
|
|
.getVisibleLeafColumns()
|
|
.find(
|
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
|
)
|
|
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
|
|
return false
|
|
// A width this coordinator did not write belongs to the user's drag;
|
|
// reflow stands down until a reset re-arms the column.
|
|
const currentSize = columnSizing[state.applied.columnId]
|
|
if (currentSize !== undefined && currentSize !== state.applied.grown) {
|
|
return false
|
|
}
|
|
// The free space as it would measure with our growth removed; the
|
|
// target re-absorbs exactly that, floored at minSize so a narrow
|
|
// window hands space back without ever crushing the column (no
|
|
// explicit minSize floors at the starting width instead).
|
|
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
|
|
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
|
|
const target = Math.max(floor, state.applied.base + freeAtBase)
|
|
if (Math.abs(target - state.applied.grown) < 1) return false
|
|
state.applied = { ...state.applied, grown: target }
|
|
table.setColumnSizing((old) => ({
|
|
...old,
|
|
[state.applied!.columnId]: target,
|
|
}))
|
|
return true
|
|
}
|
|
|
|
return {
|
|
apply(freeSpace: number) {
|
|
const table = getTable()
|
|
const state = getDataGridAutoSizeState(table.store)
|
|
// Same live read as `reflow`; the snapshot race is what made the
|
|
// re-arm check clear the bookkeeping right after the first absorb.
|
|
const columnSizing =
|
|
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
|
|
|
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
|
// controlled state replacement) so the column re-fills instead of
|
|
// leaving a dead blank strip.
|
|
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
|
|
state.applied = null
|
|
}
|
|
|
|
const autoSizeColumn = table
|
|
.getVisibleLeafColumns()
|
|
.find(
|
|
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
|
)
|
|
|
|
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
|
|
state.pendingFreeSpace = freeSpace
|
|
const now = Date.now()
|
|
if (now - state.lastCommitAt > SETTLE_MS) {
|
|
state.lastCommitAt = now
|
|
return reflow(freeSpace)
|
|
}
|
|
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
|
|
state.settleTimer = setTimeout(() => {
|
|
state.settleTimer = null
|
|
state.lastCommitAt = Date.now()
|
|
reflow(state.pendingFreeSpace)
|
|
}, SETTLE_MS)
|
|
return false
|
|
}
|
|
|
|
const fillWidth = Math.max(0, freeSpace)
|
|
if (fillWidth <= 0) return false
|
|
|
|
if (!autoSizeColumn) {
|
|
return false
|
|
}
|
|
|
|
// A width this coordinator did not write belongs to someone else -
|
|
// almost always the user, who just dragged the column's resize handle.
|
|
// Filling over it is what made a `meta.autoSize` column look
|
|
// un-resizable: the drag committed, the next viewport measurement
|
|
// stamped the fill back on top, and the column snapped to its old width.
|
|
//
|
|
// Deliberately keyed on observed state rather than on `state.applied`, which
|
|
// is per-coordinator memory: anything that rebuilds the coordinator
|
|
// (a remount, a new table store) forgets what it did, and the guard has
|
|
// to survive that. An explicit reset clears the entry and re-arms the
|
|
// fill, which is what makes double-click-to-reset still work.
|
|
const currentSize = columnSizing[autoSizeColumn.id]
|
|
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
|
|
return false
|
|
}
|
|
|
|
// Candidate switched (e.g. the grown column was hidden and another
|
|
// meta.autoSize column took over): revert the previous growth if the
|
|
// user hasn't manually resized that column since, so visibility
|
|
// toggles cannot ratchet the table wider than its container forever.
|
|
const revert =
|
|
state.applied &&
|
|
columnSizing[state.applied.columnId] === state.applied.grown
|
|
? state.applied
|
|
: null
|
|
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
|
const grown = base + fillWidth
|
|
|
|
state.applied = { columnId: autoSizeColumn.id, base, grown }
|
|
table.setColumnSizing((old) => {
|
|
const next = { ...old, [autoSizeColumn.id]: grown }
|
|
if (revert && next[revert.columnId] === revert.grown) {
|
|
next[revert.columnId] = revert.base
|
|
}
|
|
return next
|
|
})
|
|
|
|
return true
|
|
},
|
|
}
|
|
}
|
|
|
|
export type DataGridRequestParams = {
|
|
pageIndex: number
|
|
pageSize: number
|
|
sorting?: SortingState
|
|
columnFilters?: ColumnFiltersState
|
|
}
|
|
|
|
/** Which spreadsheet operation produced a cell change batch. */
|
|
export type DataGridCellsChangeSource =
|
|
| "paste"
|
|
| "cut"
|
|
| "clear"
|
|
| "fill"
|
|
| "edit"
|
|
|
|
export interface DataGridCellChange<TData> {
|
|
rowId: string
|
|
columnId: string
|
|
/** The consumer's row object, so immutable write-back needs no second lookup. */
|
|
row: TData
|
|
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
|
|
previousValue: unknown
|
|
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
|
|
value: unknown
|
|
}
|
|
|
|
/** What onCellsCopy receives after the grid writes the clipboard. */
|
|
export interface DataGridCopyDetails {
|
|
/** The TSV text that reached the clipboard. */
|
|
text: string
|
|
/** The same content as a row-major grid of formatted fields. */
|
|
grid: string[][]
|
|
/** True when the write came from a cut, which clears the region next. */
|
|
cut: boolean
|
|
}
|
|
|
|
/** One resolved positive region, in pre-paginated display indexes. */
|
|
export interface DataGridCellSelectionBound {
|
|
minRowIndex: number
|
|
maxRowIndex: number
|
|
minColumnIndex: number
|
|
maxColumnIndex: number
|
|
}
|
|
|
|
/** What onCellSelectionChange receives, resolved from the live selection. */
|
|
export interface DataGridCellSelectionSnapshot {
|
|
/** The virtually focused cell, the active range's anchor. Null when clear. */
|
|
focused: { rowId: string; columnId: string } | null
|
|
/** Every positive region, geometric order. */
|
|
bounds: DataGridCellSelectionBound[]
|
|
/** The most recent region, the one copy and paste act on. */
|
|
activeBound: DataGridCellSelectionBound | null
|
|
/**
|
|
* Cells of the selection the view can show - the count that matches the
|
|
* painted range and what a batch will touch.
|
|
*/
|
|
visibleCellCount: number
|
|
}
|
|
|
|
export interface DataGridCellRejection {
|
|
rowId: string
|
|
columnId: string
|
|
raw: string
|
|
reason: "readonly" | "invalid"
|
|
}
|
|
|
|
/**
|
|
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
|
|
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
|
|
* consumer's state stays the only owner of what the statuses mean.
|
|
*/
|
|
export type DataGridRowStatus = "new" | "dirty" | "deleted"
|
|
|
|
/**
|
|
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
|
|
* the amber tone for "dirty", the destructive tone for "invalid".
|
|
*/
|
|
export type DataGridCellStatus = "dirty" | "invalid"
|
|
|
|
/**
|
|
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
|
|
* a typed printable character (then `initialText` carries it, the
|
|
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
|
|
* column is writable via `meta.cellEdit`.
|
|
*/
|
|
export interface DataGridCellEditRequest<TData> {
|
|
rowId: string
|
|
columnId: string
|
|
row: TData
|
|
previousValue: unknown
|
|
/** The typed character that started the edit; absent for Enter and F2. */
|
|
initialText?: string
|
|
}
|
|
|
|
/**
|
|
* One batch per operation, however many cells it spans: a 10k-cell paste is
|
|
* one callback, one consumer setState, one table rebuild. The grid never
|
|
* writes data itself.
|
|
*/
|
|
export interface DataGridCellsChangeDetails<TData> {
|
|
source: DataGridCellsChangeSource
|
|
changes: DataGridCellChange<TData>[]
|
|
rejected: DataGridCellRejection[]
|
|
}
|
|
|
|
export interface DataGridProps<
|
|
TFeatures extends TableFeatures,
|
|
TData extends object,
|
|
> {
|
|
className?: string
|
|
table?: Table<TFeatures, TData>
|
|
recordCount: number
|
|
children?: ReactNode
|
|
onRowClick?: (row: TData) => void
|
|
/**
|
|
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
|
|
* Served through the props getter like `onRowClick`, so an inline identity
|
|
* never republishes the context. Absent: copy still works, writes no-op.
|
|
*/
|
|
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
|
|
/**
|
|
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
|
|
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
|
|
*/
|
|
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
|
|
/**
|
|
* Fires on every cell-selection change (focus moves, ranges grow or
|
|
* clear) with a resolved snapshot, so a selection count, formula bar or
|
|
* context panel needs no reach into TanStack internals.
|
|
*/
|
|
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
|
|
/**
|
|
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
|
|
* native copy and cut events), e.g. to confirm with a toast.
|
|
*/
|
|
onCellsCopy?: (details: DataGridCopyDetails) => void
|
|
/**
|
|
* Renders an "Add row" affordance as the table body's last row; clicking
|
|
* it is the consumer's cue to start creating a row. Absent: no row.
|
|
*/
|
|
onRowCreate?: () => void
|
|
/** Label of the onRowCreate affordance. Defaults to "Add row". */
|
|
rowCreateLabel?: ReactNode
|
|
/**
|
|
* Rendered inside the table body after the data rows: the slot a DRAFT
|
|
* row lives in while it is being created, before it joins `data` and the
|
|
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
|
|
*/
|
|
appendRow?: ReactNode
|
|
/**
|
|
* Optional CRUD indication per row. Return undefined for no indication;
|
|
* omit the prop entirely to render none anywhere.
|
|
*/
|
|
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
|
|
/**
|
|
* Optional CRUD indication per cell: the classic corner mark on cells the
|
|
* consumer tracks as edited or invalid.
|
|
*/
|
|
getCellStatus?: (
|
|
row: TData,
|
|
columnId: string
|
|
) => DataGridCellStatus | undefined
|
|
isLoading?: boolean
|
|
/**
|
|
* Overrides for every built-in string: menu items, aria labels,
|
|
* pagination copy, state messages. Merged over the defaults; a more
|
|
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
|
|
* pagination label props) still wins over its `i18n` counterpart.
|
|
*/
|
|
i18n?: DataGridI18nOverrides
|
|
loadingMode?: "skeleton" | "spinner"
|
|
loadingMessage?: ReactNode | string
|
|
fetchingMoreMessage?: ReactNode | string
|
|
allRowsLoadedMessage?: ReactNode | string
|
|
emptyMessage?: ReactNode | string
|
|
tableLayout?: {
|
|
dense?: boolean
|
|
cellBorder?: boolean
|
|
rowBorder?: boolean
|
|
rowRounded?: boolean
|
|
stripped?: boolean
|
|
headerBackground?: boolean
|
|
footerBackground?: boolean
|
|
headerBorder?: boolean
|
|
headerSticky?: boolean
|
|
width?: "auto" | "fixed"
|
|
columnsVisibility?: boolean
|
|
columnsResizable?: boolean
|
|
columnsResizeMode?: "onChange" | "onEnd"
|
|
columnsPinnable?: boolean
|
|
columnsMovable?: boolean
|
|
columnsDraggable?: boolean
|
|
rowsDraggable?: boolean
|
|
rowsPinnable?: boolean
|
|
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
|
|
cellSelection?: boolean
|
|
/**
|
|
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
|
|
* grow multi-cell regions. "single": one focused cell only - every
|
|
* gesture collapses to it and Ctrl/Cmd+A is inert.
|
|
*/
|
|
cellSelectionMode?: "single" | "range"
|
|
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
|
|
cellFillHandle?: boolean
|
|
/**
|
|
* How a cell opens its editor from the mouse: "dblclick" (default,
|
|
* the spreadsheet standard - first click focuses, second edits) or
|
|
* "click", opening the editor as soon as a plain click lands on the
|
|
* focused cell; modifier clicks and drags still select.
|
|
*/
|
|
cellEditMode?: "dblclick" | "click"
|
|
/**
|
|
* When true, committing an edit with Enter moves focus down (Shift+Enter
|
|
* up), the Sheets and Excel convention. Off by default: the commit keeps
|
|
* focus on the edited cell. Tab always commits and moves across.
|
|
*/
|
|
cellEditEnterAdvance?: boolean
|
|
/**
|
|
* The handle's look: "dot" is a solid primary dot kept inside the
|
|
* cell corner; "ring" is the Sheets-style hollow circle riding the
|
|
* corner point, clamped at the grid's own boundary; "square" is the
|
|
* Excel-style solid square inside the corner. Default "dot".
|
|
*/
|
|
cellFillHandleVariant?: "dot" | "ring" | "square"
|
|
}
|
|
tableClassNames?: {
|
|
base?: string
|
|
header?: string
|
|
headerRow?: string
|
|
headerSticky?: string
|
|
body?: string
|
|
bodyRow?: string
|
|
footer?: string
|
|
edgeCell?: string
|
|
/** Override the getRowStatus tints; defaults are the warning-muted set. */
|
|
rowNew?: string
|
|
rowDirty?: string
|
|
rowDeleted?: string
|
|
/**
|
|
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
|
|
* that pins a draft row for quick-create wants it to read as an active
|
|
* row, not a muted one.
|
|
*/
|
|
rowPinned?: string
|
|
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
|
|
rowCreate?: string
|
|
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
|
|
cellFillHandle?: string
|
|
}
|
|
}
|
|
|
|
const DataGridContext = createContext<
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
DataGridContextProps<any> | undefined
|
|
>(undefined)
|
|
|
|
/**
|
|
* Reads the grid context. Pass `TData` from the calling component when the
|
|
* table, a row or a cell is handed on to something typed against that row
|
|
* shape: v9 declares `TData` invariant, so the default `any` no longer
|
|
* unifies with a concrete row type the way it did on v8.
|
|
*/
|
|
function useDataGrid<
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
TData extends object = any,
|
|
>(): DataGridContextProps<TData> {
|
|
const context = useContext(DataGridContext) as
|
|
| DataGridContextProps<TData>
|
|
| undefined
|
|
if (!context) {
|
|
throw new Error("useDataGrid must be used within a DataGridProvider")
|
|
}
|
|
return context
|
|
}
|
|
|
|
function DataGridProvider<TData extends object>({
|
|
children,
|
|
table,
|
|
...props
|
|
}: DataGridLayoutProps<TData> & {
|
|
table: DataGridTableInstance<TData>
|
|
children?: ReactNode
|
|
}) {
|
|
// Rendering reads this snapshot; imperative sizing reads committed values.
|
|
const gridId = useId()
|
|
const i18n = mergeDataGridI18n(props.i18n)
|
|
const [sizing] = useState(() => {
|
|
let committedTable = table
|
|
return {
|
|
controller: createDataGridAutoSizeController(() => committedTable),
|
|
update(next: DataGridTableInstance<TData>) { committedTable = next },
|
|
}
|
|
})
|
|
useLayoutEffect(() => { sizing.update(table) }, [sizing, table])
|
|
|
|
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
|
|
// options cannot flip it back between drags. v9 makes `table.options`
|
|
// readonly, so this goes through setOptions in an effect rather than a
|
|
// render-phase mutation. Without an explicit mode, the consumer's own
|
|
// tanstack columnResizeMode (default "onEnd") is honored.
|
|
const resizeMode =
|
|
props.tableLayout?.columnsResizable && props.tableLayout.columnsResizeMode
|
|
? props.tableLayout.columnsResizeMode
|
|
: undefined
|
|
|
|
useEffect(() => {
|
|
if (!resizeMode) return
|
|
if (table.options.columnResizeMode === resizeMode) return
|
|
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
|
}, [table, resizeMode])
|
|
|
|
// With the selection UI on, every paste/fill/clear batch replaces `data`,
|
|
// and the feature's default autoResetCellSelection would wipe the selection
|
|
// after each write. Ranges are row-id based and unresolvable ids drop out
|
|
// of the bounds safely, so keeping state across data edits is the correct
|
|
// default here.
|
|
const cellSelectionOn = !!props.tableLayout?.cellSelection
|
|
useEffect(() => {
|
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
|
if (table.options.autoResetCellSelection === false) return
|
|
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
|
|
}, [table, cellSelectionOn])
|
|
|
|
// Rendered row indexes re-base per page, so a range kept across a page flip
|
|
// re-lights the same positions over different rows. A page change clears
|
|
// the selection instead - the rule Excel-family grids apply to paginated
|
|
// range selection.
|
|
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
|
|
const previousPageKeyRef = useRef(pageKey)
|
|
useEffect(() => {
|
|
if (previousPageKeyRef.current === pageKey) return
|
|
previousPageKeyRef.current = pageKey
|
|
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
|
table.resetCellSelection(true)
|
|
}, [pageKey, cellSelectionOn, table])
|
|
|
|
const value = {
|
|
props, table, i18n, recordCount: props.recordCount,
|
|
isLoading: props.isLoading || false, gridId, autoSize: sizing.controller,
|
|
}
|
|
|
|
return (
|
|
// One React context serves every TData, but v9 declares both TFeatures and
|
|
// TData invariant, so a `DataGridContextProps<any>` context cannot accept a
|
|
// `DataGridContextProps<TData>` value structurally. The erasure happens
|
|
// here and is undone by the TData generic on each consumer component.
|
|
<DataGridContext.Provider
|
|
value={value as unknown as DataGridContextProps<TData>}
|
|
>
|
|
{children}
|
|
</DataGridContext.Provider>
|
|
)
|
|
}
|
|
|
|
function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
|
children,
|
|
table,
|
|
...props
|
|
}: DataGridProps<TFeatures, TData>) {
|
|
const defaultProps: Partial<DataGridProps<TFeatures, TData>> = {
|
|
loadingMode: "skeleton",
|
|
tableLayout: {
|
|
dense: false,
|
|
cellBorder: false,
|
|
rowBorder: true,
|
|
rowRounded: false,
|
|
stripped: false,
|
|
headerSticky: false,
|
|
headerBackground: false,
|
|
footerBackground: false,
|
|
headerBorder: true,
|
|
width: "fixed",
|
|
columnsVisibility: false,
|
|
columnsResizable: false,
|
|
// columnsResizeMode has no default on purpose: when unset, the
|
|
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
|
|
columnsPinnable: false,
|
|
columnsMovable: false,
|
|
columnsDraggable: false,
|
|
rowsDraggable: false,
|
|
rowsPinnable: false,
|
|
cellSelection: false,
|
|
cellFillHandle: false,
|
|
},
|
|
tableClassNames: {
|
|
base: "",
|
|
header: "",
|
|
headerRow: "",
|
|
// z-40 keeps the sticky header above pinned body cells (zIndex 30 in
|
|
// getPinningStyles), which would otherwise paint over it while
|
|
// scrolling vertically with columnsPinnable enabled.
|
|
headerSticky: "sticky top-0 z-40 bg-background/90 backdrop-blur-xs",
|
|
body: "",
|
|
bodyRow: "",
|
|
footer: "",
|
|
edgeCell: "",
|
|
},
|
|
}
|
|
|
|
const mergedProps: DataGridProps<TFeatures, TData> = {
|
|
...defaultProps,
|
|
...props,
|
|
tableLayout: {
|
|
...defaultProps.tableLayout,
|
|
...(props.tableLayout || {}),
|
|
},
|
|
tableClassNames: {
|
|
...defaultProps.tableClassNames,
|
|
...(props.tableClassNames || {}),
|
|
},
|
|
}
|
|
|
|
// Ensure table is provided
|
|
if (!table) {
|
|
throw new Error('DataGrid requires a "table" prop')
|
|
}
|
|
|
|
// The single widening point. Consumers own the TanStack core and may hand
|
|
// over any feature bundle; internals need a concrete one to resolve the
|
|
// feature-gated APIs they call, and v9's invariant TFeatures rules out
|
|
// expressing that with a generic constraint.
|
|
const internalTable = table as unknown as DataGridTableInstance<TData>
|
|
const internalProps = mergedProps as unknown as DataGridLayoutProps<TData>
|
|
|
|
return (
|
|
<DataGridProvider table={internalTable} {...internalProps}>
|
|
{children}
|
|
</DataGridProvider>
|
|
)
|
|
}
|
|
|
|
function DataGridContainer({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: ReactNode
|
|
className?: string
|
|
/** Accepted for backwards compatibility; currently has no effect. */
|
|
border?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
data-slot="data-grid"
|
|
// relative: anchors floating chrome composed inside the container,
|
|
// like the fill-drag preview outline.
|
|
className={cn("relative w-full overflow-hidden", className)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|