import * as React from 'react' import { flexRender, type ColumnDef, type Row, type Table as TanstackTable, } from '@tanstack/react-table' import { useMediaQuery } from '@/hooks' import { cn } from '@/lib/utils' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { PageFooterPortal } from '@/components/layout' import { MobileCardList } from './mobile-card-list' import { DataTablePagination } from './pagination' import { TableEmpty } from './table-empty' import { TableSkeleton } from './table-skeleton' import { DataTableToolbar } from './toolbar' /** * Pass-through configuration for the default {@link DataTableToolbar}. * Pass `toolbar` (ReactNode) instead to fully replace the default toolbar. */ export type DataTablePageToolbarProps = Omit< React.ComponentProps>, 'table' > export type DataTablePageProps = { /** * TanStack Table instance returned from `useReactTable`. */ table: TanstackTable /** * Column definitions. Used for skeleton column count and empty-state colSpan. */ columns: ColumnDef[] /** * Initial loading state — renders {@link TableSkeleton} or mobile skeleton. */ isLoading?: boolean /** * Refetch / background loading — dims the table without removing rows. */ isFetching?: boolean /** * Empty-state title (used for both desktop {@link TableEmpty} and mobile fallback). */ emptyTitle?: string /** * Empty-state description. */ emptyDescription?: string /** * Empty-state icon override (desktop only; mobile uses default Database icon). */ emptyIcon?: React.ReactNode /** * Empty-state extra content — e.g. a "Create" button below the message. */ emptyAction?: React.ReactNode /** * Custom toolbar node — fully replaces the default {@link DataTableToolbar}. * Useful for layouts like "primary buttons + toolbar" or feature-specific filter cards. * If provided, `toolbarProps` is ignored. */ toolbar?: React.ReactNode /** * Pass-through props for the default {@link DataTableToolbar}. * Ignored if `toolbar` is provided. Pass `null` to omit the toolbar entirely. */ toolbarProps?: DataTablePageToolbarProps | null /** * Bulk action bar — typically a wrapped {@link DataTableBulkActions} component. * Rendered only on desktop (mobile selection is uncommon). */ bulkActions?: React.ReactNode /** * Custom mobile list node — fully replaces the default {@link MobileCardList}. */ mobile?: React.ReactNode /** * Pass-through props for the default {@link MobileCardList}. * Ignored if `mobile` is provided. */ mobileProps?: { getRowKey?: (row: Row) => string | number getRowClassName?: (row: Row) => string | undefined } /** * Disable the mobile-specific layout entirely — always renders desktop table. * Useful for pages where the table is read-only and short. */ hideMobile?: boolean /** * Row className resolver — applied to both desktop `TableRow` and mobile card. * Composes with the default `data-state="selected"` styling on desktop. * The `ctx.isMobile` flag is provided so consumers can return the * appropriate variant (e.g. `DISABLED_ROW_DESKTOP` vs `DISABLED_ROW_MOBILE`) * without having to re-call `useMediaQuery` themselves. */ getRowClassName?: ( row: Row, ctx: { isMobile: boolean } ) => string | undefined /** * Custom desktop row renderer — replaces the default ``/`` mapping. * Use for expanded rows, aggregate rows, click-on-row navigation, etc. */ renderRow?: (row: Row) => React.ReactNode /** * Apply explicit column widths from `header.getSize()` to ``. * Enable this when your column definitions include `size` and you want it honored. * Off by default (TanStack Table assigns a default size of 150 to all columns * which would unintentionally constrain layouts that don't define sizes). */ applyHeaderSize?: boolean /** * Optional skeleton key prefix for stable React keys across re-renders. */ skeletonKeyPrefix?: string /** * Whether to render pagination. Defaults to `true`. */ showPagination?: boolean /** * Render pagination via `PageFooterPortal` (sticks to page footer). * Defaults to `true`. Set `false` to render inline below the table. */ paginationInFooter?: boolean /** * Extra content rendered between the table/mobile list and the pagination. * E.g. summary stats, helper text. */ afterTable?: React.ReactNode /** * Outer wrapper className (applied to the toolbar+table column). */ className?: string /** * Desktop table container className (the bordered scroll wrapper). */ tableClassName?: string /** * Desktop `` className override. * Useful for sticky headers (`'sticky top-0 z-10 bg-muted/30'`) on long lists. */ tableHeaderClassName?: string } /** * Unified table page wrapper. Encapsulates the canonical structure used across * all list pages: toolbar → desktop table / mobile list → pagination, plus * loading/empty states and an opt-in bulk action bar. * * Most pages should be expressible as: * ```tsx * } * /> * ``` * * For complex layouts (custom mobile, expanded rows, custom toolbar), use the * `toolbar` / `mobile` / `renderRow` slots instead of the `*Props` variants. */ export function DataTablePage(props: DataTablePageProps) { const isMobile = useMediaQuery('(max-width: 640px)') const showMobile = isMobile && !props.hideMobile const toolbarNode = renderToolbar(props) const mobileNode = renderMobile(props, showMobile) const desktopNode = renderDesktop(props, showMobile) return ( <>
{toolbarNode} {mobileNode} {desktopNode} {props.afterTable}
{/* Bulk actions are typically a fixed-position toolbar; let the consumer handle its own visibility, we just gate it to non-mobile. */} {!showMobile && props.bulkActions} {props.showPagination !== false && (props.paginationInFooter !== false ? ( ) : (
))} ) } function renderToolbar( props: DataTablePageProps ): React.ReactNode { if (props.toolbar !== undefined) { return props.toolbar } if (props.toolbarProps === null) { return null } if (props.toolbarProps) { return } return null } function renderMobile( props: DataTablePageProps, showMobile: boolean ): React.ReactNode { if (!showMobile) return null if (props.mobile !== undefined) return props.mobile const ownGetRowClassName = props.getRowClassName const mobileGetRowClassName = props.mobileProps?.getRowClassName ?? (ownGetRowClassName ? (row: Row) => ownGetRowClassName(row, { isMobile: true }) : undefined) return ( ) } function renderDesktop( props: DataTablePageProps, showMobile: boolean ): React.ReactNode { if (showMobile) return null const rows = props.table.getRowModel().rows const isFetchingOnly = props.isFetching && !props.isLoading return (
{props.table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {props.isLoading ? ( ) : rows.length === 0 ? ( {props.emptyAction} ) : ( rows.map((row) => { if (props.renderRow) { return props.renderRow(row) } return ( ) }) )}
) } function DefaultRow({ row, className, }: { row: Row className?: string }) { return ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) }