Files
vps-tracker/apps/web/src/components/reui/data-grid/data-grid-table.tsx
T
DenozordecandCursor a5f83c6ecb
Docker / build (push) Failing after 25s
fix(ui): выровнять header pin и матрицу блокировок под ReUI v9
Kit больше не перебивает defaults примитива: header без серой полосы, CRUD с table-fixed. Матрица /blocking получает sticky identity, иконки сервисов и статусы без текста ОК/Блок.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 21:47:58 +07:00

2035 lines
64 KiB
TypeScript

import {
Fragment,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
ReactNode,
TouchEvent as ReactTouchEvent,
Ref,
RefObject,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import { flexRender, Subscribe } from "@tanstack/react-table"
import type { Cell, Column, Header, Row } from "@tanstack/react-table"
import { cn } from "@cfdm/ui/lib/utils"
import { Checkbox } from "@cfdm/ui/components/checkbox"
import { Spinner } from "@cfdm/ui/components/spinner"
// Static spacing lookups; called once per cell, so they stay plain string
// picks instead of runtime variant machinery.
const headerCellSpacingVariants = ({ size }: { size?: "dense" | "default" }) =>
size === "dense" ? "px-2 h-8" : "px-3"
const bodyCellSpacingVariants = ({ size }: { size?: "dense" | "default" }) =>
size === "dense" ? "px-2 py-1.5" : "px-3 py-2"
const footerCellSpacingVariants = ({ size }: { size?: "dense" | "default" }) =>
size === "dense" ? "px-2 py-1.5" : "px-3 py-2"
function getPinningStyles<TData extends object>(
column: Column<DataGridFeatures, TData, unknown>
): CSSProperties {
const isPinned = column.getIsPinned()
return {
// Logical offsets: TanStack's "left"/"right" buckets are start/end
// semantics, so pinned columns stick to the correct edge in RTL too
// (identical to left/right in LTR).
insetInlineStart:
isPinned === "start" ? `${column.getStart("start")}px` : undefined,
insetInlineEnd:
isPinned === "end" ? `${column.getAfter("end")}px` : undefined,
position: isPinned ? "sticky" : undefined,
transform: isPinned ? "translateZ(0)" : undefined,
contain: isPinned ? "paint" : undefined,
width: column.getSize(),
zIndex: isPinned ? 30 : undefined,
backgroundClip: isPinned ? "padding-box" : undefined,
}
}
// Shared indent contract for tree rows: DataGridTableRowExpand consumes it,
// and fully custom cells can reuse it for depth alignment without the
// built-in toggle.
function getDataGridTreeIndentStyle<TData extends object>(
row: Row<DataGridFeatures, TData>,
indent: number = 20
): CSSProperties {
return {
"--data-grid-tree-padding": `${row.depth * indent}px`,
} as CSSProperties
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (!ref) return
if (typeof ref === "function") {
ref(value)
return
}
;(ref as { current: T | null }).current = value
}
/**
* Nearest scroll-area viewport that belongs to THIS grid. A viewport outside
* the grid's own container (e.g. a page-level ScrollArea) would make the
* width measurement - and the virtualizer - bind the wrong box.
*/
function getDataGridScrollAreaViewport(node: HTMLElement): HTMLElement | null {
const scrollViewport = node.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null
if (!scrollViewport) return null
const gridContainer = node.closest('[data-slot="data-grid"]')
if (gridContainer && !gridContainer.contains(scrollViewport)) return null
return scrollViewport
}
type DataGridResizeStartEvent =
| ReactMouseEvent<HTMLDivElement>
| ReactTouchEvent<HTMLDivElement>
type DataGridResizeDocumentEvent = globalThis.MouseEvent | globalThis.TouchEvent
function isDataGridTouchEvent(
event: DataGridResizeStartEvent | DataGridResizeDocumentEvent
): event is ReactTouchEvent<HTMLDivElement> | globalThis.TouchEvent {
return "touches" in event
}
type DataGridTouchListLike = {
length: number
item: (index: number) => { identifier: number; clientX: number } | null
}
function findTouchClientX(list: DataGridTouchListLike, identifier: number) {
for (let i = 0; i < list.length; i++) {
const touch = list.item(i)
if (touch && touch.identifier === identifier) return touch.clientX
}
return undefined
}
function getDataGridResizeEventClientX(
event: DataGridResizeStartEvent | DataGridResizeDocumentEvent,
touchIdentifier?: number
) {
if (isDataGridTouchEvent(event)) {
if (typeof touchIdentifier === "number") {
return (
findTouchClientX(event.touches, touchIdentifier) ??
findTouchClientX(event.changedTouches, touchIdentifier)
)
}
return event.touches[0]?.clientX ?? event.changedTouches[0]?.clientX
}
return event.clientX
}
function startDataGridColumnResizeOnEnd<TData extends object>(
event: DataGridResizeStartEvent,
header: Header<DataGridFeatures, TData, unknown>,
table: DataGridTableInstance<TData>
): (() => void) | undefined {
const column = table.getColumn(header.column.id)
if (!column || !column.getCanResize()) return
const isTouchSession = isDataGridTouchEvent(event)
if (isTouchSession && event.touches.length > 1) return
event.persist?.()
const ownerDocument = event.currentTarget.ownerDocument
const ownerWindow = ownerDocument.defaultView
const previousBodyCursor = ownerDocument.body.style.cursor
const previousDocumentCursor = ownerDocument.documentElement.style.cursor
const startSize = header.getSize()
// Track the initiating finger so a second touch cannot move or commit the
// resize with the wrong clientX.
const touchIdentifier = isTouchSession
? event.touches[0]?.identifier
: undefined
const dragStartClientX = getDataGridResizeEventClientX(event, touchIdentifier)
const headerCell = event.currentTarget.closest("th")
const headerRect = headerCell?.getBoundingClientRect()
const startOffset =
headerRect &&
Number.isFinite(
table.options.columnResizeDirection === "rtl"
? headerRect.left
: headerRect.right
)
? table.options.columnResizeDirection === "rtl"
? headerRect.left
: headerRect.right
: dragStartClientX
if (typeof dragStartClientX !== "number" || typeof startOffset !== "number") {
return
}
ownerDocument.body.style.cursor = "col-resize"
ownerDocument.documentElement.style.cursor = "col-resize"
const columnSizingStart = header
.getLeafHeaders()
.map(
(leafHeader) =>
[leafHeader.column.id, leafHeader.column.getSize()] as [string, number]
)
const directionMultiplier =
table.options.columnResizeDirection === "rtl" ? -1 : 1
// Clamp the drag to the leaf columns' min/max sizes so the preview
// indicator matches what the commit will produce (no overshoot followed by
// a snap-back on release). columnDef always carries resolved defaults.
let minDeltaPercentage = -0.999999
let maxDeltaPercentage = Number.POSITIVE_INFINITY
columnSizingStart.forEach(([columnId, headerSize]) => {
if (headerSize <= 0) return
const leafColumn = table.getColumn(columnId)
const minSize = leafColumn?.columnDef.minSize
const maxSize = leafColumn?.columnDef.maxSize
if (typeof minSize === "number") {
minDeltaPercentage = Math.max(
minDeltaPercentage,
minSize / headerSize - 1
)
}
if (typeof maxSize === "number" && Number.isFinite(maxSize)) {
maxDeltaPercentage = Math.min(
maxDeltaPercentage,
maxSize / headerSize - 1
)
}
})
let lastClientX = dragStartClientX
let ended = false
const stopListeners: Array<() => void> = []
const updateOffset = (clientXPos?: number, commit = false) => {
if (typeof clientXPos !== "number") return
lastClientX = clientXPos
const nextColumnSizing: Record<string, number> = {}
const deltaPercentage = Math.min(
Math.max(
((clientXPos - dragStartClientX) * directionMultiplier) / startSize,
minDeltaPercentage
),
maxDeltaPercentage
)
const deltaOffset = deltaPercentage * startSize
columnSizingStart.forEach(([columnId, headerSize]) => {
nextColumnSizing[columnId] =
Math.round(
Math.max(headerSize + headerSize * deltaPercentage, 0) * 100
) / 100
})
table.setColumnResizing((old) => ({
...old,
startOffset,
startSize,
deltaOffset,
deltaPercentage,
columnSizingStart,
isResizingColumn: column.id,
}))
if (commit) {
table.setColumnSizing((old) => ({
...old,
...nextColumnSizing,
}))
}
}
// Single teardown path: commits at the given position, removes every
// document/window listener, and restores cursors. Safe to call more than
// once (blur + mouseup + unmount can race).
const endResize = (clientXPos?: number) => {
if (ended) return
ended = true
stopListeners.forEach((stop) => stop())
updateOffset(clientXPos, true)
table.setColumnResizing((old) => ({
...old,
isResizingColumn: false,
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
columnSizingStart: [],
}))
ownerDocument.body.style.cursor = previousBodyCursor
ownerDocument.documentElement.style.cursor = previousDocumentCursor
}
const mouseMoveHandler = (moveEvent: globalThis.MouseEvent) => {
updateOffset(moveEvent.clientX)
}
const mouseUpHandler = (upEvent: globalThis.MouseEvent) => {
endResize(upEvent.clientX)
}
const touchMoveHandler = (moveEvent: globalThis.TouchEvent) => {
if (moveEvent.cancelable) {
moveEvent.preventDefault()
moveEvent.stopPropagation()
}
updateOffset(getDataGridResizeEventClientX(moveEvent, touchIdentifier))
}
const touchEndHandler = (endEvent: globalThis.TouchEvent) => {
// Ignore other fingers lifting; only the initiating touch ends the drag.
const clientXPos =
typeof touchIdentifier === "number"
? findTouchClientX(endEvent.changedTouches, touchIdentifier)
: getDataGridResizeEventClientX(endEvent)
if (typeof clientXPos !== "number") return
if (endEvent.cancelable) {
endEvent.preventDefault()
endEvent.stopPropagation()
}
endResize(clientXPos)
}
// System-interrupted gestures and window focus loss would otherwise leave
// the session (and its document listeners) live with no pointer held.
const touchCancelHandler = () => {
endResize(lastClientX)
}
const windowBlurHandler = () => {
endResize(lastClientX)
}
const passiveIfSupported = { passive: false } as const
if (isTouchSession) {
ownerDocument.addEventListener(
"touchmove",
touchMoveHandler,
passiveIfSupported
)
ownerDocument.addEventListener(
"touchend",
touchEndHandler,
passiveIfSupported
)
ownerDocument.addEventListener("touchcancel", touchCancelHandler)
stopListeners.push(() => {
ownerDocument.removeEventListener("touchmove", touchMoveHandler)
ownerDocument.removeEventListener("touchend", touchEndHandler)
ownerDocument.removeEventListener("touchcancel", touchCancelHandler)
})
} else {
ownerDocument.addEventListener(
"mousemove",
mouseMoveHandler,
passiveIfSupported
)
ownerDocument.addEventListener(
"mouseup",
mouseUpHandler,
passiveIfSupported
)
stopListeners.push(() => {
ownerDocument.removeEventListener("mousemove", mouseMoveHandler)
ownerDocument.removeEventListener("mouseup", mouseUpHandler)
})
}
if (ownerWindow) {
ownerWindow.addEventListener("blur", windowBlurHandler)
stopListeners.push(() =>
ownerWindow.removeEventListener("blur", windowBlurHandler)
)
}
table.setColumnResizing((old) => ({
...old,
startOffset,
startSize,
deltaOffset: 0,
deltaPercentage: 0,
columnSizingStart,
isResizingColumn: column.id,
}))
return () => endResize(lastClientX)
}
type DataGridTablePinnedBoundary = "top" | "bottom"
function getDataGridTableRowSections<TData extends object>(
table: DataGridTableInstance<TData>,
rowsPinnable?: boolean
) {
if (!rowsPinnable) {
return {
topRows: [] as Row<DataGridFeatures, TData>[],
centerRows: table.getRowModel().rows as Row<DataGridFeatures, TData>[],
bottomRows: [] as Row<DataGridFeatures, TData>[],
}
}
return {
topRows: table.getTopRows() as Row<DataGridFeatures, TData>[],
centerRows: table.getCenterRows() as Row<DataGridFeatures, TData>[],
bottomRows: table.getBottomRows() as Row<DataGridFeatures, TData>[],
}
}
function getDataGridTableResolvedRows<TData extends object>(
table: DataGridTableInstance<TData>,
rowsPinnable?: boolean
) {
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
rowsPinnable
)
const resolvedRows: Array<{
row: Row<DataGridFeatures, TData>
pinnedBoundary?: DataGridTablePinnedBoundary
}> = []
topRows.forEach((row, index) => {
resolvedRows.push({
row,
pinnedBoundary:
index === topRows.length - 1 &&
(centerRows.length > 0 || bottomRows.length > 0)
? "top"
: undefined,
})
})
centerRows.forEach((row) => {
resolvedRows.push({ row })
})
bottomRows.forEach((row, index) => {
resolvedRows.push({
row,
pinnedBoundary:
index === 0 && (centerRows.length > 0 || topRows.length > 0)
? "bottom"
: undefined,
})
})
return resolvedRows
}
function getDataGridTableOrderedVisibleColumns<TData extends object>(
table: DataGridTableInstance<TData>
) {
return [
...table.getStartVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getEndVisibleLeafColumns(),
] as Column<DataGridFeatures, TData, unknown>[]
}
function getDataGridTableOrderedVisibleCells<TData extends object>(
row: Row<DataGridFeatures, TData>
) {
return [
...row.getStartVisibleCells(),
...row.getCenterVisibleCells(),
...row.getEndVisibleCells(),
] as Cell<DataGridFeatures, TData, unknown>[]
}
function getDataGridTableMergedHeaderGroups<TData extends object>(
table: DataGridTableInstance<TData>
) {
const leftHeaderGroups = table.getStartHeaderGroups()
const centerHeaderGroups = table.getCenterHeaderGroups()
const rightHeaderGroups = table.getEndHeaderGroups()
const headerGroupCount = Math.max(
leftHeaderGroups.length,
centerHeaderGroups.length,
rightHeaderGroups.length
)
return Array.from({ length: headerGroupCount }, (_, index) => {
const leftGroup = leftHeaderGroups[index]
const centerGroup = centerHeaderGroups[index]
const rightGroup = rightHeaderGroups[index]
return {
id:
[leftGroup?.id, centerGroup?.id, rightGroup?.id]
.filter(Boolean)
.join(":") || `header-group-${index}`,
headers: [
...(leftGroup?.headers ?? []),
...(centerGroup?.headers ?? []),
...(rightGroup?.headers ?? []),
] as Header<DataGridFeatures, TData, unknown>[],
}
})
}
function hasDataGridTableRightPinnedColumns<TData extends object>(
table: DataGridTableInstance<TData>
) {
return (table.state.columnPinning.end?.length ?? 0) > 0
}
function DataGridTableFillCol() {
const { props } = useDataGrid()
if (!props.tableLayout?.columnsResizable) return null
return (
<col
data-slot="data-grid-table-fill-col"
style={{ width: "var(--data-grid-fill-size, 0px)" }}
/>
)
}
function DataGridTableFillHeadCell() {
const { props } = useDataGrid()
if (!props.tableLayout?.columnsResizable) return null
return (
<th
aria-hidden="true"
data-slot="data-grid-table-fill-head-cell"
style={{ width: "var(--data-grid-fill-size, 0px)" }}
className={cn("p-0", props.tableLayout?.headerBackground && "bg-muted")}
/>
)
}
function DataGridTableFillBodyCell() {
const { props } = useDataGrid()
if (!props.tableLayout?.columnsResizable) return null
return (
<td
aria-hidden="true"
data-slot="data-grid-table-fill-body-cell"
style={{ width: "var(--data-grid-fill-size, 0px)" }}
className="p-0"
/>
)
}
function DataGridTableFillFootCell() {
const { props } = useDataGrid()
if (!props.tableLayout?.columnsResizable) return null
return (
<td
aria-hidden="true"
data-slot="data-grid-table-fill-foot-cell"
style={{ width: "var(--data-grid-fill-size, 0px)" }}
className="p-0"
/>
)
}
function DataGridTableBase({ children }: { children: ReactNode }) {
const { props, table } = useDataGrid()
const leftVisibleColumns = table.getStartVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getEndVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
/**
* Compute column widths as CSS custom properties once upfront (memoized).
* Cells reference these via calc(var(--col-X-size) * 1px) so the browser
* handles width propagation without per-cell getSize() calls or React
* re-renders of the body.
*/
const columnSizeVars = useMemo(() => {
if (!props.tableLayout?.columnsResizable) return undefined
const headers = table.getFlatHeaders()
const colSizes: Record<string, number> = {}
for (let i = 0; i < headers.length; i++) {
const header = headers[i]!
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
props.tableLayout?.columnsResizable,
// Visibility/order/pinning change the flat header set, so a column shown
// after mount must get its size variable even though sizing is untouched.
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnSizing,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnVisibility,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnOrder,
// eslint-disable-next-line react-hooks/exhaustive-deps
table.state.columnPinning,
])
return (
<table
data-slot="data-grid-table"
className={cn(
"text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right",
props.tableLayout?.columnsResizable ? "min-w-0" : "w-full min-w-full",
props.tableLayout?.width === "auto" ? "table-auto" : "table-fixed",
!props.tableLayout?.columnsResizable && "",
!props.tableLayout?.columnsDraggable &&
"border-separate border-spacing-0",
props.tableClassNames?.base
)}
style={
props.tableLayout?.columnsResizable
? {
...columnSizeVars,
width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))`,
}
: undefined
}
>
<colgroup>
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
<col
key={column.id}
style={
props.tableLayout?.columnsResizable
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
: props.tableLayout?.width === "fixed"
? { width: column.getSize() }
: undefined
}
/>
))}
{hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
{rightVisibleColumns.map((column) => (
<col
key={column.id}
style={
props.tableLayout?.columnsResizable
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
: props.tableLayout?.width === "fixed"
? { width: column.getSize() }
: undefined
}
/>
))}
{!hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
</colgroup>
{children}
</table>
)
}
function DataGridTableViewport({
children,
className,
viewportRef,
style,
}: {
children: ReactNode
className?: string
viewportRef?: Ref<HTMLDivElement>
style?: CSSProperties
}) {
const { props, table, autoSize } = useDataGrid()
const isColumnsResizable = !!props.tableLayout?.columnsResizable
const viewportNodeRef = useRef<HTMLDivElement | null>(null)
const fillStateRef = useRef({ containerWidth: 0, appliedFill: -1 })
const stopContainerObserverRef = useRef<(() => void) | null>(null)
// Free space is written as a CSS variable directly on the viewport node
// instead of React state, so container resizes and column-size commits
// reach the fill column without re-rendering the grid.
const syncFillWidth = useCallback(() => {
const node = viewportNodeRef.current
if (!node) return
const fillWidth = Math.max(
0,
fillStateRef.current.containerWidth - table.getTotalSize()
)
if (fillStateRef.current.appliedFill !== fillWidth) {
fillStateRef.current.appliedFill = fillWidth
node.style.setProperty("--data-grid-fill-size", `${fillWidth}px`)
}
autoSize?.apply(fillWidth)
}, [autoSize, table])
const handleViewportRef = useCallback(
(node: HTMLDivElement | null) => {
stopContainerObserverRef.current?.()
stopContainerObserverRef.current = null
viewportNodeRef.current = node
assignRef(viewportRef, node)
if (!node) return
if (!isColumnsResizable) {
fillStateRef.current.appliedFill = -1
node.style.removeProperty("--data-grid-fill-size")
return
}
const scrollViewport =
getDataGridScrollAreaViewport(node) ?? node.parentElement
const measurementTarget = scrollViewport ?? node
const measure = () => {
fillStateRef.current.containerWidth = measurementTarget.clientWidth
syncFillWidth()
}
// First measure runs inside the mount commit, before paint, so the fill
// column and any meta.autoSize growth land in the first painted frame.
measure()
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(measure)
observer.observe(measurementTarget)
stopContainerObserverRef.current = () => observer.disconnect()
}
},
[isColumnsResizable, syncFillWidth, viewportRef]
)
// Column sizing commits and visibility changes alter the table's total size
// without moving the container, so the fill var must re-sync after renders
// the ResizeObserver never sees. No-ops when the value is unchanged.
useLayoutEffect(() => {
if (!isColumnsResizable) return
syncFillWidth()
})
return (
<div
data-slot="data-grid-table-viewport"
ref={handleViewportRef}
className={cn("relative min-w-full align-top", className)}
style={{
...(isColumnsResizable
? {
width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))`,
}
: undefined),
...style,
}}
>
{children}
<DataGridTableResizeIndicator viewportNodeRef={viewportNodeRef} />
</div>
)
}
function DataGridTableHead({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
return (
<thead
className={cn(
props.tableClassNames?.header,
props.tableLayout?.headerSticky && props.tableClassNames?.headerSticky
)}
>
{children}
</thead>
)
}
function DataGridTableHeadRow({
children,
}: {
children: ReactNode
rowId: string
}) {
const { props } = useDataGrid()
return (
<tr
className={cn(
props.tableLayout?.headerBorder && "[&>th]:border-b",
props.tableLayout?.cellBorder && "*:last:border-e-0",
props.tableLayout?.stripped && "bg-transparent",
props.tableLayout?.headerBackground === false && "bg-transparent",
props.tableClassNames?.headerRow
)}
>
{children}
</tr>
)
}
function DataGridTableHeadRowCell<TData extends object>({
children,
header,
dndRef,
dndStyle,
}: {
children: ReactNode
header: Header<DataGridFeatures, TData, unknown>
dndRef?: React.Ref<HTMLTableCellElement>
dndStyle?: CSSProperties
}) {
const { props } = useDataGrid()
const { column } = header
const isPinned = column.getIsPinned()
const isFirstStartPinned =
isPinned === "start" && column.getIsFirstColumn("start")
const isLastStartPinned =
isPinned === "start" && column.getIsLastColumn("start")
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
const isLastEndPinned = isPinned === "end" && column.getIsLastColumn("end")
const isLastVisibleColumn =
column.getIndex() ===
header.getContext().table.getVisibleLeafColumns().length - 1
const headerCellSpacing = headerCellSpacingVariants({
size: props.tableLayout?.dense ? "dense" : "default",
})
const sortDirection = column.getIsSorted()
return (
<th
ref={dndRef}
scope="col"
colSpan={header.colSpan > 1 ? header.colSpan : undefined}
aria-sort={
sortDirection === "asc"
? "ascending"
: sortDirection === "desc"
? "descending"
: undefined
}
style={{
...(props.tableLayout?.width === "fixed" &&
!props.tableLayout?.columnsResizable && {
width: header.getSize(),
}),
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--header-${header.id}-size) * 1px)`,
}),
...(dndStyle ? dndStyle : null),
}}
data-pinned={isPinned || undefined}
data-outer-pinned-col={
isFirstStartPinned ? "start" : isLastEndPinned ? "end" : undefined
}
data-last-col={
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
}
className={cn(
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing,
props.tableLayout?.headerBackground && "bg-muted",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
(isPinned ? "overflow-hidden" : "overflow-visible"),
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
isLastVisibleColumn &&
"pe-8",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
"[&[data-pinned=end]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]",
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=start]_div.cursor-col-resize:last-child]:opacity-0"
),
header.column.columnDef.meta?.headerClassName,
// Edge detection spans the full visible leaf order; the header's own
// group only covers one pinning bucket.
column.getIndex() === 0 || isLastVisibleColumn
? props.tableClassNames?.edgeCell
: ""
)}
>
{children}
</th>
)
}
/**
* TanStack's own default, restated here on purpose.
*
* v8 merged each feature's default table options into `table.options`, so
* reading `table.options.columnResizeMode` gave you `"onEnd"` even when the
* consumer never set it. v9 resolves feature defaults internally and leaves
* the option `undefined` on the instance, so the old `?? table.options...`
* fallback quietly produced `undefined` - and every grid that had not opted
* into a mode explicitly lost the onEnd drag session: no cursor lock, no
* vertical indicator, and an immediate commit instead of a deferred one.
*/
const DATA_GRID_DEFAULT_COLUMN_RESIZE_MODE = "onEnd" as const
function getDataGridColumnResizeMode(
layoutMode: "onChange" | "onEnd" | undefined,
tableMode: "onChange" | "onEnd" | undefined
) {
return layoutMode ?? tableMode ?? DATA_GRID_DEFAULT_COLUMN_RESIZE_MODE
}
function DataGridTableHeadRowCellResize<TData extends object>({
header,
}: {
header: Header<DataGridFeatures, TData, unknown>
}) {
const { props, table } = useDataGrid<TData>()
const { column } = header
const isPinned = column.getIsPinned()
const isLastVisibleColumn =
column.getIndex() ===
header.getContext().table.getVisibleLeafColumns().length - 1
const isResizeModeOnEnd =
getDataGridColumnResizeMode(
props.tableLayout?.columnsResizeMode,
table.options.columnResizeMode
) === "onEnd"
const stopResizeSessionRef = useRef<(() => void) | undefined>(undefined)
// End a live drag if the handle unmounts mid-resize so document listeners
// and the app-wide col-resize cursor don't outlive the grid.
useEffect(() => {
return () => {
stopResizeSessionRef.current?.()
stopResizeSessionRef.current = undefined
}
}, [])
const handleMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
// Only the primary button starts a resize; guard before preventDefault so
// right-click still opens the context menu.
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
if (isResizeModeOnEnd) {
stopResizeSessionRef.current?.()
stopResizeSessionRef.current = startDataGridColumnResizeOnEnd(
event,
header,
table
)
return
}
header.getResizeHandler()(event)
}
const handleTouchStart = (event: ReactTouchEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
if (isResizeModeOnEnd) {
stopResizeSessionRef.current?.()
stopResizeSessionRef.current = startDataGridColumnResizeOnEnd(
event,
header,
table
)
return
}
header.getResizeHandler()(event)
}
return (
<div
{...{
onDoubleClick: () => column.resetSize(),
onMouseDown: handleMouseDown,
onTouchStart: handleTouchStart,
className: cn(
"absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex",
isLastVisibleColumn
? "end-0 w-5 justify-end before:hidden"
: isPinned
? cn(
// A pinned column is sticky, so the handle sits inside the
// cell instead of straddling the boundary, where the next
// sticky cell would paint over it.
"end-0 w-5 justify-end",
// With the pin affordance on, the pinned edge already draws
// its own separator and a resize line would double it. But
// pinning is also usable purely as an ordering lock, with no
// affordance and no separator -- and there this line is the
// only thing marking the edge, so hiding it left a resizable
// column showing a resize cursor and no indicator at all.
props.tableLayout?.columnsPinnable
? "before:hidden"
: "before:absolute before:inset-y-0 before:end-0 before:w-px before:bg-border"
)
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
column.getIsResizing() &&
(isResizeModeOnEnd
? "opacity-100"
: isLastVisibleColumn
? "before:absolute before:end-0 before:block before:inset-y-0 before:w-0.5 before:bg-primary opacity-100"
: "before:block before:bg-primary before:w-0.5 opacity-100")
),
}}
/>
)
}
function DataGridTableResizeIndicator({
viewportNodeRef,
}: {
viewportNodeRef: RefObject<HTMLDivElement | null>
}) {
const { props, table } = useDataGrid()
const indicatorRef = useRef<HTMLDivElement | null>(null)
const indicatorHeadRef = useRef<HTMLDivElement | null>(null)
// Header height is stable for the duration of a drag; caching it per
// session avoids a forced layout (querySelector + getBoundingClientRect)
// on every mousemove.
const headerHeightCacheRef = useRef<{
key: string | false
value: number
}>({ key: false, value: 0 })
const columnResizing = table.state.columnResizing
const resizingColumnId = columnResizing.isResizingColumn
const resizeMode = getDataGridColumnResizeMode(
props.tableLayout?.columnsResizeMode,
table.options.columnResizeMode
)
const isActive = !!(
props.tableLayout?.columnsResizable &&
resizeMode === "onEnd" &&
resizingColumnId
)
// Positioning happens imperatively after each drag-frame render: layout
// reads (viewport rect, thead height) and ref access belong outside render,
// and writing styles directly avoids holding the viewport node in React
// state, which would cost every grid a second render pass at mount.
useLayoutEffect(() => {
const indicator = indicatorRef.current
const indicatorHead = indicatorHeadRef.current
const viewportElement = viewportNodeRef.current
if (!isActive || !indicator || !indicatorHead || !resizingColumnId) return
const resizingHeader = table
.getFlatHeaders()
.find(
(header) =>
header.column.id === resizingColumnId ||
header.id === resizingColumnId
)
if (!resizingHeader) return
// deltaOffset is a logical delta (already direction-adjusted); translate
// by the physical pointer movement so the indicator follows the cursor
// in RTL instead of mirroring it.
const directionMultiplier =
table.options.columnResizeDirection === "rtl" ? -1 : 1
const deltaOffset = (columnResizing.deltaOffset ?? 0) * directionMultiplier
if (headerHeightCacheRef.current.key !== resizingColumnId) {
headerHeightCacheRef.current = {
key: resizingColumnId,
value:
viewportElement
?.querySelector('[data-slot="data-grid-table"] thead')
?.getBoundingClientRect().height ?? 0,
}
}
const headerHeight = headerHeightCacheRef.current.value
const indicatorLeft =
typeof columnResizing.startOffset === "number" && viewportElement
? columnResizing.startOffset -
viewportElement.getBoundingClientRect().left
: resizingHeader.getStart() + resizingHeader.getSize()
indicator.style.left = `${indicatorLeft}px`
indicator.style.transform = `translateX(${deltaOffset}px)`
indicatorHead.style.height = `${Math.max(headerHeight, 6)}px`
})
if (!isActive) return null
return (
<div
ref={indicatorRef}
aria-hidden="true"
data-slot="data-grid-table-resize-indicator"
className="pointer-events-none absolute inset-y-0 z-50"
>
<div className="bg-primary/85 absolute inset-y-0 left-0 w-px -translate-x-1/2" />
<div
ref={indicatorHeadRef}
className="bg-primary rounded-b-sm absolute top-0 left-0 -translate-x-1/2 shadow-xs"
style={{ width: 5 }}
/>
</div>
)
}
function DataGridTableRowSpacer() {
return (
<tbody
aria-hidden="true"
className="h-2"
data-slot="data-grid-table-body-spacer"
></tbody>
)
}
function DataGridTableBody({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
return (
<tbody
data-slot="data-grid-table-body"
className={cn(
props.tableLayout?.rowRounded &&
"[&_td:first-child]:rounded-l-lg",
props.tableLayout?.rowRounded &&
"[&_td:last-child]:rounded-r-lg",
props.tableClassNames?.body
)}
>
{children}
</tbody>
)
}
function DataGridTableFoot({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
return (
<tfoot
data-slot="data-grid-table-foot"
className={cn(props.tableClassNames?.footer)}
>
{children}
</tfoot>
)
}
function DataGridTableFootRow({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
const footRowBottomBorderClasses = "[&:not(:last-child)>td]:border-b"
return (
<tr
data-slot="data-grid-table-foot-row"
className={cn(
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
props.tableLayout?.rowBorder && footRowBottomBorderClasses,
props.tableLayout?.cellBorder && "*:last:border-e-0"
)}
>
{children}
<DataGridTableFillFootCell />
</tr>
)
}
function DataGridTableFootRowCell({
children,
colSpan,
className,
}: {
children?: ReactNode
colSpan?: number
className?: string
}) {
const { props } = useDataGrid()
const spacing = footerCellSpacingVariants({
size: props.tableLayout?.dense ? "dense" : "default",
})
return (
<td
colSpan={colSpan}
className={cn(
"text-secondary-foreground/80 align-middle font-medium",
spacing,
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
props.tableLayout?.cellBorder && "border-e",
className
)}
>
{children}
</td>
)
}
function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
const { table, props } = useDataGrid()
return (
<tr
className={cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
props.onRowClick && "cursor-pointer",
!props.tableLayout?.stripped &&
props.tableLayout?.rowBorder &&
"border-border border-b [&:not(:last-child)>td]:border-b",
props.tableLayout?.cellBorder && "*:last:border-e-0",
props.tableLayout?.stripped &&
"odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent",
table.options.enableRowSelection && "*:first:relative",
props.tableClassNames?.bodyRow
)}
>
{children}
</tr>
)
}
function DataGridTableBodyRowSkeletonCell<TData extends object>({
children,
column,
}: {
children: ReactNode
column: Column<DataGridFeatures, TData, unknown>
}) {
const { props, table } = useDataGrid()
const bodyCellSpacing = bodyCellSpacingVariants({
size: props.tableLayout?.dense ? "dense" : "default",
})
return (
<td
style={
props.tableLayout?.columnsResizable
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
: undefined
}
className={cn(
"align-middle",
bodyCellSpacing,
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
"truncate",
column.columnDef.meta?.cellClassName,
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]",
column.getIndex() === 0 ||
column.getIndex() === table.getVisibleLeafColumns().length - 1
? props.tableClassNames?.edgeCell
: ""
)}
>
{children}
</td>
)
}
function DataGridTableBodyRow<TData extends object>({
children,
row,
pinnedBoundary,
rowRef,
dndRef,
dndStyle,
dataIndex,
}: {
children: ReactNode
row: Row<DataGridFeatures, TData>
pinnedBoundary?: DataGridTablePinnedBoundary
rowRef?: React.Ref<HTMLTableRowElement>
dndRef?: React.Ref<HTMLTableRowElement>
dndStyle?: CSSProperties
dataIndex?: number
}) {
const { props, table } = useDataGrid()
const isRowPinned = row.getIsPinned()
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
return (
<tr
ref={(node) => {
assignRef(rowRef, node)
assignRef(dndRef, node)
}}
style={{ ...(dndStyle ? dndStyle : null) }}
data-state={
table.options.enableRowSelection && row.getIsSelected()
? "selected"
: undefined
}
data-index={dataIndex}
data-row-id={row.id}
data-depth={row.depth || undefined}
data-row-pinned={isRowPinned || undefined}
data-row-pinned-boundary={pinnedBoundary}
onClick={() => props.onRowClick && props.onRowClick(row.original)}
className={cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
props.onRowClick && "cursor-pointer",
!props.tableLayout?.stripped &&
props.tableLayout?.rowBorder &&
bodyRowBottomBorderClasses,
props.tableLayout?.cellBorder &&
`*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
// Virtualized rows stripe by absolute row index (CSS :nth-child
// parity flips as spacer rows resize while scrolling).
props.tableLayout?.stripped &&
(typeof dataIndex === "number"
? cn(
"hover:bg-transparent",
dataIndex % 2 === 0 && "bg-muted/90 hover:bg-muted"
)
: "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent"),
table.options.enableRowSelection && "*:first:relative",
props.tableLayout?.rowsPinnable &&
isRowPinned &&
"bg-muted/30 hover:bg-muted/50",
pinnedBoundary === "top" &&
"[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)] dark:[&>td]:shadow-[0_2px_0_rgba(255,255,255,0.06)]",
pinnedBoundary === "bottom" &&
"[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)] dark:[&>td]:shadow-[0_2px_0_rgba(255,255,255,0.06)]",
props.tableClassNames?.bodyRow
)}
>
{children}
</tr>
)
}
function DataGridTableBodyRowExpandded<TData extends object>({
row,
}: {
row: Row<DataGridFeatures, TData>
}) {
const { props, table } = useDataGrid()
const expandedContent = table
.getAllColumns()
.find((column) => column.columnDef.meta?.expandedContent)?.columnDef
.meta?.expandedContent
// Tree and grouped rows share row.getIsExpanded() with detail expansion.
// Without a detail column there is nothing to render, and an empty <tr>
// would break striping parity, rowBorder, and virtual row measurement.
if (!expandedContent) return null
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
return (
<tr
className={cn(props.tableLayout?.rowBorder && bodyRowBottomBorderClasses)}
>
<td
colSpan={
getDataGridTableOrderedVisibleCells(row).length +
(props.tableLayout?.columnsResizable ? 1 : 0)
}
>
{expandedContent(row.original)}
</td>
</tr>
)
}
function DataGridTableBodyRowCell<TData extends object>({
children,
cell,
dndRef,
dndStyle,
}: {
children: ReactNode
cell: Cell<DataGridFeatures, TData, unknown>
dndRef?: React.Ref<HTMLTableCellElement>
dndStyle?: CSSProperties
}) {
const { props } = useDataGrid()
const { column, row } = cell
const isPinned = column.getIsPinned()
const isLastStartPinned =
isPinned === "start" && column.getIsLastColumn("start")
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
const bodyCellSpacing = bodyCellSpacingVariants({
size: props.tableLayout?.dense ? "dense" : "default",
})
return (
<td
ref={dndRef}
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
...(dndStyle ? dndStyle : null),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
}
className={cn(
"align-middle",
bodyCellSpacing,
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
"truncate",
cell.column.columnDef.meta?.cellClassName,
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-background data-pinned:isolate",
"[&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]",
"[&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)]"
),
column.getIndex() === 0 ||
column.getIndex() === row.getVisibleCells().length - 1
? props.tableClassNames?.edgeCell
: ""
)}
>
{children}
</td>
)
}
function DataGridTableRenderedRow<TData extends object>({
row,
pinnedBoundary,
rowRef,
rowIndex,
}: {
row: Row<DataGridFeatures, TData>
pinnedBoundary?: DataGridTablePinnedBoundary
rowRef?: React.Ref<HTMLTableRowElement>
/** Virtualized list index, rendered as data-index for measureElement. */
rowIndex?: number
}) {
const { props, table } = useDataGrid()
const startVisibleCells = row.getStartVisibleCells()
const centerVisibleCells = row.getCenterVisibleCells()
const endVisibleCells = row.getEndVisibleCells()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<Fragment>
<DataGridTableBodyRow
row={row}
pinnedBoundary={pinnedBoundary}
rowRef={rowRef}
dataIndex={rowIndex}
>
{[...startVisibleCells, ...centerVisibleCells].map(
(cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
)}
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{endVisibleCells.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
}
function DataGridTableEmpty() {
const { table, props } = useDataGrid()
const visibleColumnCount =
getDataGridTableOrderedVisibleColumns(table).length +
(props.tableLayout?.columnsResizable ? 1 : 0)
return (
<tr>
<td
colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground py-6 text-center text-sm"
>
{props.emptyMessage || "No data available"}
</td>
</tr>
)
}
function DataGridTableLoader() {
const { props } = useDataGrid()
return (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
<Spinner className="size-5 opacity-60" />
{props.loadingMessage || "Loading..."}
</div>
</div>
)
}
function DataGridTableRowPin<TData extends object>({
row,
}: {
row: Row<DataGridFeatures, TData>
}) {
const isPinned = row.getIsPinned()
return (
<button
type="button"
aria-label={isPinned ? "Unpin row" : "Pin row"}
onClick={(event) => {
// Pinning must not bubble into the row's onRowClick handler.
event.stopPropagation()
if (isPinned) {
row.pin(false)
} else {
row.pin("top")
}
}}
className={cn(
"text-muted-foreground hover:text-foreground rounded-lg inline-flex size-7 items-center justify-center transition-colors",
isPinned && "text-primary hover:text-primary/80"
)}
>
{isPinned ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<path d="M16 2l4.585 4.586-2.122 2.121L17.05 7.293l-3.535 3.536 1.413 5.658-2.12 2.121-4.244-4.243L4.322 18.6l-1.414-1.41 4.242-4.244-4.243-4.243 2.122-2.121 5.656 1.414 3.536-3.536-1.414-1.414z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="17" x2="12" y2="22" />
<path d="M5 17h14v-1.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V6h1a2 2 0 000-4H8a2 2 0 000 4h1v4.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24z" />
</svg>
)}
</button>
)
}
/**
* Selection cell.
*
* The `Subscribe` wrapper is the v9 fix for React Compiler, and it is why this
* file no longer needs `"use no memo"`. `useTable` hands back a fresh table on
* every state change, which covers anything read through `table`, but this
* component reads selection through `row.getIsSelected()` - a builder call
* that hides its state dependency from the compiler. Rendered inside a column
* `cell` template it receives a *stable* row, so the compiler is free to
* memoize this JSX and never re-run those reads, which shows up as checkboxes
* that do not respond to clicks.
*
* Subscribing to the row-selection atom gives the compiler a dependency it can
* see. Note the standalone `Subscribe` rather than `table.Subscribe`: inside a
* cell template the `table` handed to the column def is the core `Table`, which
* has no `Subscribe` attached.
*/
function DataGridTableRowSelect<TData extends object>({
row,
}: {
row: Row<DataGridFeatures, TData>
}) {
return (
<Subscribe source={row.table.atoms.rowSelection}>
{() => (
<>
<div
className={cn(
"bg-primary absolute inset-s-0 top-0 bottom-0 hidden w-[2px]",
row.getIsSelected() && "block"
)}
></div>
<Checkbox
checked={row.getIsSelected()}
indeterminate={row.getIsSomeSelected() && !row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(event) => {
// Selection must not bubble into the row's onRowClick handler.
event.stopPropagation()
}}
aria-label="Select row"
className="align-[inherit]"
/>
</>
)}
</Subscribe>
)
}
function DataGridTableRowSelectAll() {
const { table, recordCount, isLoading } = useDataGrid()
// `getIsSomePageRowsSelected()` means "at least one" in v9, where v8 meant
// "some but not all", so the all-selected case has to be excluded explicitly
// or the header checkbox stays indeterminate once every row is checked.
return (
<Subscribe source={table.atoms.rowSelection}>
{() => {
const isAllSelected = table.getIsAllPageRowsSelected()
const isSomeSelected = table.getIsSomePageRowsSelected()
return (
<Checkbox
checked={isAllSelected}
indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
className="align-[inherit]"
/>
)
}}
</Subscribe>
)
}
function DataGridTableRowExpand<TData extends object>({
row,
indent = 20,
className,
children,
}: {
row: Row<DataGridFeatures, TData>
/** Horizontal offset in px applied per tree depth level. */
indent?: number
className?: string
/** Custom toggle icon; replaces the default chevron. */
children?: ReactNode
}) {
const { props } = useDataGrid()
const isExpanded = row.getIsExpanded()
const controlSize = props.tableLayout?.dense ? "size-6" : "size-7"
return (
<span
data-slot="data-grid-table-row-expand"
style={getDataGridTreeIndentStyle(row, indent)}
className={cn(
"inline-flex shrink-0 items-center ps-(--data-grid-tree-padding) align-middle",
className
)}
>
{row.getCanExpand() ? (
<button
type="button"
aria-expanded={isExpanded}
aria-label={isExpanded ? "Collapse row" : "Expand row"}
onClick={(event) => {
// Expansion must not bubble into the row's onRowClick handler.
event.stopPropagation()
row.toggleExpanded()
}}
className={cn(
"text-muted-foreground hover:text-foreground rounded-lg inline-flex items-center justify-center transition-colors",
controlSize
)}
>
{children ?? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="transition-transform duration-200 in-aria-[expanded=false]:-rotate-90 rtl:in-aria-[expanded=false]:rotate-90"
>
<path d="m6 9 6 6 6-6" />
</svg>
)}
</button>
) : (
// Leaf spacer: compact by design so leaf content sits near the
// parent label instead of a full toggle width deeper.
<span aria-hidden="true" className="w-2 shrink-0" />
)}
</span>
)
}
function DataGridTableBodyRows<TData extends object>({
table,
}: {
table: DataGridTableInstance<TData>
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) {
const leftVisibleColumns = table.getStartVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getEndVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
))}
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (isLoading && props.loadingMode === "spinner") {
return (
<tr>
<td
colSpan={
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
}
className="p-8"
>
<div className="flex items-center justify-center">
<svg
className="text-muted-foreground mr-3 -ml-1 h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{props.loadingMessage || "Loading..."}
</div>
</td>
</tr>
)
}
const resolvedRows = getDataGridTableResolvedRows(
table,
props.tableLayout?.rowsPinnable
)
if (!resolvedRows.length) return <DataGridTableEmpty />
return (
<>
{resolvedRows.map(({ row, pinnedBoundary }) => (
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={pinnedBoundary}
/>
))}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableBodyRows = memo(
DataGridTableBodyRows,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableBodyRows
function DataGridTableHeader() {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<DataGridTableViewport>
<DataGridTableBase>
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => {
return (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
</DataGridTableBase>
</DataGridTableViewport>
)
}
function DataGridTable({
footerContent,
renderHeader = true,
}: {
footerContent?: ReactNode
renderHeader?: boolean
}) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<DataGridTableViewport>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => {
return (
<DataGridTableHeadRow
key={headerGroup.id}
rowId={headerGroup.id}
>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell
header={header}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell
header={header}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export {
DataGridTable,
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableRenderedRow,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
DataGridTableHeader,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableLoader,
DataGridTableRowExpand,
DataGridTableRowPin,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getPinningStyles,
getDataGridTableResolvedRows,
getDataGridTableRowSections,
getDataGridTreeIndentStyle,
hasDataGridTableRightPinnedColumns,
}
export type { DataGridTablePinnedBoundary }