"use client" 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 { dataGridCellSelectionCellClasses, getDataGridCellSelectionCellAttrs, toDataGridDomId, 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 "@/lib/utils" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Spinner } from "@/components/ui/spinner" import { PlusIcon } from "lucide-react" // 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" // The truncation wrapper used when cell selection is on: it consumes the // td padding with negative margins and restores it as its own, so its // overflow clips at exactly the boundary the td's `truncate` used to clip // at - including custom cells that pull into the padding the same way. const bodyCellTruncateWrapVariants = ({ size, }: { size?: "dense" | "default" }) => size === "dense" ? "-mx-2 -my-1.5 truncate px-2 py-1.5" : "-mx-3 -my-2 truncate px-3 py-2" const footerCellSpacingVariants = ({ size }: { size?: "dense" | "default" }) => size === "dense" ? "px-2 py-1.5" : "px-3 py-2" function getPinningStyles( column: Column ): 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( row: Row, indent: number = 20 ): CSSProperties { return { "--data-grid-tree-padding": `${row.depth * indent}px`, } as CSSProperties } function assignRef(ref: Ref | 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 | ReactTouchEvent type DataGridResizeDocumentEvent = globalThis.MouseEvent | globalThis.TouchEvent function isDataGridTouchEvent( event: DataGridResizeStartEvent | DataGridResizeDocumentEvent ): event is ReactTouchEvent | 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( event: DataGridResizeStartEvent, header: Header, table: DataGridTableInstance, /** * Live mode: per-move width updates go straight to the table element's * `--col/--header` variables, so cells track the pointer with ZERO React * renders; state is written twice per drag (start marker, release commit) * instead of per mousemove - which re-renders the consumer's whole tree. */ live = false ): (() => 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 liveTableElement = live ? headerCell?.closest("table") : null // An end-pinned column is anchored at its end edge and grows from its // START edge, so its whole resize geometry runs mirrored, exactly like // RTL: the anchor is the opposite edge and the drag direction inverts. const resizeRtl = (table.options.columnResizeDirection === "rtl") !== (column.getIsPinned() === "end") const startOffset = headerRect && Number.isFinite(resizeRtl ? headerRect.left : headerRect.right) ? resizeRtl ? 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 = resizeRtl ? -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 = {} 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 }) if (liveTableElement) { columnSizingStart.forEach(([columnId]) => { const nextSize = nextColumnSizing[columnId] if (typeof nextSize !== "number") return liveTableElement.style.setProperty( `--col-${columnId}-size`, String(nextSize) ) liveTableElement.style.setProperty( `--header-${columnId}-size`, String(nextSize) ) }) } else { 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( table: DataGridTableInstance, rowsPinnable?: boolean ) { if (!rowsPinnable) { return { topRows: [] as Row[], centerRows: table.getRowModel().rows as Row[], bottomRows: [] as Row[], } } return { topRows: table.getTopRows() as Row[], centerRows: table.getCenterRows() as Row[], bottomRows: table.getBottomRows() as Row[], } } function getDataGridTableResolvedRows( table: DataGridTableInstance, rowsPinnable?: boolean ) { const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( table, rowsPinnable ) const resolvedRows: Array<{ row: Row 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( table: DataGridTableInstance ) { return [ ...table.getStartVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getEndVisibleLeafColumns(), ] as Column[] } function getDataGridTableOrderedVisibleCells( row: Row ) { return [ ...row.getStartVisibleCells(), ...row.getCenterVisibleCells(), ...row.getEndVisibleCells(), ] as Cell[] } function getDataGridTableMergedHeaderGroups( table: DataGridTableInstance ) { 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[], } }) } function hasDataGridTableRightPinnedColumns( table: DataGridTableInstance ) { return (table.state.columnPinning.end?.length ?? 0) > 0 } // A meta.fillWidth column absorbs the free space itself, so the fill cells // must collapse or the strip would be counted twice. function hasDataGridFillColumn( table: DataGridTableInstance ) { return table .getVisibleLeafColumns() .some((column) => column.columnDef.meta?.fillWidth) } function DataGridTableFillCol() { const { props, table } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillHeadCell() { const { props, table } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillBodyCell() { const { props, table } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillFootCell() { const { props, table } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } /** * The quick-create affordance, rendered as the body's last row when * `onRowCreate` is set: one full-width ghost button, the Notion/Airtable * "+ New" idiom. What the click creates stays consumer-owned. */ function DataGridTableAddRow() { const { i18n, props, table } = useDataGrid() if (!props.onRowCreate) return null return ( ) } function DataGridTableBase({ children }: { children: ReactNode }) { const { props, table, recordCount } = 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() // A meta.fillWidth column absorbs the filler strip: its size variable // carries the unitless fill amount, so every consumer of // calc(var(--col-X-size) * 1px) stretches with the container while the // fill cells collapse to zero. const fillColumnId = table .getVisibleLeafColumns() .find((column) => column.columnDef.meta?.fillWidth)?.id const colSizes: Record = {} 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.id === fillColumnId ? `calc(${header.column.getSize()} + var(--data-grid-fill, 0))` : 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. table.state.columnSizing, table.state.columnVisibility, table.state.columnOrder, table.state.columnPinning, // A def swap can change a column's `size` without touching sizing // STATE; without this dep the CSS variables keep the old widths. For a // consumer defining columns inline the memo degrades to per-render // recompute, which is the safe direction. table.options.columns, ]) // With cell selection on, the table announces as a grid so the tds compute // as gridcells and aria-selected applies; existing grids keep their plain // semantic table untouched. const cellSelectionOn = !!props.tableLayout?.cellSelection && table.atoms.cellSelection != null return ( {[...leftVisibleColumns, ...centerVisibleColumns].map((column) => ( ))} {hasRightPinnedColumns ? : null} {rightVisibleColumns.map((column) => ( ))} {!hasRightPinnedColumns ? : null} {children}
) } function DataGridTableViewport({ children, className, viewportRef, style, }: { children: ReactNode className?: string viewportRef?: Ref style?: CSSProperties }) { const { props, table, autoSize } = useDataGrid() const isColumnsResizable = !!props.tableLayout?.columnsResizable const viewportNodeRef = useRef(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 freeSpace = fillStateRef.current.containerWidth - table.getTotalSize() const fillWidth = Math.max(0, freeSpace) if (fillStateRef.current.appliedFill !== fillWidth) { fillStateRef.current.appliedFill = fillWidth node.style.setProperty("--data-grid-fill-size", `${fillWidth}px`) // Unitless twin for the meta.fillWidth column, whose size variable // participates in a calc that multiplies by 1px afterwards. node.style.setProperty("--data-grid-fill", String(fillWidth)) } // Signed on purpose: a shrinking container drives the free space // NEGATIVE, which is exactly what tells a meta.autoSize column to hand // its absorbed width back. autoSize?.apply(freeSpace) }, [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") node.style.removeProperty("--data-grid-fill") 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 (
{children}
) } function DataGridTableHead({ children }: { children: ReactNode }) { const { props } = useDataGrid() return ( {children} ) } function DataGridTableHeadRow({ children, rowId: _rowId, }: { children: ReactNode rowId: string }) { const { props } = useDataGrid() return ( 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} ) } function DataGridTableHeadRowCell({ children, header, dndRef, dndStyle, }: { children: ReactNode header: Header dndRef?: React.Ref dndStyle?: CSSProperties }) { const { props, table } = 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 ( 1 ? header.colSpan : undefined} aria-sort={ sortDirection === "asc" ? "ascending" : sortDirection === "desc" ? "descending" : column.getCanSort() ? "none" : undefined } aria-colindex={ props.tableLayout?.cellSelection ? column.getIndex() + 1 : undefined } data-col-id={column.id} style={{ ...(props.tableLayout?.width === "fixed" && !props.tableLayout?.columnsResizable && { width: header.getSize(), }), ...(props.tableLayout?.columnsPinnable && column.getCanPin() && getPinningStyles(column)), // A sticky child does not ride a sticky ancestor: under a sticky // header the pinned cell's own position:sticky (horizontal) opts it // out of the thead's vertical stickiness, and the corner scrolls // away while its non-positioned siblings stick. Pin both axes on // the cell itself. ...(props.tableLayout?.headerSticky && isPinned && { top: 0 }), ...(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", // The resize filler column follows the center group, and a border on // the last center cell would float against that empty strip. props.tableLayout?.cellBorder && props.tableLayout?.columnsResizable && // With end-pinned columns but no pin affordance, the pinned edge // draws no separator of its own; keep the border then. (props.tableLayout?.columnsPinnable || !hasDataGridTableRightPinnedColumns(table)) && !isPinned && column.getIsLastColumn("center") && "border-e-0", 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} ) } /** * 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({ header, }: { header: Header }) { const { props, table } = useDataGrid() 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) => { // 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() stopResizeSessionRef.current?.() stopResizeSessionRef.current = startDataGridColumnResizeOnEnd( event, header, table, !isResizeModeOnEnd ) } const handleTouchStart = (event: ReactTouchEvent) => { event.preventDefault() event.stopPropagation() stopResizeSessionRef.current?.() stopResizeSessionRef.current = startDataGridColumnResizeOnEnd( event, header, table, !isResizeModeOnEnd ) } return ( // A span, deliberately: consumers stretch end-aligned header CONTENT // with `[&>div]:w-full` on headerClassName, and that compiled child // selector (0,1,1) beats the handle's own w-5 (0,1,0) - a div handle // then covers the whole cell and swallows the menu click. Hardening // w-5 with ! was rejected: the tag change removes the fight instead // of escalating it. column.resetSize(), onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, className: cn( "absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex", isPinned === "end" ? cn( // An end-pinned column grows from its START edge (its end is // anchored), so the handle lives there, inside the sticky // cell. "start-0 w-5 justify-start", props.tableLayout?.columnsPinnable ? "before:hidden" : "before:absolute before:inset-y-0 before:start-0 before:w-px before:bg-border" ) : 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" ) : cn( "-end-2 w-5 justify-center", // With cell borders on, the th border-e already marks every // boundary and this always-on line would double it; the wide // hit area and resize cursor stay. props.tableLayout?.cellBorder ? "before:hidden" : "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 }) { const { props, table } = useDataGrid() const indicatorRef = useRef(null) const indicatorHeadRef = useRef(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 // instead of mirroring it. End-pinned columns resize mirrored (see the // session), so the multiplier matches theirs. const directionMultiplier = (table.options.columnResizeDirection === "rtl") !== (resizingHeader.column.getIsPinned() === "end") ? -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 // Anchor on the live header cell: content math (getStart) cannot place a // STICKY pinned column, while the DOM rect is right for every column at // any scroll position. The state offset and content math stay as // fallbacks for a header that is not in the DOM. const viewportRoot = viewportElement ?? indicator.closest('[data-slot="data-grid-table-viewport"]') const headerCellElement = viewportRoot?.querySelector( `thead th[data-col-id="${CSS.escape(resizingHeader.column.id)}"]` ) let indicatorLeft: number if (headerCellElement && viewportRoot) { const anchorRect = headerCellElement.getBoundingClientRect() const edgeClientX = (table.options.columnResizeDirection === "rtl") !== (resizingHeader.column.getIsPinned() === "end") ? anchorRect.left : anchorRect.right indicatorLeft = edgeClientX - viewportRoot.getBoundingClientRect().left } else if ( typeof columnResizing.startOffset === "number" && viewportElement ) { indicatorLeft = columnResizing.startOffset - viewportElement.getBoundingClientRect().left } else { indicatorLeft = 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 (