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( 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 ): (() => 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 = {} 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( 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 } function DataGridTableFillCol() { const { props } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillHeadCell() { const { props } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillBodyCell() { const { props } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } function DataGridTableFillFootCell() { const { props } = useDataGrid() if (!props.tableLayout?.columnsResizable) return null return ( ) } 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 = {} 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 ( {[...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 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 (
{children}
) } function DataGridTableHead({ children }: { children: ReactNode }) { const { props } = useDataGrid() return ( {children} ) } function DataGridTableHeadRow({ children, }: { 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 } = 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" : 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} ) } /** * 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() if (isResizeModeOnEnd) { stopResizeSessionRef.current?.() stopResizeSessionRef.current = startDataGridColumnResizeOnEnd( event, header, table ) return } header.getResizeHandler()(event) } const handleTouchStart = (event: ReactTouchEvent) => { event.preventDefault() event.stopPropagation() if (isResizeModeOnEnd) { stopResizeSessionRef.current?.() stopResizeSessionRef.current = startDataGridColumnResizeOnEnd( event, header, table ) return } header.getResizeHandler()(event) } return (
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 }) { 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 // 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 (