'use client' import { type ReactNode, useEffect, useRef } from 'react' import { cn } from '../../../utils/cn' import { Chevron02RightIcon } from '../../icons-v2-generated' import { Pagination } from '../../pagination' import { Button } from '../button' import { CursorPagination } from '../cursor-pagination' import { TableEmptyState } from './table-empty-state' import { TableHeader } from './table-header' import { TableRow } from './table-row' import { ROW_HEIGHT_DESKTOP, ROW_HEIGHT_MOBILE, TableCardSkeleton } from './table-skeleton' import { useTableMotion } from './use-table-motion' import type { RowAction, TableColumn, TableProps } from './types' /** * Injects synthetic columns (row actions and/or row-level chevron link) at the end of the columns array. */ function injectSyntheticColumns( columns: TableColumn[], rowActions?: RowAction[], renderRowActions?: (item: T) => ReactNode, rowHref?: (item: T) => string | null | undefined, actionsColumnWidth?: string, ): TableColumn[] { const hasActions = Boolean(rowActions?.length) || Boolean(renderRowActions) const result = [...columns] if (hasActions) { const actionsColumn: TableColumn = { key: '__actions__', label: '', // A caller-fixed width keeps the header and every row on ONE grid // (see `actionsColumnWidth` in types.ts); the default stays // content-sized for back-compat. width: actionsColumnWidth ?? 'min-w-[100px] w-auto shrink-0 flex-none', align: 'right', renderCell: (item: T) => (
{renderRowActions ? ( renderRowActions(item) ) : ( rowActions!.map((action, actionIndex) => ( )) )}
), } result.push(actionsColumn) } if (rowHref) { const chevronColumn: TableColumn = { key: '__chevron__', label: '', width: 'w-12 shrink-0 flex-none', align: 'right', renderCell: (item: T) => { const href = rowHref(item) if (!href) return null return (
) }, } result.push(chevronColumn) } return result } /** @deprecated Use `DataTable` from `data-table` instead. */ export function Table({ data, columns, rowKey, loading = false, emptyMessage, skeletonRows = 10, className, containerClassName, headerClassName, rowClassName, compact, onRowClick, rowActions, renderRowActions, rowHref, sortBy, sortDirection, onSort, filters, onFilterChange, selectable, selectedRows = [], onSelectionChange, bulkActions, showToolbar, cursorPagination, pagePagination, paginationClassName, infiniteScroll, stickyHeader, stickyHeaderOffset, animateRowReorder, actionsColumnWidth, }: TableProps) { // Opt-in row-reorder animation: framer-motion is loaded lazily (its own chunk) // ONLY when `animateRowReorder` is set, so the default table stays motion-free. const tableMotion = useTableMotion(Boolean(animateRowReorder)) const columnsWithActions = injectSyntheticColumns(columns, rowActions, renderRowActions, rowHref, actionsColumnWidth) const getRowHref = (item: T): string | undefined => { if (onRowClick || !rowHref) return undefined return rowHref(item) ?? undefined } const getRowKey = (item: T, index: number): string => { if (typeof rowKey === 'function') { return rowKey(item) } const key = item[rowKey] return key?.toString() || index.toString() } const getRowClassName = (item: T, index: number): string => { if (typeof rowClassName === 'function') { return rowClassName(item, index) } return rowClassName || '' } const isRowSelected = (item: T) => { if (!selectable || !selectedRows) return false const key = getRowKey(item, -1) return selectedRows.some(row => getRowKey(row, -1) === key) } const handleSelectRow = (item: T) => { if (!onSelectionChange) return const key = getRowKey(item, -1) const isSelected = isRowSelected(item) if (isSelected) { onSelectionChange(selectedRows.filter(row => getRowKey(row, -1) !== key)) } else { onSelectionChange([...selectedRows, item]) } } const handleSelectAll = () => { if (!onSelectionChange) return if (selectedRows.length === data.length) { onSelectionChange([]) } else { onSelectionChange([...data]) } } const allSelected = selectedRows.length > 0 && selectedRows.length === data.length const someSelected = selectedRows.length > 0 && selectedRows.length < data.length // Infinite scroll: IntersectionObserver on sentinel div const sentinelRef = useRef(null) const onLoadMoreRef = useRef(infiniteScroll?.onLoadMore) onLoadMoreRef.current = infiniteScroll?.onLoadMore useEffect(() => { if (!infiniteScroll?.hasNextPage || infiniteScroll.isFetchingNextPage) return const sentinel = sentinelRef.current if (!sentinel) return const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { onLoadMoreRef.current?.() } }, { rootMargin: '200px' }, ) observer.observe(sentinel) return () => observer.disconnect() }, [infiniteScroll?.hasNextPage, infiniteScroll?.isFetchingNextPage]) return (
{/* Toolbar for bulk actions */} {showToolbar && bulkActions && selectedRows.length > 0 && (
{selectedRows.length} item{selectedRows.length !== 1 ? 's' : ''} selected
{bulkActions.map((action, index) => ( ))}
)} {/* Desktop Header */} {/* Table Body */}
{loading && data.length === 0 ? ( 0} hasChevron={Boolean(rowHref)} /> ) : data.length === 0 ? ( ) : ( <> {/* Real data rows. When `animateRowReorder` is on, wrap ONLY these in a `LayoutGroup` so a reorder (same key, new order) animates via FLIP — the invisible placeholder rows below are intentionally left outside it. `AnimatePresence` is not needed here: the row set is stable across a reorder (no enter/exit); add it if a future task animates rows being added/removed. */} {(() => { const rows = data.map((item, index) => ( )) // LayoutGroup only once framer-motion has lazily resolved; until // then (and when off) rows render as plain `
`s. const LayoutGroup = tableMotion?.LayoutGroup return animateRowReorder && LayoutGroup ? {rows} : rows })()} {/* Infinite scroll: skeleton rows */} {infiniteScroll?.isFetchingNextPage && ( 0} hasChevron={Boolean(rowHref)} /> )} {/* Infinite scroll: sentinel element */} {infiniteScroll?.hasNextPage && (