'use client' import * as React from 'react' import type { ColumnSizingState, OnChangeFn, PaginationState, RowData, RowSelectionState, SortingState, Table } from '@tanstack/react-table' import { defaultGetId } from '@admin/utils/table/default-get-id' import { isString } from '@admin/utils/table/is-string' interface ColumnResizeInfo { id: string minSize: number maxSize: number size: number canResize: boolean } type StateSetter = (value: TValue) => unknown type NullableStateSetter = (value: TValue | null) => unknown interface SortingParamsSetterValue { pageIndex: number sortDir: string | null sortField: string | null } interface TableUtilsOptions { data?: TData[] selectedIds?: string[] onSelectedIdsChange?: (ids: string[]) => void getId?: (row: TData) => string | null | undefined pageIndex?: number pageSize?: number setPageIndex?: StateSetter setSortingParams?: (value: SortingParamsSetterValue) => unknown sortField?: string sortDir?: string setSortField?: NullableStateSetter setSortDir?: NullableStateSetter } export function useTableUtils({ data = [], selectedIds, onSelectedIdsChange, getId = defaultGetId, pageIndex, pageSize, setPageIndex, setSortingParams, sortField, sortDir, setSortField, setSortDir }: TableUtilsOptions = {}) { const [columnSizing, setColumnSizing] = React.useState({}) const [hoveredColumnId, setHoveredColumnId] = React.useState(null) const tableContainerRef = React.useRef(null) const tableRef = React.useRef | null>(null) const hasUserResizedColumnsRef = React.useRef(false) const columnResizeInfoRef = React.useRef([]) const registerTable = React.useCallback((table: Table) => { tableRef.current = table }, []) const constrainColumnSizing = React.useCallback( (current: ColumnSizingState, proposed: ColumnSizingState): ColumnSizingState => { const visibleColumns = columnResizeInfoRef.current if (visibleColumns.length === 0) return proposed let changedColumnIndex = -1 let changedDelta = 0 for (let index = 0; index < visibleColumns.length; index += 1) { const column = visibleColumns[index] const currentSize = current[column.id] ?? column.size const proposedSize = proposed[column.id] ?? currentSize const delta = proposedSize - currentSize if (Math.abs(delta) > Math.abs(changedDelta)) { changedColumnIndex = index changedDelta = delta } } if (changedColumnIndex === -1 || changedDelta === 0) return proposed const changedColumn = visibleColumns[changedColumnIndex] const nextColumn = visibleColumns[changedColumnIndex + 1] if (!changedColumn?.canResize || !nextColumn?.canResize) return current const currentChangedSize = current[changedColumn.id] ?? changedColumn.size const currentNextSize = current[nextColumn.id] ?? nextColumn.size const growRoom = Math.max(changedColumn.maxSize - currentChangedSize, 0) const shrinkRoom = Math.max(currentChangedSize - changedColumn.minSize, 0) const nextGrowRoom = Math.max(nextColumn.maxSize - currentNextSize, 0) const nextShrinkRoom = Math.max(currentNextSize - nextColumn.minSize, 0) const constrainedDelta = changedDelta > 0 ? Math.min(changedDelta, growRoom, nextShrinkRoom) : -Math.min(Math.abs(changedDelta), shrinkRoom, nextGrowRoom) const nextSizing = { ...current, ...proposed } nextSizing[changedColumn.id] = Math.round(currentChangedSize + constrainedDelta) nextSizing[nextColumn.id] = Math.round(currentNextSize - constrainedDelta) return nextSizing }, [] ) const handleColumnSizingChange = React.useCallback>( (updater) => { hasUserResizedColumnsRef.current = true setColumnSizing((current) => { const proposed = typeof updater === 'function' ? updater(current) : updater return constrainColumnSizing(current, proposed) }) }, [constrainColumnSizing] ) React.useLayoutEffect(() => { const table = tableRef.current if (!table) return columnResizeInfoRef.current = table.getVisibleLeafColumns().map((column) => ({ id: column.id, minSize: column.columnDef.minSize ?? 48, maxSize: column.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER, size: column.getSize(), canResize: column.getCanResize() })) }) const syncColumnSizingToContainer = React.useCallback(() => { const table = tableRef.current if (!table) return const containerWidth = tableContainerRef.current?.getBoundingClientRect().width ?? 0 const visibleColumns = table.getVisibleLeafColumns() if (containerWidth <= 0 || visibleColumns.length === 0) return const baseColumns = visibleColumns.map((column) => ({ id: column.id, minSize: column.columnDef.minSize ?? 48, size: column.getSize(), canGrow: column.getCanResize() })) const growableColumns = baseColumns.filter((column) => column.canGrow) const flexibleColumns = growableColumns.length > 0 ? growableColumns : baseColumns const flexibleColumnIds = new Set(flexibleColumns.map((column) => column.id)) const fixedTotal = baseColumns .filter((column) => !flexibleColumnIds.has(column.id)) .reduce((total, column) => total + column.size, 0) const flexibleBaseTotal = flexibleColumns.reduce((total, column) => total + column.size, 0) const flexibleMinTotal = flexibleColumns.reduce((total, column) => total + column.minSize, 0) const flexibleAvailable = Math.max(containerWidth - fixedTotal, flexibleMinTotal) const extraPerColumn = flexibleColumns.length > 0 && flexibleAvailable > flexibleBaseTotal ? (flexibleAvailable - flexibleBaseTotal) / flexibleColumns.length : 0 const shrinkNeeded = Math.max(flexibleBaseTotal - flexibleAvailable, 0) const shrinkRoom = flexibleColumns.reduce( (total, column) => total + Math.max(column.size - column.minSize, 0), 0 ) setColumnSizing((current) => { const next: ColumnSizingState = {} let changed = Object.keys(current).length !== baseColumns.length const targetTotal = Math.round(Math.max(containerWidth, fixedTotal + flexibleMinTotal)) const flexibleSizing = flexibleColumns.map((column) => { const columnShrinkRoom = Math.max(column.size - column.minSize, 0) const shrink = shrinkRoom > 0 ? shrinkNeeded * (columnShrinkRoom / shrinkRoom) : 0 const grow = extraPerColumn return { id: column.id, size: Math.round(column.size + grow - shrink) } }) const flexibleSizingById = new Map(flexibleSizing.map((column) => [column.id, column.size])) const roundedFixedTotal = baseColumns .filter((column) => !flexibleColumnIds.has(column.id)) .reduce((total, column) => total + Math.round(column.size), 0) const roundedFlexibleTotal = flexibleSizing.reduce((total, column) => total + column.size, 0) const roundingDelta = targetTotal - roundedFixedTotal - roundedFlexibleTotal const lastFlexibleColumn = flexibleSizing.at(-1) if (lastFlexibleColumn) { flexibleSizingById.set( lastFlexibleColumn.id, Math.max(lastFlexibleColumn.size + roundingDelta, 0) ) } for (const column of baseColumns) { const size = flexibleColumnIds.has(column.id) ? (flexibleSizingById.get(column.id) ?? Math.round(column.size)) : Math.round(column.size) next[column.id] = size if (current[column.id] !== size) { changed = true } } return changed ? next : current }) }, []) React.useLayoutEffect(() => { if (hasUserResizedColumnsRef.current) return syncColumnSizingToContainer() const element = tableContainerRef.current if (!element || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(() => { if (!hasUserResizedColumnsRef.current) { syncColumnSizingToContainer() } }) observer.observe(element) return () => observer.disconnect() }, [syncColumnSizingToContainer]) const resetColumnSizingToContainer = React.useCallback(() => { hasUserResizedColumnsRef.current = false window.requestAnimationFrame(syncColumnSizingToContainer) }, [syncColumnSizingToContainer]) const sorting = React.useMemo( () => (sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : []), [sortField, sortDir] ) const handleSortingChange = React.useCallback>( (updater) => { if (!setSortingParams && (!setSortField || !setSortDir || !setPageIndex)) { return } const newSorting = typeof updater === 'function' ? updater(sorting) : updater const [nextSort] = newSorting const nextSortField = nextSort?.id ?? null const nextSortDir = nextSort ? (nextSort.desc ? 'desc' : 'asc') : null React.startTransition(() => { if (setSortingParams) { void setSortingParams({ pageIndex: 0, sortDir: nextSortDir, sortField: nextSortField }) return } void setSortField?.(nextSortField) void setSortDir?.(nextSortDir) void setPageIndex?.(0) }) }, [sorting, setSortingParams, setSortField, setSortDir, setPageIndex] ) const rowSelection = React.useMemo(() => { if (!selectedIds) return {} const selection: RowSelectionState = {} data.forEach((row, index) => { const id = getId(row) if (isString(id) && selectedIds.includes(id)) { selection[index.toString()] = true } }) return selection }, [data, getId, selectedIds]) const handleRowSelectionChange = React.useCallback>( (updater) => { if (!selectedIds || !onSelectedIdsChange) return const newSelection = typeof updater === 'function' ? updater(rowSelection) : updater const currentPageSelectedIds = new Set( Object.keys(newSelection) .filter((key) => newSelection[key]) .map((key) => { const row = data[Number.parseInt(key, 10)] return row ? getId(row) : null }) .filter(isString) ) const currentPageIds = new Set(data.map(getId).filter(isString)) const otherPageIds = selectedIds.filter((id) => !currentPageIds.has(id)) onSelectedIdsChange([...otherPageIds, ...currentPageSelectedIds]) }, [data, getId, onSelectedIdsChange, rowSelection, selectedIds] ) const handlePaginationChange = React.useCallback>( (updater) => { if (pageIndex === undefined || pageSize === undefined || !setPageIndex) { return } const current = { pageIndex, pageSize } const next = typeof updater === 'function' ? updater(current) : updater React.startTransition(() => { void setPageIndex(next.pageIndex) }) }, [pageIndex, pageSize, setPageIndex] ) return { columnSizing, handleColumnSizingChange, handlePaginationChange, handleRowSelectionChange, handleSortingChange, hoveredColumnId, registerTable, resetColumnSizingToContainer, rowSelection, setHoveredColumnId, sorting, tableContainerRef } }