Data display
Data Table
TanStack Table v9 with sorting, faceted filters, grouping with totals, detail panels, sub-rows, pinning, resizing, selection, virtualisation and server mode.
Channels | 7 days | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
Account lock after two wrong PINsC-4960 | Identity | Resolving | 391 | OMOmar Al Mansoori | 23 Sept | |||||
Push notifications arrive in English onlyC-4939 | Mobile app | Fix drafted | 230 | Unassigned | 5 Oct | |||||
Balance widget shows yesterday’s figureC-4897 | Mobile app | Fix drafted | 224 | LHLayla Haddad | 14 Oct | |||||
Duplicate SMS for every card purchaseC-4946 | Cards | New | 221 | Unassigned | 14 Oct | |||||
Voice OTP unreadable for Arabic speakersC-5009 | Identity | Resolving | 214 | HMHessa Al Marri | 6 Oct | |||||
Cashback missing for grocery categoryC-5016 | Billing | Resolving | 209 | Unassigned | 27 Sept | |||||
SMS banking replies in wrong languageC-5058 | Identity | Resolving | 193 | NKNoura Khalid | 28 Sept | |||||
Plan changes bill customers twiceC-4820 | Billing | New | 179 | SASaeed Al Ameri | 30 Sept | |||||
Salary transfers show as pending for a dayC-4848 | Payments | Fix drafted | 159 | OMOmar Al Mansoori | 20 Oct | |||||
Arabic statements cut off after 40 charactersC-4834 | Statements | Resolving | 156 | HMHessa Al Marri | 21 Oct |
1–10 of 36
pnpm dlx shadcn@latest add @paragon/data-tableFull page
The same table filling a Constellation page inside the app shell. Open it on its own to see it at full width.
Grouped
Group by any column from the toolbar or a header menu. Group rows collapse, count their members and total their numbers.
Channels | 7 days | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| SystemIdentity6 | 1,117 | |||||||||
| SystemMobile app6 | 751 | |||||||||
| SystemBilling7 | 658 | |||||||||
Cashback missing for grocery categoryC-5016 | Resolving | 209 | Unassigned | 27 Sept | ||||||
Plan changes bill customers twiceC-4820 | New | 179 | SASaeed Al Ameri | 30 Sept | ||||||
Overdraft fee on same-day depositC-5065 | Resolving | 94 | OMOmar Al Mansoori | 30 Sept | ||||||
Late fee charged on autopay dateC-4862 | New | 67 | Unassigned | 26 Sept | ||||||
Direct debit retries three timesC-4953 | Fix drafted | 52 | LHLayla Haddad | 23 Sept | ||||||
Annual fee charged after cancellationC-4981 | Investigating | 36 | NKNoura Khalid | 24 Sept | ||||||
Refunds take 14 days instead of 5C-4904 | Investigating | 21 | LHLayla Haddad | 4 Oct | ||||||
| SystemPayments6 | 634 | |||||||||
| SystemCards6 | 624 | |||||||||
| SystemStatements5 | 493 |
Nested rows
Sub-rows from the data itself: systems hold causes, causes hold the actions Telos took.
| Billing | system | Investigating | 267 |
| Plan changes bill customers twice | cause | New | 179 |
| Late fee charged on autopay date | cause | New | 67 |
| Refunds take 14 days instead of 5 | cause | Investigating | 21 |
| Identity | system | Investigating | 319 |
| Cards | system | Investigating | 193 |
| Statements | system | Investigating | 348 |
Virtualised
Only the rows in view are rendered, so the same table holds 500 or 500,000. Sorting, filtering and selection still run over every row.
500 of 500 threads match. Only the rows in view are in the DOM; sorting and filtering run over all of them.
Server-driven
manual: true hands filtering, sorting and paging to the server. Watch the request change as you use the table.
GET/v1/conversations?page=1&size=10&sort=-receivedloading0–0 of 0
import type * as React from "react"
import {
aggregationFn_count,
aggregationFn_extent,
aggregationFn_mean,
aggregationFn_sum,
aggregationFn_uniqueCount,
columnFacetingFeature,
columnFilteringFeature,
columnGroupingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_arrIncludesSome,
filterFn_equalsString,
filterFn_includesString,
filterFn_inNumberRange,
globalFilteringFeature,
metaHelper,
rowAggregationFeature,
rowExpandingFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
sortFn_text,
tableFeatures,
type Cell,
type Column,
type Header,
type Row,
type RowData,
} from "@tanstack/react-table"
import type { ReactTable } from "@tanstack/react-table"
export type DataTableFilterOption = {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}
/** Per-column settings the Paragon data table reads. */
export type DataTableColumnMeta = {
/** Human name for menus, filters and the column picker. Falls back to the id. */
label?: string
/** Right-align numbers and dates with `end`. Logical, so it mirrors in RTL. */
align?: "start" | "end"
/** Declares a toolbar filter for this column. */
filter?: { variant: "select"; options: DataTableFilterOption[] }
/** Extra classes for this column's cells. */
className?: string
}
/**
* Every feature the Paragon table supports, registered once. Pipeline order:
* filter → group → sort → expand → paginate.
*/
export const dataTableFeatures = tableFeatures({
columnMeta: metaHelper<DataTableColumnMeta>(),
columnVisibilityFeature,
columnSizingFeature,
columnResizingFeature,
columnPinningFeature,
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: {
includesString: filterFn_includesString,
equalsString: filterFn_equalsString,
arrIncludesSome: filterFn_arrIncludesSome,
inNumberRange: filterFn_inNumberRange,
},
columnFacetingFeature,
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
facetedMinMaxValues: createFacetedMinMaxValues(),
columnGroupingFeature,
groupedRowModel: createGroupedRowModel(),
rowAggregationFeature,
aggregationFns: {
sum: aggregationFn_sum,
count: aggregationFn_count,
mean: aggregationFn_mean,
extent: aggregationFn_extent,
uniqueCount: aggregationFn_uniqueCount,
},
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
text: sortFn_text,
datetime: sortFn_datetime,
basic: sortFn_basic,
},
rowExpandingFeature,
expandedRowModel: createExpandedRowModel(),
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature,
})
export type DataTableFeatures = typeof dataTableFeatures
export type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
export type DataTableRow<TData extends RowData> = Row<DataTableFeatures, TData>
export type DataTableColumn<TData extends RowData> = Column<DataTableFeatures, TData, unknown>
export type DataTableHeader<TData extends RowData> = Header<DataTableFeatures, TData, unknown>
export type DataTableCell<TData extends RowData> = Cell<DataTableFeatures, TData, unknown>
/** Column helper bound to the Paragon feature set, so meta and fn names are typed. */
export function createDataTableColumnHelper<TData extends RowData>() {
return createColumnHelper<DataTableFeatures, TData>()
}
export function columnLabel(column: { id: string; columnDef: { meta?: DataTableColumnMeta } }) {
return column.columnDef.meta?.label ?? column.id
}"use client"
import { useDirection } from "@base-ui/react/direction-provider"
import { useTable, type RowData, type TableOptions } from "@tanstack/react-table"
import { dataTableFeatures, type DataTableFeatures } from "./features"
type UseDataTableOptions<TData extends RowData> = Omit<TableOptions<DataTableFeatures, TData>, "features"> & {
/**
* The server owns filtering, sorting and paging. Pass the current page as
* `data`, the total as `rowCount`, and send the controlled state with each request.
*/
manual?: boolean
/** Render every row instead of pages, for virtualised tables. */
paginate?: boolean
}
/**
* A TanStack Table v9 instance with every Paragon feature switched on and
* sensible defaults: shift-click multi-sort and range selection, resizing,
* 20 rows a page, and resize direction taken from the nearest DirectionProvider.
* Keep `data` and `columns` stable (module scope, state or useMemo).
*/
export function useDataTable<TData extends RowData>({ manual = false, paginate = true, initialState, ...options }: UseDataTableOptions<TData>) {
const direction = useDirection()
return useTable({
features: dataTableFeatures,
columnResizeMode: "onChange",
columnResizeDirection: direction,
enableMultiSort: true,
globalFilterFn: "includesString",
groupedColumnMode: false,
...(manual ? { manualFiltering: true, manualSorting: true, manualPagination: true } : {}),
...(!paginate ? { manualPagination: true } : {}),
initialState: {
pagination: { pageIndex: 0, pageSize: 20 },
...initialState,
},
...options,
})
}"use client"
import * as React from "react"
import type { ColumnDef, RowData } from "@tanstack/react-table"
import { ChevronDown } from "lucide-react"
import { Checkbox } from "@/registry/paragon/ui/checkbox"
import type { DataTableFeatures, DataTableRow } from "./features"
/** Ids the table treats as utility columns: no header menu, no indentation. */
export const UTILITY_COLUMNS = new Set(["select", "expand"])
/**
* Row checkboxes with a select-all header. Shift-click selects the range from
* the last checked row; on a group row it selects the whole group.
*/
export function createSelectColumn<TData extends RowData>(): ColumnDef<DataTableFeatures, TData, unknown> {
return {
id: "select",
size: 44,
minSize: 44,
maxSize: 44,
enableSorting: false,
enableHiding: false,
enableResizing: false,
enableGrouping: false,
enablePinning: false,
enableGlobalFilter: false,
meta: { label: "Select" },
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
/>
),
cell: ({ row }) => <RowCheckbox row={row} />,
}
}
function RowCheckbox<TData extends RowData>({ row }: { row: DataTableRow<TData> }) {
const all = row.getIsSelected() || (row.getIsGrouped() && row.getIsAllSubRowsSelected())
return (
<Checkbox
aria-label="Select row"
checked={all}
indeterminate={!all && row.getIsSomeSelected()}
disabled={!row.getCanSelect()}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked, details) => {
// TanStack reads `target.checked` and `shiftKey` to support range selection.
row.getToggleSelectedHandler()({
target: { checked },
shiftKey: (details.event as MouseEvent | KeyboardEvent | undefined)?.shiftKey ?? false,
})
}}
/>
)
}
/**
* A disclosure chevron for rows that open: a detail panel, sub-rows, or both.
* The header toggles every row at once.
*/
export function createExpandColumn<TData extends RowData>(): ColumnDef<DataTableFeatures, TData, unknown> {
return {
id: "expand",
size: 44,
minSize: 44,
maxSize: 44,
enableSorting: false,
enableHiding: false,
enableResizing: false,
enableGrouping: false,
enablePinning: false,
enableGlobalFilter: false,
meta: { label: "Expand" },
header: ({ table }) =>
table.getCanSomeRowsExpand() ? (
<ExpandButton
expanded={table.getIsAllRowsExpanded()}
label={table.getIsAllRowsExpanded() ? "Collapse all rows" : "Expand all rows"}
onClick={() => table.toggleAllRowsExpanded()}
/>
) : null,
cell: ({ row }) =>
row.getCanExpand() && !row.getIsGrouped() ? (
<ExpandButton
expanded={row.getIsExpanded()}
label={row.getIsExpanded() ? "Collapse row" : "Expand row"}
onClick={() => row.toggleExpanded()}
/>
) : null,
}
}
export function ExpandButton({
expanded,
label,
onClick,
}: {
expanded: boolean
label: string
onClick: () => void
}) {
return (
<button
type="button"
aria-expanded={expanded}
aria-label={label}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className="group/expander -my-1 grid size-7 place-items-center rounded-[0.5rem] text-muted-foreground transition-colors duration-(--duration-base) outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-solid"
>
<ChevronDown
aria-hidden="true"
className="size-4 -rotate-90 transition-transform duration-(--duration-slow) ease-paragon group-aria-expanded/expander:rotate-0 motion-reduce:transition-none rtl:rotate-90 rtl:group-aria-expanded/expander:rotate-0"
/>
</button>
)
}"use client"
import * as React from "react"
import { FlexRender, type RowData } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import {
ArrowUp,
EyeOff,
Layers,
MoreHorizontal,
PinOff,
SearchX,
ArrowLeftToLine,
ArrowRightToLine,
} from "lucide-react"
import { cn } from "cn"
import { Button } from "@/registry/paragon/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu"
import { Skeleton } from "@/registry/paragon/ui/skeleton"
import { ExpandButton, UTILITY_COLUMNS } from "./columns"
import {
columnLabel,
type DataTableColumn,
type DataTableHeader,
type DataTableInstance,
type DataTableRow,
} from "./features"
export type DataTableDensity = "comfortable" | "compact"
type DataTableProps<TData extends RowData> = {
table: DataTableInstance<TData>
/** Content for the panel under an expanded row. Rows open on click when set. */
renderDetail?: (row: DataTableRow<TData>) => React.ReactNode
/** Render only the rows in view. Pair with `useDataTable({ paginate: false })`. */
virtual?: { height: number | string }
density?: DataTableDensity
/** First load shows skeleton rows; later loads dim the current rows. */
loading?: boolean
empty?: React.ReactNode
onRowClick?: (row: DataTableRow<TData>) => void
className?: string
"aria-label"?: string
}
/**
* The Paragon data table. Renders a TanStack v9 instance from `useDataTable`
* with sorting headers, sticky pinned columns, resizing, grouping with
* totals, detail panels, sub-rows, virtualisation and keyboard navigation
* (arrow keys move, Space selects, Enter expands, Escape clears selection).
*/
export function DataTable<TData extends RowData>({
table,
renderDetail,
virtual,
density = "comfortable",
loading = false,
empty,
onRowClick,
className,
"aria-label": ariaLabel,
}: DataTableProps<TData>) {
const rootRef = React.useRef<HTMLDivElement>(null)
const scrollRef = React.useRef<HTMLDivElement>(null)
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
const firstContentColumnId = visibleColumns.find((column) => !UTILITY_COLUMNS.has(column.id))?.id
const lastStartPinned = table.getStartVisibleLeafColumns().at(-1)?.id
const firstEndPinned = table.getEndVisibleLeafColumns()[0]?.id
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => (density === "compact" ? 41 : 57),
getItemKey: (index) => rows[index]?.id ?? index,
overscan: 8,
enabled: !!virtual,
})
// Edge shadows on pinned columns appear only when content is scrolled beneath them.
React.useEffect(() => {
const scroller = scrollRef.current
const root = rootRef.current
if (!scroller || !root) return
const update = () => {
const offset = Math.abs(scroller.scrollLeft)
const max = scroller.scrollWidth - scroller.clientWidth
root.dataset.scrolledStart = String(offset > 1)
root.dataset.overflowEnd = String(offset < max - 1)
// Detail panels span the visible width, not the full scrollable table.
root.style.setProperty("--detail-width", `${scroller.clientWidth}px`)
}
const observer = new ResizeObserver(update)
observer.observe(scroller)
scroller.addEventListener("scroll", update, { passive: true })
return () => {
observer.disconnect()
scroller.removeEventListener("scroll", update)
}
}, [])
function columnStyle(column: DataTableColumn<TData>): React.CSSProperties {
const pinned = column.getIsPinned()
return {
width: column.getSize(),
...(pinned
? {
position: "sticky",
insetInlineStart: pinned === "start" ? column.getStart("start") : undefined,
insetInlineEnd: pinned === "end" ? column.getAfter("end") : undefined,
}
: null),
}
}
function pinClasses(column: DataTableColumn<TData>) {
const pinned = column.getIsPinned()
return cn(
pinned && "z-[2]",
column.id === lastStartPinned &&
"group-data-[scrolled-start=true]/data-table:shadow-[10px_0_12px_-12px_rgb(19_20_17/0.45)] rtl:group-data-[scrolled-start=true]/data-table:shadow-[-10px_0_12px_-12px_rgb(19_20_17/0.45)] dark:group-data-[scrolled-start=true]/data-table:shadow-[10px_0_12px_-12px_rgb(0_0_0/0.9)] dark:rtl:group-data-[scrolled-start=true]/data-table:shadow-[-10px_0_12px_-12px_rgb(0_0_0/0.9)]",
column.id === firstEndPinned &&
"group-data-[overflow-end=true]/data-table:shadow-[-10px_0_12px_-12px_rgb(19_20_17/0.45)] rtl:group-data-[overflow-end=true]/data-table:shadow-[10px_0_12px_-12px_rgb(19_20_17/0.45)] dark:group-data-[overflow-end=true]/data-table:shadow-[-10px_0_12px_-12px_rgb(0_0_0/0.9)] dark:rtl:group-data-[overflow-end=true]/data-table:shadow-[10px_0_12px_-12px_rgb(0_0_0/0.9)]"
)
}
const cellBase = cn(
"border-b px-3 align-middle transition-colors duration-(--duration-fast) first:ps-4 last:pe-4",
"bg-card group-hover/row:bg-[color-mix(in_oklab,var(--card),var(--foreground)_2.5%)]",
"group-data-[selected=true]/row:bg-[color-mix(in_oklab,var(--card),var(--ring)_7%)] group-data-[expanded=true]/row:bg-[color-mix(in_oklab,var(--card),var(--foreground)_2.5%)]",
"group-data-[density=comfortable]/data-table:py-3 group-data-[density=compact]/data-table:py-1.5"
)
function renderRow(row: DataTableRow<TData>) {
const grouped = row.getIsGrouped()
const showDetail = !!renderDetail && row.getIsExpanded() && !grouped && row.depth === 0
const clickable = !!onRowClick || (!!renderDetail && row.getCanExpand() && !grouped)
return (
<React.Fragment key={row.id}>
<tr
data-row-id={row.id}
data-selected={row.getIsSelected() || undefined}
data-expanded={showDetail || undefined}
data-grouped={grouped || undefined}
tabIndex={-1}
onClick={(event) => {
if ((event.target as HTMLElement).closest("button, a, input, [role=checkbox], [role=menuitem]")) return
if (onRowClick) onRowClick(row)
else if (clickable) row.toggleExpanded()
}}
className={cn(
"group/row outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid",
clickable && "cursor-pointer",
showDetail && "[&>td]:border-b-transparent"
)}
>
{row.getVisibleCells().map((cell) => {
const column = cell.column
const meta = column.columnDef.meta
const isLabelCell = grouped && column.id === firstContentColumnId
const indent = !grouped && row.depth > 0 && column.id === firstContentColumnId
// Group rows show their label, utility controls and real totals. Every column has a
// default aggregatedCell and aggregationFn "auto", so ask the cell whether it aggregated.
const blankOnGroup = grouped && !UTILITY_COLUMNS.has(column.id) && !cell.getIsAggregated()
return (
<td
key={cell.id}
style={{
...columnStyle(column),
...(indent ? { paddingInlineStart: `calc(1rem + ${row.depth * 1.5}rem)` } : null),
}}
className={cn(
cellBase,
pinClasses(column),
"overflow-hidden text-ellipsis whitespace-nowrap",
UTILITY_COLUMNS.has(column.id) && "px-2 first:ps-3 last:pe-3",
meta?.align === "end" && "text-end tabular-nums",
grouped && "bg-secondary/70 font-medium group-hover/row:bg-secondary",
meta?.className
)}
>
{isLabelCell ? <GroupLabel row={row} /> : blankOnGroup ? null : <FlexRender cell={cell} />}
</td>
)
})}
</tr>
{showDetail ? (
<tr data-detail-for={row.id}>
<td colSpan={visibleColumns.length} className="border-b bg-[color-mix(in_oklab,var(--card),var(--foreground)_2.5%)] p-0">
<div className="grid grid-rows-[1fr] opacity-100 transition-[grid-template-rows,opacity] duration-(--duration-slow) ease-paragon motion-reduce:transition-none starting:grid-rows-[0fr] starting:opacity-0">
<div className="min-h-0 overflow-hidden">
<div className="sticky start-0 w-[min(100%,var(--detail-width,100%))] px-4 pt-1 pb-5">{renderDetail?.(row)}</div>
</div>
</div>
</td>
</tr>
) : null}
</React.Fragment>
)
}
function onKeyDown(event: React.KeyboardEvent<HTMLTableElement>) {
const tr = event.target as HTMLElement
if (!(tr instanceof HTMLTableRowElement) || !tr.dataset.rowId) return
const all = [...event.currentTarget.querySelectorAll<HTMLTableRowElement>("tr[data-row-id]")]
const index = all.indexOf(tr)
const row = table.getRow(tr.dataset.rowId, true)
const move = (next?: HTMLTableRowElement) => {
event.preventDefault()
next?.focus()
}
switch (event.key) {
case "ArrowDown":
return move(all[index + 1])
case "ArrowUp":
return move(all[index - 1])
case "Home":
return move(all[0])
case "End":
return move(all.at(-1))
case " ":
event.preventDefault()
if (row.getCanSelect()) row.toggleSelected()
return
case "Enter":
if (row.getCanExpand()) row.toggleExpanded()
return
case "Escape":
table.resetRowSelection()
return
}
}
const virtualItems = virtual ? virtualizer.getVirtualItems() : []
const paddingTop = virtual ? (virtualItems[0]?.start ?? 0) : 0
const paddingBottom = virtual ? virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0) : 0
const showSkeleton = loading && rows.length === 0
return (
<div
ref={rootRef}
data-slot="data-table"
data-density={density}
data-loading={loading || undefined}
className={cn("group/data-table relative isolate overflow-hidden rounded-card bg-card ring-1 ring-border", className)}
>
{loading && rows.length > 0 ? (
<div aria-hidden="true" className="absolute inset-x-0 top-0 z-10 h-0.5 animate-pulse bg-ring" />
) : null}
<div
ref={scrollRef}
className="relative overflow-auto overscroll-x-contain"
style={virtual ? { height: virtual.height } : undefined}
>
<table
aria-label={ariaLabel}
aria-busy={loading || undefined}
aria-rowcount={table.getRowCount?.()}
onKeyDown={onKeyDown}
className="table-fixed border-separate border-spacing-0 text-[0.9375rem]"
style={{ width: table.getTotalSize(), minWidth: "100%" }}
>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const column = header.column
return (
<th
key={header.id}
colSpan={header.colSpan}
scope="col"
aria-sort={
column.getIsSorted() === "asc"
? "ascending"
: column.getIsSorted() === "desc"
? "descending"
: undefined
}
style={{ ...columnStyle(column), position: "sticky", top: 0 }}
className={cn(
"group/th z-[3] h-10 border-b bg-card px-3 text-start align-middle text-[0.6875rem] font-medium tracking-[0.08em] whitespace-nowrap text-muted-foreground uppercase first:ps-4 last:pe-4",
pinClasses(column),
column.getIsPinned() && "z-[4]",
UTILITY_COLUMNS.has(column.id) && "px-2 first:ps-3 last:pe-3",
column.columnDef.meta?.className,
column.columnDef.meta?.align === "end" && "text-end"
)}
>
{header.isPlaceholder ? null : typeof column.columnDef.header === "function" ? (
<FlexRender header={header} />
) : (
<DataTableColumnHeader header={header} />
)}
{column.getCanResize() ? <ResizeHandle header={header} /> : null}
</th>
)
})}
</tr>
))}
</thead>
{showSkeleton ? (
<tbody>
{Array.from({ length: Math.min(table.state.pagination?.pageSize ?? 8, 8) }, (_, index) => (
<tr key={index}>
{visibleColumns.map((column) => (
<td key={column.id} className={cellBase} style={columnStyle(column)}>
<Skeleton className={cn("h-3.5", UTILITY_COLUMNS.has(column.id) ? "size-4" : index % 2 ? "w-3/5" : "w-4/5")} />
</td>
))}
</tr>
))}
</tbody>
) : rows.length === 0 ? (
<tbody>
<tr>
<td colSpan={visibleColumns.length} className="border-b-0 p-0">
{empty ?? <DataTableEmpty table={table} />}
</td>
</tr>
</tbody>
) : virtual ? (
<>
{paddingTop > 0 ? (
<tbody aria-hidden="true">
<tr style={{ height: paddingTop }} />
</tbody>
) : null}
{virtualItems.map((item) => {
const row = rows[item.index]
return (
<tbody key={row.id} data-index={item.index} ref={virtualizer.measureElement}>
{renderRow(row)}
</tbody>
)
})}
{paddingBottom > 0 ? (
<tbody aria-hidden="true">
<tr style={{ height: paddingBottom }} />
</tbody>
) : null}
</>
) : (
<tbody className="transition-opacity duration-(--duration-base) group-data-[loading=true]/data-table:opacity-60">
{rows.map(renderRow)}
</tbody>
)}
</table>
</div>
</div>
)
}
function GroupLabel<TData extends RowData>({ row }: { row: DataTableRow<TData> }) {
const groupColumn = row.groupingColumnId ? row.getAllCellsByColumnId()[row.groupingColumnId] : undefined
const leafCount = countLeaves(row)
return (
<span className="flex items-center gap-2">
<ExpandButton
expanded={row.getIsExpanded()}
label={row.getIsExpanded() ? "Collapse group" : "Expand group"}
onClick={() => row.toggleExpanded()}
/>
<span className="text-[0.6875rem] font-medium tracking-[0.08em] text-muted-foreground uppercase">
{groupColumn ? columnLabel(groupColumn.column) : null}
</span>
<span className="min-w-0 truncate">{groupColumn ? <FlexRender cell={groupColumn} /> : String(row.groupingValue)}</span>
<span className="rounded-full bg-card px-2 text-xs text-muted-foreground tabular-nums ring-1 ring-border">{leafCount}</span>
</span>
)
}
function countLeaves<TData extends RowData>(row: DataTableRow<TData>): number {
return row.subRows.length ? row.subRows.reduce((total, sub) => total + countLeaves(sub), 0) : 1
}
/**
* Default header: click the label to sort (shift-click to add a sort), and use
* the menu for direction, pinning, grouping and hiding. Used automatically when
* a column's `header` is a string.
*/
export function DataTableColumnHeader<TData extends RowData>({ header }: { header: DataTableHeader<TData> }) {
const column = header.column
const table = header.getContext().table
const title = typeof column.columnDef.header === "string" ? column.columnDef.header : columnLabel(column)
const sorted = column.getIsSorted()
const sortCount = table.atoms.sorting.get().length
const align = column.columnDef.meta?.align
const hasMenu = column.getCanSort() || column.getCanHide() || column.getCanPin() || column.getCanGroup()
if (UTILITY_COLUMNS.has(column.id) || !hasMenu) return <span>{title}</span>
return (
<div className={cn("-mx-1 flex items-center", align === "end" && "justify-end")}>
{column.getCanSort() ? (
<button
type="button"
onClick={column.getToggleSortingHandler()}
className="inline-flex h-7 min-w-0 items-center gap-1.5 rounded-[0.375rem] px-1 uppercase transition-colors duration-(--duration-base) outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-solid aria-[pressed=true]:text-foreground"
aria-pressed={!!sorted}
title="Sort. Shift-click to add to the sort."
>
<span className="truncate">{title}</span>
<ArrowUp
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-[rotate,opacity] duration-(--duration-slow) ease-paragon",
sorted === "desc" && "rotate-180",
!sorted && "opacity-0 group-hover/th:opacity-40"
)}
/>
{sorted && sortCount > 1 ? (
<span className="text-[0.625rem] tabular-nums">{column.getSortIndex() + 1}</span>
) : null}
</button>
) : (
<span className="truncate px-1">{title}</span>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label={`${title} options`}
className={cn(
// Floats over the label so hidden it costs no width.
"absolute top-1/2 -translate-y-1/2 bg-card opacity-0 shadow-[0_0_0_4px_var(--card)] group-hover/th:opacity-100 focus-visible:opacity-100 data-popup-open:opacity-100",
align === "end" ? "start-1.5" : "end-2"
)}
/>
}
>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent align={align === "end" ? "end" : "start"} className="w-52 normal-case tracking-normal">
{column.getCanSort() ? (
<>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp />
Sort ascending
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowUp className="rotate-180" />
Sort descending
</DropdownMenuItem>
{sorted ? <DropdownMenuItem onClick={() => column.clearSorting()}>Clear sort</DropdownMenuItem> : null}
</>
) : null}
{column.getCanPin() ? (
<>
<DropdownMenuSeparator />
{column.getIsPinned() !== "start" ? (
<DropdownMenuItem onClick={() => column.pin("start")}>
<ArrowLeftToLine className="rtl:-scale-x-100" />
Pin to start
</DropdownMenuItem>
) : null}
{column.getIsPinned() !== "end" ? (
<DropdownMenuItem onClick={() => column.pin("end")}>
<ArrowRightToLine className="rtl:-scale-x-100" />
Pin to end
</DropdownMenuItem>
) : null}
{column.getIsPinned() ? (
<DropdownMenuItem onClick={() => column.pin(false)}>
<PinOff />
Unpin
</DropdownMenuItem>
) : null}
</>
) : null}
{column.getCanGroup() ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={column.getToggleGroupingHandler()}>
<Layers />
{column.getIsGrouped() ? "Ungroup" : `Group by ${title.toLowerCase()}`}
</DropdownMenuItem>
</>
) : null}
{column.getCanHide() ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff />
Hide column
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function ResizeHandle<TData extends RowData>({ header }: { header: DataTableHeader<TData> }) {
const handler = header.getResizeHandler()
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${columnLabel(header.column)}`}
data-resizing={header.column.getIsResizing() || undefined}
onMouseDown={handler}
onTouchStart={handler}
onDoubleClick={() => header.column.resetSize()}
onClick={(event) => event.stopPropagation()}
className="group/resize absolute inset-y-0 -end-1 z-[5] flex w-2 cursor-col-resize touch-none justify-center select-none"
>
<span className="my-2.5 w-px bg-transparent transition-colors duration-(--duration-fast) group-hover/resize:bg-ring group-data-[resizing=true]/resize:bg-ring group-hover/th:bg-border" />
</div>
)
}
function DataTableEmpty<TData extends RowData>({ table }: { table: DataTableInstance<TData> }) {
const filtered = (table.state.columnFilters?.length ?? 0) > 0 || !!table.state.globalFilter
return (
<div className="flex flex-col items-center gap-3 px-6 py-14 text-center">
<span className="grid size-10 place-items-center rounded-[0.75rem] bg-secondary text-muted-foreground">
<SearchX className="size-5" />
</span>
<div className="flex flex-col gap-1">
<p className="font-medium">{filtered ? "Nothing matches these filters" : "Nothing here yet"}</p>
<p className="text-sm text-muted-foreground">
{filtered ? "Try fewer filters or a different search." : "Rows will appear here as they arrive."}
</p>
</div>
{filtered ? (
<Button
variant="outline"
size="sm"
onClick={() => {
table.resetColumnFilters()
table.setGlobalFilter("")
}}
>
Clear filters
</Button>
) : null}
</div>
)
}"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
import { Check, Layers, PlusCircle, Rows3, Rows4, Search, Settings2, X } from "lucide-react"
import { cn } from "cn"
import { Badge } from "@/registry/paragon/ui/badge"
import { Button } from "@/registry/paragon/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/registry/paragon/ui/command"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/registry/paragon/ui/input-group"
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/paragon/ui/popover"
import { Separator } from "@/registry/paragon/ui/separator"
import { ToggleGroup, ToggleGroupItem } from "@/registry/paragon/ui/toggle-group"
import type { DataTableDensity } from "./data-table"
import { columnLabel, type DataTableColumn, type DataTableInstance } from "./features"
/**
* Search, filters and view controls. Filters come from columns that declare
* `meta.filter`, so adding a column with options adds its filter here.
*/
export function DataTableToolbar<TData extends RowData>({
table,
searchPlaceholder = "Search…",
density,
onDensityChange,
children,
className,
}: {
table: DataTableInstance<TData>
searchPlaceholder?: string
density?: DataTableDensity
onDensityChange?: (density: DataTableDensity) => void
/** Extra actions, placed before the view controls. */
children?: React.ReactNode
className?: string
}) {
const filterColumns = table
.getAllLeafColumns()
.filter((column) => column.columnDef.meta?.filter && column.getCanFilter())
const filtered = (table.state.columnFilters?.length ?? 0) > 0 || !!table.state.globalFilter
const groupable = table.getAllLeafColumns().some((column) => column.getCanGroup())
return (
<div data-slot="data-table-toolbar" className={cn("flex flex-wrap items-center gap-2", className)}>
<SearchInput
value={(table.state.globalFilter as string | undefined) ?? ""}
onChange={(value) => table.setGlobalFilter(value)}
placeholder={searchPlaceholder}
/>
{filterColumns.map((column) => (
<DataTableFacetedFilter key={column.id} column={column} />
))}
{filtered ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
table.resetColumnFilters()
table.setGlobalFilter("")
}}
className="animate-in duration-(--duration-base) fade-in"
>
Reset
<X data-icon="inline-end" />
</Button>
) : null}
<div className="ms-auto flex items-center gap-2">
{children}
{groupable ? <GroupByMenu table={table} /> : null}
{density && onDensityChange ? (
<ToggleGroup
size="sm"
value={[density]}
onValueChange={(value) => value[0] && onDensityChange(value[0] as DataTableDensity)}
aria-label="Row density"
>
<ToggleGroupItem value="comfortable" aria-label="Comfortable rows" className="px-2">
<Rows3 />
</ToggleGroupItem>
<ToggleGroupItem value="compact" aria-label="Compact rows" className="px-2">
<Rows4 />
</ToggleGroupItem>
</ToggleGroup>
) : null}
<DataTableViewOptions table={table} />
</div>
</div>
)
}
function SearchInput({
value,
onChange,
placeholder,
}: {
value: string
onChange: (value: string) => void
placeholder: string
}) {
const [draft, setDraft] = React.useState(value)
const [previous, setPrevious] = React.useState(value)
// Adopt outside changes (such as Reset) without an effect.
if (value !== previous) {
setPrevious(value)
setDraft(value)
}
React.useEffect(() => {
if (draft === value) return
const timeout = setTimeout(() => onChange(draft), 200)
return () => clearTimeout(timeout)
}, [draft, value, onChange])
return (
<InputGroup className="h-9 w-full rounded-[calc(var(--radius-button)-2px)] sm:w-64">
<InputGroupInput
size="sm"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
aria-label="Search rows"
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
{draft ? (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setDraft("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
)
}
/** Multi-select filter with live counts from the rows the other filters leave. */
export function DataTableFacetedFilter<TData extends RowData>({ column }: { column: DataTableColumn<TData> }) {
const options = column.columnDef.meta?.filter?.options ?? []
// Page-local counts would mislead when the server filters, so hide them.
const facets = column.getFacetedUniqueValues()
const showCounts = !column.table.options.manualFiltering
const selected = new Set((column.getFilterValue() as string[] | undefined) ?? [])
const title = columnLabel(column)
function toggle(value: string) {
const next = new Set(selected)
if (next.has(value)) next.delete(value)
else next.add(value)
column.setFilterValue(next.size ? [...next] : undefined)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className={cn("border-dashed", selected.size && "border-solid border-foreground/25")}
/>
}
>
<PlusCircle data-icon="inline-start" className={cn("transition-transform duration-(--duration-slow) ease-paragon", selected.size && "rotate-45")} />
{title}
{selected.size ? (
<>
<Separator orientation="vertical" className="mx-0.5 data-vertical:h-4 data-vertical:self-center" />
{selected.size > 2 ? (
<Badge variant="secondary" className="rounded-[0.375rem] px-1.5">
{selected.size} selected
</Badge>
) : (
options
.filter((option) => selected.has(option.value))
.map((option) => (
<Badge key={option.value} variant="secondary" className="rounded-[0.375rem] px-1.5">
{option.label}
</Badge>
))
)}
</>
) : null}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 gap-0 p-0">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No options.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.has(option.value)
return (
<CommandItem key={option.value} value={option.label} onSelect={() => toggle(option.value)}>
<span
data-checked={isSelected || undefined}
className="grid size-4 shrink-0 place-items-center rounded-[0.3125rem] border border-input bg-card text-primary-foreground transition-colors duration-(--duration-base) data-checked:border-primary data-checked:bg-primary"
>
<Check className={cn("size-3 text-primary-foreground!", !isSelected && "invisible")} strokeWidth={3} />
</span>
{option.icon ? <option.icon /> : null}
<span className="truncate">{option.label}</span>
{showCounts ? (
<span className="ms-auto font-mono text-xs text-muted-foreground tabular-nums">
{facets.get(option.value) ?? 0}
</span>
) : null}
</CommandItem>
)
})}
</CommandGroup>
{selected.size ? (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={() => column.setFilterValue(undefined)} className="justify-center text-muted-foreground">
Clear {title.toLowerCase()}
</CommandItem>
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
function GroupByMenu<TData extends RowData>({ table }: { table: DataTableInstance<TData> }) {
const columns = table.getAllLeafColumns().filter((column) => column.getCanGroup())
const current = table.state.grouping?.[0] ?? "none"
const currentColumn = columns.find((column) => column.id === current)
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
<Layers data-icon="inline-start" />
{currentColumn ? (
<>
<span className="text-muted-foreground">Group</span>
{columnLabel(currentColumn)}
</>
) : (
"Group"
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Group rows by</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={current}
onValueChange={(value) => {
table.setGrouping(value === "none" ? [] : [value as string])
table.setExpanded(true)
}}
>
<DropdownMenuRadioItem value="none">Nothing</DropdownMenuRadioItem>
{columns.map((column) => (
<DropdownMenuRadioItem key={column.id} value={column.id}>
{columnLabel(column)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
/** Show and hide columns, and put pinning and widths back. */
export function DataTableViewOptions<TData extends RowData>({ table }: { table: DataTableInstance<TData> }) {
const columns = table.getAllLeafColumns().filter((column) => column.getCanHide())
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
<Settings2 data-icon="inline-start" />
View
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Columns</DropdownMenuLabel>
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(checked) => column.toggleVisibility(checked)}
closeOnClick={false}
>
{columnLabel(column)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
table.resetColumnVisibility()
table.resetColumnPinning()
table.resetColumnSizing()
}}
>
Reset layout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}"use client"
import type { RowData } from "@tanstack/react-table"
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/registry/paragon/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/registry/paragon/ui/select"
import type { DataTableInstance } from "./features"
/** Range, rows per page and page controls. Works for client and server tables alike. */
export function DataTablePagination<TData extends RowData>({
table,
pageSizes = [10, 20, 50, 100],
locale = "en-GB",
className,
}: {
table: DataTableInstance<TData>
pageSizes?: number[]
locale?: string
className?: string
}) {
const { pageIndex, pageSize } = table.state.pagination ?? { pageIndex: 0, pageSize: 20 }
const total = table.getRowCount()
const pageCount = Math.max(table.getPageCount(), 1)
const from = total === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, total)
const format = new Intl.NumberFormat(locale).format
const selected = Object.keys(table.state.rowSelection ?? {}).length
return (
<div
data-slot="data-table-pagination"
className={cn("flex flex-wrap items-center justify-between gap-x-6 gap-y-3 text-sm text-muted-foreground", className)}
>
<p className="tabular-nums" aria-live="polite">
{selected ? (
<>
<span className="font-medium text-foreground">{format(selected)}</span> selected ·{" "}
</>
) : null}
{format(from)}–{format(to)} of {format(total)}
</p>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="hidden sm:inline">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
items={Object.fromEntries(pageSizes.map((size) => [String(size), String(size)]))}
>
<SelectTrigger size="sm" className="w-[4.5rem]" aria-label="Rows per page">
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[4.5rem]">
{pageSizes.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="tabular-nums">
Page <span className="text-foreground">{format(pageIndex + 1)}</span> of {format(pageCount)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="First page"
onClick={() => table.firstPage()}
disabled={!table.getCanPreviousPage()}
className="hidden md:inline-flex"
>
<ChevronsLeft className="rtl:-scale-x-100" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Previous page" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
<ChevronLeft className="rtl:-scale-x-100" />
</Button>
<Button variant="outline" size="icon-sm" aria-label="Next page" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
<ChevronRight className="rtl:-scale-x-100" />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Last page"
onClick={() => table.lastPage()}
disabled={!table.getCanNextPage()}
className="hidden md:inline-flex"
>
<ChevronsRight className="rtl:-scale-x-100" />
</Button>
</div>
</div>
</div>
)
}"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
import { X } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/registry/paragon/ui/button"
import { Kbd } from "@/registry/paragon/ui/kbd"
import type { DataTableInstance, DataTableRow } from "./features"
/**
* A night bar that springs up from the bottom of the view while rows are
* selected. It takes no layout space and stays in view as the page scrolls.
* Children receive the selected rows that are loaded.
*/
export function DataTableSelectionBar<TData extends RowData>({
table,
children,
locale = "en-GB",
className,
}: {
table: DataTableInstance<TData>
children?: (rows: DataTableRow<TData>[]) => React.ReactNode
locale?: string
className?: string
}) {
const count = Object.keys(table.state.rowSelection ?? {}).length
const visible = count > 0
const rows = visible ? table.getSelectedRowModel().rows : []
return (
<div data-slot="data-table-selection-bar" className="pointer-events-none sticky bottom-4 z-30 h-0">
<div
role="toolbar"
aria-label="Selected rows"
aria-hidden={!visible}
inert={!visible}
data-visible={visible}
className={cn(
"dark absolute inset-x-0 bottom-0 mx-auto flex w-fit max-w-[calc(100%-2rem)] items-center gap-1 rounded-[1rem] bg-night p-1.5 ps-4 text-foreground shadow-[0_24px_48px_-16px_rgb(0_0_0/0.45)] ring-1 ring-white/8",
"transition-[opacity,translate,scale] duration-(--duration-slow) ease-spring",
"data-[visible=false]:translate-y-3 data-[visible=false]:scale-95 data-[visible=false]:opacity-0 data-[visible=true]:pointer-events-auto",
"motion-reduce:transition-opacity",
className
)}
>
<span className="pe-2 text-sm whitespace-nowrap tabular-nums" aria-live="polite">
<span className="font-medium">{new Intl.NumberFormat(locale).format(count)}</span>
<span className="text-muted-foreground"> selected</span>
</span>
<span className="mx-1 h-5 w-px bg-border" />
<div className="flex items-center gap-0.5">{children?.(rows)}</div>
<span className="mx-1 h-5 w-px bg-border" />
<Button variant="ghost" size="sm" onClick={() => table.resetRowSelection()} className="text-muted-foreground">
<X data-icon="inline-start" />
Clear
<Kbd className="ms-1 bg-white/8 ring-0 shadow-none">esc</Kbd>
</Button>
</div>
</div>
)
}