"use client"
import * as React from "react"
import {
CaretDoubleLeftIcon,
CaretDoubleRightIcon,
CaretDownIcon,
CaretLeftIcon,
CaretRightIcon,
CaretUpDownIcon,
CaretUpIcon,
CheckIcon,
MagnifyingGlassIcon,
PlusCircleIcon,
SlidersHorizontalIcon,
XIcon,
} from "@phosphor-icons/react"
import {
columnFacetingFeature,
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
globalFilteringFeature,
metaHelper,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
sortFn_text,
tableFeatures,
useTable,
type Column,
type ColumnDef,
type ColumnFiltersState,
type ColumnVisibilityState,
type FilterFn,
type ReactTable,
type Row,
type RowData,
type RowSelectionState,
type SortingState,
} from "@tanstack/react-table"
import { NumberPop } from "@/components/ui/motion"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { cue } from "@/lib/sounds"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "cn"
/*
* Data Table: a full table on TanStack Table v9 and the Table primitive.
* Pass columns and data and you get sorting, a global search, faceted
* filters with counts, column visibility, row selection, pagination with a
* rows per page Select, an Empty state and Skeleton loading rows. The
* toolbar keeps every control on one row and scrolls sideways on a phone,
* and the table scrolls inside its own frame so the page never does.
*
* Consumers write column defs only. Use `dataTableColumns<T>()` for typed
* helpers, `DataTableColumnHeader` for a sortable header, and put
* `meta.filterOptions` on a column to get a faceted filter for it.
*/
/* ---------------------------------------------------------------- features */
export type DataTableFilterOption = {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}
export type DataTableColumnMeta = {
/** Name shown in the View menu and filter buttons. Defaults to the column id. */
label?: string
/** Turns on a faceted filter in the toolbar for this column. */
filterOptions?: DataTableFilterOption[]
/** Classes for the header and every cell, e.g. "text-right". */
className?: string
}
/** Keeps rows whose value is one of the picked values. */
const filterFn_facet: FilterFn<any, any> = (row, columnId, value) => {
if (!Array.isArray(value) || value.length === 0) return true
return value.includes(String(row.getValue(columnId)))
}
filterFn_facet.autoRemove = (value) => !Array.isArray(value) || value.length === 0
export const dataTableFeatures = tableFeatures({
columnFacetingFeature,
columnFilteringFeature,
columnVisibilityFeature,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns: { includesString: filterFn_includesString, facet: filterFn_facet },
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text, basic: sortFn_basic, datetime: sortFn_datetime },
columnMeta: metaHelper<DataTableColumnMeta>(),
})
export type DataTableFeatures = typeof dataTableFeatures
export type DataTableColumnDef<TData extends RowData> = ColumnDef<DataTableFeatures, TData, any>
export type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
type DataTableColumn<TData extends RowData, TValue = unknown> = Column<DataTableFeatures, TData, TValue>
/** Typed column helper: `const col = dataTableColumns<Invoice>()`, then `col.accessor(...)`. */
export function dataTableColumns<TData extends RowData>() {
return createColumnHelper<DataTableFeatures, TData>()
}
function columnLabel<TData extends RowData>(column: DataTableColumn<TData, any>) {
return column.columnDef.meta?.label ?? column.id
}
/* ----------------------------------------------------------- column header */
export function DataTableColumnHeader<TData extends RowData, TValue>({
column,
title,
className,
}: {
column: DataTableColumn<TData, TValue>
title: string
className?: string
}) {
if (!column.getCanSort()) {
return (
<span data-slot="data-table-column-header" className={className}>
{title}
</span>
)
}
const sorted = column.getIsSorted()
const Icon = sorted === "asc" ? CaretUpIcon : sorted === "desc" ? CaretDownIcon : CaretUpDownIcon
const next = sorted === "asc" ? "descending" : sorted === "desc" ? "not sorted" : "ascending"
return (
<Button
data-slot="data-table-column-header"
data-sorted={sorted || undefined}
type="button"
variant="ghost"
size="sm"
aria-label={`${title}, sort ${next}`}
className={cn(
"-mx-2 h-7 gap-1 px-2 font-medium in-[.text-right]:flex-row-reverse data-sorted:text-foreground",
className
)}
onClick={() => {
cue("tap")
column.toggleSorting()
}}
>
<span>{title}</span>
<Icon
weight="bold"
data-slot="data-table-sort-icon"
className={cn("size-3.5", sorted ? "text-foreground" : "text-muted-foreground/70")}
/>
</Button>
)
}
/* ---------------------------------------------------------- faceted filter */
export function DataTableFacetedFilter<TData extends RowData, TValue>({
column,
title,
options,
}: {
column?: DataTableColumn<TData, TValue>
title?: string
options: DataTableFilterOption[]
}) {
const facets = column?.getFacetedUniqueValues()
const selected = new Set((column?.getFilterValue() as string[] | undefined) ?? [])
const toggle = (value: string) => {
const next = new Set(selected)
if (next.has(value)) next.delete(value)
else next.add(value)
cue("select")
column?.setFilterValue(next.size ? Array.from(next) : undefined)
}
return (
<Popover onOpenChange={(open) => cue(open ? "open" : "close")}>
<PopoverTrigger asChild>
<Button
data-slot="data-table-faceted-filter"
data-active={selected.size > 0 || undefined}
type="button"
variant="outline"
size="sm"
className="shrink-0 border-dashed"
>
<PlusCircleIcon weight="duotone" />
{title}
{selected.size > 0 && (
<>
<Separator orientation="vertical" className="mx-0.5 h-3.5!" />
{selected.size > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
<NumberPop value={selected.size} /> selected
</Badge>
) : (
options
.filter((option) => selected.has(option.value))
.map((option) => (
<Badge key={option.value} variant="secondary" className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent data-slot="data-table-faceted-filter-content" className="w-52 p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.has(option.value)
const count = facets?.get(option.value) ?? 0
return (
<CommandItem key={option.value} value={option.label} onSelect={() => toggle(option.value)}>
<span
data-slot="data-table-faceted-filter-check"
data-selected={isSelected || undefined}
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors duration-(--duration-quick)",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input [&_svg]:invisible"
)}
>
<CheckIcon weight="bold" className="size-3 text-primary-foreground" />
</span>
{option.icon && <option.icon className="size-4 text-muted-foreground" />}
<span className="truncate">{option.label}</span>
<CommandShortcut className="font-mono tracking-normal tabular-nums">
{count}
</CommandShortcut>
</CommandItem>
)
})}
</CommandGroup>
{selected.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
cue("close")
column?.setFilterValue(undefined)
}}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
/* ------------------------------------------------------------ view options */
export function DataTableViewOptions<TData extends RowData>({ table }: { table: DataTableInstance<TData> }) {
const columns = table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== "undefined" && column.getCanHide())
if (columns.length === 0) return null
return (
<DropdownMenu onOpenChange={(open) => cue(open ? "open" : "close")}>
<DropdownMenuTrigger asChild>
<Button data-slot="data-table-view-options" type="button" variant="outline" size="sm" className="shrink-0">
<SlidersHorizontalIcon weight="duotone" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => {
cue("toggle")
column.toggleVisibility(!!value)
}}
>
{columnLabel(column)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
/* ----------------------------------------------------------------- toolbar */
export function DataTableToolbar<TData extends RowData>({
table,
searchPlaceholder = "Search...",
search = true,
children,
className,
}: {
table: DataTableInstance<TData>
searchPlaceholder?: string
search?: boolean
/** Extra controls, placed at the end of the row next to View. */
children?: React.ReactNode
className?: string
}) {
const globalFilter = (table.state.globalFilter as string | undefined) ?? ""
const isFiltered = table.state.columnFilters.length > 0 || globalFilter.length > 0
const facetColumns = table.getAllColumns().filter((column) => column.columnDef.meta?.filterOptions)
return (
<div
data-slot="data-table-toolbar"
className={cn(
// Room above and below so button shadows are not clipped by the scroll box.
"-mx-1 -my-2 flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto px-1 py-2 scroll-fade-x [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className
)}
>
{search && (
<InputGroup data-slot="data-table-search" className="w-44 shrink-0 sm:w-64">
<InputGroupAddon>
<MagnifyingGlassIcon weight="duotone" />
</InputGroupAddon>
<InputGroupInput
aria-label={searchPlaceholder}
placeholder={searchPlaceholder}
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
/>
{globalFilter && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => {
cue("close")
table.setGlobalFilter("")
}}
>
<XIcon weight="bold" />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)}
{facetColumns.map((column) => (
<DataTableFacetedFilter
key={column.id}
column={column}
title={columnLabel(column)}
options={column.columnDef.meta!.filterOptions!}
/>
))}
{isFiltered && (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => {
cue("close")
table.resetColumnFilters()
table.setGlobalFilter("")
}}
>
Reset
<XIcon weight="bold" />
</Button>
)}
<div data-slot="data-table-toolbar-actions" className="ml-auto flex shrink-0 items-center gap-2">
{children}
<DataTableViewOptions table={table} />
</div>
</div>
)
}
/* -------------------------------------------------------------- pagination */
export function DataTablePagination<TData extends RowData>({
table,
pageSizes = [10, 20, 30, 50],
className,
}: {
table: DataTableInstance<TData>
pageSizes?: number[]
className?: string
}) {
const { pageIndex, pageSize } = table.state.pagination
const pageCount = Math.max(1, table.getPageCount())
const selectable = table.options.enableRowSelection !== false
const go = (fn: () => void) => () => {
cue("navigate")
fn()
}
return (
<div
data-slot="data-table-pagination"
className={cn("flex flex-wrap items-center justify-between gap-x-4 gap-y-2 text-sm", className)}
>
<div data-slot="data-table-selection-count" className="text-muted-foreground tabular-nums">
{selectable
? `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`
: `${table.getFilteredRowModel().rows.length} rows`}
</div>
<div className="ml-auto flex items-center gap-3 sm:gap-6">
<div className="flex items-center gap-2">
<span className="hidden font-medium sm:inline">Rows per page</span>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
cue("select")
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" aria-label="Rows per page" className="w-17">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{pageSizes.map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination className="mx-0 w-auto">
<PaginationContent className="gap-1">
<PaginationItem className="hidden sm:block">
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to first page"
disabled={!table.getCanPreviousPage()}
onClick={go(() => table.setPageIndex(0))}
>
<CaretDoubleLeftIcon weight="bold" className="cn-rtl-flip" />
</Button>
</PaginationItem>
<PaginationItem>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to previous page"
disabled={!table.getCanPreviousPage()}
onClick={go(() => table.previousPage())}
>
<CaretLeftIcon weight="bold" className="cn-rtl-flip" />
</Button>
</PaginationItem>
<PaginationItem
data-slot="data-table-page"
aria-live="polite"
className="px-1.5 font-medium whitespace-nowrap tabular-nums"
>
Page <NumberPop value={pageIndex + 1} /> of {pageCount}
</PaginationItem>
<PaginationItem>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to next page"
disabled={!table.getCanNextPage()}
onClick={go(() => table.nextPage())}
>
<CaretRightIcon weight="bold" className="cn-rtl-flip" />
</Button>
</PaginationItem>
<PaginationItem className="hidden sm:block">
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to last page"
disabled={!table.getCanNextPage()}
onClick={go(() => table.setPageIndex(table.getPageCount() - 1))}
>
<CaretDoubleRightIcon weight="bold" className="cn-rtl-flip" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)
}
/* -------------------------------------------------------- selection column */
function selectionColumn<TData extends RowData>(): DataTableColumnDef<TData> {
return {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => {
cue("toggle")
table.toggleAllPageRowsSelected(!!value)
}}
aria-label="Select all rows on this page"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(value) => {
cue("toggle")
row.toggleSelected(!!value)
}}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
enableGlobalFilter: false,
meta: { className: "w-8" },
}
}
/* -------------------------------------------------------------- data table */
export type DataTableProps<TData extends RowData> = {
columns: DataTableColumnDef<TData>[]
data: TData[]
/** Adds a checkbox column for row selection. */
selectable?: boolean
/** Shows Skeleton rows in place of data. */
loading?: boolean
/** Number of Skeleton rows while loading. Defaults to 5. */
loadingRows?: number
/** Turns the search box off. */
search?: boolean
searchPlaceholder?: string
pageSizes?: number[]
initialPageSize?: number
initialSorting?: SortingState
/** Stable row ids, so selection survives sorting and new data. */
getRowId?: (row: TData, index: number) => string
/** Called with the selected rows whenever selection changes. */
onSelectionChange?: (rows: TData[]) => void
/** Extra controls at the end of the toolbar row. Gets the table instance. */
toolbar?: React.ReactNode | ((table: DataTableInstance<TData>) => React.ReactNode)
/** Replaces the default empty state. */
empty?: React.ReactNode
/** Tallest the table body gets before it scrolls, with the header pinned. Defaults to 30rem. */
maxHeight?: string
className?: string
}
export function DataTable<TData extends RowData>({
columns,
data,
selectable = false,
loading = false,
loadingRows = 5,
search = true,
searchPlaceholder,
pageSizes = [10, 20, 30, 50],
initialPageSize,
initialSorting = [],
getRowId,
onSelectionChange,
toolbar,
empty,
maxHeight = "30rem",
className,
}: DataTableProps<TData>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting)
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<ColumnVisibilityState>({})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = React.useState("")
const allColumns = React.useMemo(() => {
// Columns with filter options filter by "one of the picked values".
const withFacets = columns.map((column) =>
column.meta?.filterOptions && !column.filterFn ? { ...column, filterFn: "facet" as const } : column
)
return selectable ? [selectionColumn<TData>(), ...withFacets] : withFacets
}, [columns, selectable])
const table = useTable({
features: dataTableFeatures,
data,
columns: allColumns,
getRowId,
state: { sorting, columnFilters, columnVisibility, rowSelection, globalFilter },
initialState: { pagination: { pageIndex: 0, pageSize: initialPageSize ?? pageSizes[0] ?? 10 } },
enableRowSelection: selectable,
globalFilterFn: "includesString",
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
})
// Report selection to the consumer after it settles.
const onSelectionRef = React.useRef(onSelectionChange)
onSelectionRef.current = onSelectionChange
React.useEffect(() => {
onSelectionRef.current?.(table.getSelectedRowModel().rows.map((row: Row<DataTableFeatures, TData>) => row.original))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowSelection, data])
// When loading ends, rows fade in once with the skeleton reveal.
const [revealing, setRevealing] = React.useState(false)
const wasLoading = React.useRef(loading)
React.useEffect(() => {
if (wasLoading.current && !loading) {
setRevealing(true)
const id = window.setTimeout(() => setRevealing(false), 700)
wasLoading.current = loading
return () => window.clearTimeout(id)
}
wasLoading.current = loading
}, [loading])
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
const isFiltered = columnFilters.length > 0 || globalFilter.length > 0
const extra = typeof toolbar === "function" ? toolbar(table) : toolbar
return (
<div data-slot="data-table" className={cn("flex w-full min-w-0 flex-col gap-3", className)}>
<DataTableToolbar table={table} search={search} searchPlaceholder={searchPlaceholder}>
{extra}
</DataTableToolbar>
<div
data-slot="data-table-frame"
style={{ "--data-table-height": maxHeight } as React.CSSProperties}
className={cn(
"min-w-0 overflow-hidden rounded-lg border"
)}
>
{/* Scrolls both ways inside the frame. globals.css fades the edges with more to show, on the viewport only, so the scrollbars stay opaque. */}
<ScrollFadeArea fade="none" scrollbars="both" className="max-h-(--data-table-height) overscroll-contain">
<table data-slot="table" aria-busy={loading || undefined} className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-[color-mix(in_oklab,var(--muted)_40%,var(--background))]">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted()
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
className={cn("px-3", header.column.columnDef.meta?.className)}
>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody data-reveal={revealing || undefined} className="t-data-table-body">
{loading ? (
Array.from({ length: loadingRows }, (_, i) => (
<TableRow key={`loading-${i}`} data-slot="data-table-loading-row" className="hover:bg-transparent">
{visibleColumns.map((column, j) => (
<TableCell key={column.id} className={cn("px-3", column.columnDef.meta?.className)}>
<Skeleton
className={cn(
"h-4",
column.id === "select" ? "size-4" : ["w-24", "w-32", "w-20", "w-28", "w-16"][(i + j) % 5],
column.columnDef.meta?.className?.includes("text-right") && "ml-auto"
)}
/>
</TableCell>
))}
</TableRow>
))
) : rows.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-slot="data-table-row"
data-state={row.getIsSelected() ? "selected" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={cn("px-3", cell.column.columnDef.meta?.className)}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={visibleColumns.length} className="p-0 whitespace-normal">
{empty ?? (
<Empty data-slot="data-table-empty" className="py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<MagnifyingGlassIcon weight="duotone" />
</EmptyMedia>
<EmptyTitle>{isFiltered ? "No matching rows" : "Nothing here yet"}</EmptyTitle>
<EmptyDescription>
{isFiltered ? "Try a different search or clear the filters." : "Rows show up here once there is data."}
</EmptyDescription>
</EmptyHeader>
{isFiltered && (
<EmptyContent>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
cue("close")
table.resetColumnFilters()
table.setGlobalFilter("")
}}
>
Clear filters
</Button>
</EmptyContent>
)}
</Empty>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</ScrollFadeArea>
</div>
<DataTablePagination table={table} pageSizes={pageSizes} />
</div>
)
}