{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:ui",
  "title": "Data Table",
  "description": "Dependency-free data grid built on the Table primitives — typed columns, client-side sort, row selection, and pagination out of the box, with a server-side/TanStack escape hatch.",
  "dependencies": [
    "@saasflare/ui"
  ],
  "files": [
    {
      "path": "components/ui/data-table.tsx",
      "type": "registry:ui",
      "content": "// @draft\n\"use client\"\n\n/**\n * @fileoverview DataTable — typed, dependency-free data grid built on the\n * Saasflare Table primitives. Consumers pass `data: T[]` + typed `columns` and\n * get client-side multi-column sort, row selection (controlled/uncontrolled),\n * client-side pagination (via {@link usePagination}), sticky header, density,\n * and empty/loading states out of the box — zero TanStack, zero wiring.\n * @module packages/ui/components/ui/data-table\n * @layer composed\n *\n * Server-side / TanStack: pass `manualSort` + `manualPagination` and drive\n * state from `onSortChange` / `onPageChange`. The component stays\n * dependency-free; `@tanstack/react-table` is NEVER a dependency of `@saasflare/ui`.\n *\n * @component\n * @example\n * import { DataTable } from \"@saasflare/ui\";\n * <DataTable\n *   data={users}\n *   columns={[\n *     { accessorKey: \"name\", header: \"Name\", sortable: true },\n *     { accessorKey: \"email\", header: \"Email\" },\n *   ]}\n *   getRowId=\"id\"\n *   selectionMode=\"multiple\"\n *   pageSize={10}\n * />\n */\n\nimport * as React from \"react\"\nimport { CaretUpIcon, CaretDownIcon } from \"@saasflare/ui\"\n\nimport { cn } from \"@saasflare/ui\"\nimport { useSaasflareProps, type SaasflareComponentProps } from \"@saasflare/ui\"\nimport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n} from \"@saasflare/ui\"\nimport { Checkbox } from \"@saasflare/ui\"\nimport { Skeleton } from \"@saasflare/ui\"\nimport {\n  Pagination,\n  PaginationContent,\n  PaginationItem,\n  PaginationLink,\n  PaginationPrevious,\n  PaginationNext,\n  PaginationEllipsis,\n} from \"@saasflare/ui\"\nimport {\n  useDataTable,\n  type DataTableColumn,\n  type DataTableSort,\n  type DataTableAlign,\n  type DataTableDensity,\n  type DataTableSelectionMode,\n} from \"@saasflare/ui\"\n\nexport type { DataTableColumn, DataTableSort, DataTableAlign, DataTableDensity }\n\n/**\n * Props for {@link DataTable}, generic over the row type `T`.\n *\n * Sorting, selection, and pagination each work controlled or uncontrolled;\n * pass `manualSort` / `manualPagination` to hand ordering and slicing over to\n * a server or TanStack layer while keeping the rendering here.\n */\ninterface DataTableProps<T>\n  extends Omit<React.ComponentProps<\"table\">, \"children\" | keyof SaasflareComponentProps>,\n    SaasflareComponentProps {\n  /** Row data. */\n  data: T[]\n  /** Typed column definitions. */\n  columns: DataTableColumn<T>[]\n  /** Stable row key. String key of `T` or a function. Falls back to row index (logs a dev warning, since index keys break selection across sort/paginate). */\n  getRowId?: keyof T | ((row: T, index: number) => string)\n\n  // ── Sorting (multi-column, asc → desc → none cycle) ───────────────────────\n  /** Uncontrolled initial sort. */\n  defaultSort?: DataTableSort[]\n  /** Controlled sort state. Presence switches sorting to controlled. */\n  sort?: DataTableSort[]\n  /** Fires on header activation with the next sort array. */\n  onSortChange?: (sort: DataTableSort[]) => void\n  /** Allow stacking multiple sort columns (shift-click adds a column). Default `false` (single-column). */\n  multiSort?: boolean\n  /** Skip internal sorting (server/TanStack drives order). Header still emits `onSortChange`. Default `false`. */\n  manualSort?: boolean\n\n  // ── Selection (header + per-row checkbox) ─────────────────────────────────\n  /** `\"none\"` (default) | `\"single\"` | `\"multiple\"`. Renders a leading checkbox column when not `\"none\"`. */\n  selectionMode?: DataTableSelectionMode\n  /** Uncontrolled initial selected row-id set. */\n  defaultSelectedKeys?: string[]\n  /** Controlled selected row-id set. */\n  selectedKeys?: string[]\n  /** Fires with the next selected row-id array. */\n  onSelectedKeysChange?: (keys: string[]) => void\n  /** Predicate to disable selection for specific rows (their checkbox is disabled + excluded from select-all). */\n  isRowSelectable?: (row: T) => boolean\n\n  // ── Pagination (reuses usePagination) ─────────────────────────────────────\n  /** Rows per page. Omit / `0` disables pagination (renders all rows, no footer). */\n  pageSize?: number\n  /** Uncontrolled initial page (1-indexed). Default `1`. */\n  defaultPage?: number\n  /** Controlled page (1-indexed). */\n  page?: number\n  /** Fires on page change. */\n  onPageChange?: (page: number) => void\n  /** Skip internal pagination/slicing (server drives the window). `data` is treated as the current page; pass `rowCount` for the footer. Default `false`. */\n  manualPagination?: boolean\n  /** Total row count when `manualPagination` — drives the page-count math in the footer. */\n  rowCount?: number\n\n  // ── States / chrome ───────────────────────────────────────────────────────\n  /** Loading flag — renders `loadingRows` skeleton rows; disables sort/select while true. */\n  loading?: boolean\n  /** Skeleton row count while `loading`. Default = `pageSize || 5`. */\n  loadingRows?: number\n  /** Rendered in a full-width body row when `data` is empty and not loading. Defaults to a built-in message. */\n  emptyState?: React.ReactNode\n  /** Pin the header on vertical scroll within a `maxHeight` container. Default `false`. */\n  stickyHeader?: boolean\n  /** Max body height (enables internal scroll; required for `stickyHeader` to be useful). e.g. `\"24rem\"`. */\n  maxHeight?: number | string\n  /** Density preset. Default `\"comfortable\"`. */\n  density?: DataTableDensity\n  /** Optional row click handler. Receives row + index. Adds `cursor-pointer` + keyboard activation when set. */\n  onRowClick?: (row: T, index: number) => void\n  /** Accessible caption (visually hidden by default) describing the table. */\n  caption?: React.ReactNode\n  /** Hide the built-in pagination footer even when paginated (consumer renders own Pagination). Default `false`. */\n  hidePagination?: boolean\n}\n\n/** Maps column alignment to text + flex justification classes. */\nconst ALIGN_TEXT: Record<DataTableAlign, string> = {\n  start: \"text-start\",\n  center: \"text-center\",\n  end: \"text-end\",\n}\nconst ALIGN_JUSTIFY: Record<DataTableAlign, string> = {\n  start: \"justify-start\",\n  center: \"justify-center\",\n  end: \"justify-end\",\n}\n\n/** Density → cell/header sizing classes. `comfortable` uses the Table primitive defaults. */\nconst DENSITY_HEAD: Record<DataTableDensity, string> = {\n  comfortable: \"\",\n  compact: \"h-8 py-1\",\n}\nconst DENSITY_CELL: Record<DataTableDensity, string> = {\n  comfortable: \"\",\n  compact: \"py-1\",\n}\n\n/** Resolves a stable column id for keys + sort lookups. */\nfunction columnId<T>(column: DataTableColumn<T>, index: number): string {\n  if (column.id !== undefined) return column.id\n  if (column.accessorKey !== undefined) return String(column.accessorKey)\n  return `col-${index}`\n}\n\n/** Sort indicator built from Phosphor carets (asc / desc / both-at-half-opacity). */\nfunction SortIndicator({\n  direction,\n  weight,\n}: {\n  direction: \"asc\" | \"desc\" | \"none\"\n  weight: SaasflareComponentProps[\"iconWeight\"]\n}) {\n  if (direction === \"asc\") {\n    return <CaretUpIcon weight={weight} className=\"size-3.5 text-foreground\" aria-hidden />\n  }\n  if (direction === \"desc\") {\n    return <CaretDownIcon weight={weight} className=\"size-3.5 text-foreground\" aria-hidden />\n  }\n  return (\n    <span className=\"relative inline-flex size-3.5 flex-col items-center justify-center text-muted-foreground/50\" aria-hidden>\n      <CaretUpIcon weight={weight} className=\"size-2.5 -mb-1\" />\n      <CaretDownIcon weight={weight} className=\"size-2.5 -mt-1\" />\n    </span>\n  )\n}\n\n/**\n * Dependency-free, typed, sortable + selectable + paginated data table built on\n * the Saasflare Table primitives. Resolves the four design axes\n * (surface/radius/animated/iconWeight) and emits data-surface/data-radius/data-animated.\n *\n * Selection note: the header \"select all\" checkbox operates on the\n * **current page** only (it toggles the visible page's selectable rows;\n * off-page selections are preserved).\n *\n * @component\n * @layer composed\n *\n * @example\n * <DataTable\n *   data={users}\n *   columns={[\n *     { accessorKey: \"name\", header: \"Name\", sortable: true },\n *     { accessorKey: \"email\", header: \"Email\" },\n *     { accessorKey: \"plan\", header: \"Plan\", sortable: true, align: \"end\" },\n *   ]}\n *   getRowId=\"id\"\n *   selectionMode=\"multiple\"\n *   pageSize={10}\n *   stickyHeader\n *   maxHeight=\"24rem\"\n * />\n */\nfunction DataTable<T>(props: DataTableProps<T>): React.JSX.Element {\n  const {\n    data,\n    columns,\n    getRowId,\n    defaultSort,\n    sort,\n    onSortChange,\n    multiSort = false,\n    manualSort = false,\n    selectionMode = \"none\",\n    defaultSelectedKeys,\n    selectedKeys,\n    onSelectedKeysChange,\n    isRowSelectable,\n    pageSize = 0,\n    defaultPage = 1,\n    page,\n    onPageChange,\n    manualPagination = false,\n    rowCount,\n    loading = false,\n    loadingRows,\n    emptyState,\n    stickyHeader = false,\n    maxHeight,\n    density = \"comfortable\",\n    onRowClick,\n    caption,\n    hidePagination = false,\n    className,\n    surface,\n    radius,\n    animated,\n    iconWeight,\n    ...tableProps\n  } = props\n\n  const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n\n  const table = useDataTable<T>({\n    data,\n    columns,\n    getRowId,\n    defaultSort,\n    sort,\n    onSortChange,\n    multiSort,\n    manualSort,\n    selectionMode,\n    defaultSelectedKeys,\n    selectedKeys,\n    onSelectedKeysChange,\n    isRowSelectable,\n    pageSize,\n    defaultPage,\n    page,\n    onPageChange,\n    manualPagination,\n    rowCount,\n  })\n\n  const hasSelection = selectionMode !== \"none\"\n  const totalCols = columns.length + (hasSelection ? 1 : 0)\n  const paginationEnabled = pageSize > 0\n  const showFooter = paginationEnabled && !hidePagination\n  const skeletonRows = loadingRows ?? (pageSize > 0 ? pageSize : 5)\n  const interactionsDisabled = loading\n\n  const containerStyle: React.CSSProperties | undefined =\n    maxHeight !== undefined ? { maxHeight, overflowY: \"auto\" } : undefined\n\n  const headerStickyClass = stickyHeader ? \"sticky top-0 z-10 bg-card\" : \"\"\n\n  /** Renders the per-row leading selection checkbox cell. */\n  const renderSelectionCell = (row: T, index: number) => {\n    if (!hasSelection) return null\n    const id = table.rowId(row, index)\n    const disabled = interactionsDisabled || (isRowSelectable ? !isRowSelectable(row) : false)\n    return (\n      <TableCell className={cn(\"w-px\", DENSITY_CELL[density])}>\n        <Checkbox\n          checked={table.isSelected(id)}\n          disabled={disabled}\n          onCheckedChange={() => table.toggleRow(id)}\n          onClick={(event) => event.stopPropagation()}\n          aria-label=\"Select row\"\n          surface={sf.surface}\n          radius={sf.radius}\n          animated={sf.animated}\n          iconWeight={sf.iconWeight}\n        />\n      </TableCell>\n    )\n  }\n\n  /** Ignore row-click activation that originated on an interactive descendant. */\n  const isInteractiveTarget = (target: EventTarget | null): boolean => {\n    if (!(target instanceof Element)) return false\n    return Boolean(target.closest('button, a, input, [role=\"checkbox\"], [role=\"button\"], select, textarea'))\n  }\n\n  const handleRowActivate = (row: T, index: number, target: EventTarget | null) => {\n    if (!onRowClick || interactionsDisabled) return\n    if (isInteractiveTarget(target)) return\n    onRowClick(row, index)\n  }\n\n  const selectAllChecked: boolean | \"indeterminate\" =\n    table.selectAllState === \"all\" ? true : table.selectAllState === \"some\" ? \"indeterminate\" : false\n\n  return (\n    <div className=\"w-full space-y-4\">\n      <div\n        data-slot=\"data-table\"\n        data-surface={sf.surface}\n        data-radius={sf.radius}\n        data-animated={String(sf.animated)}\n        className={cn(maxHeight !== undefined && \"relative w-full overflow-auto rounded-xl border\")}\n        style={containerStyle}\n      >\n        <Table\n          {...tableProps}\n          surface={sf.surface}\n          radius={sf.radius}\n          animated={sf.animated}\n          className={className}\n        >\n          {caption ? <TableCaption className=\"sr-only\">{caption}</TableCaption> : null}\n\n          <TableHeader className={headerStickyClass}>\n            <TableRow>\n              {hasSelection ? (\n                <TableHead className={cn(\"w-px\", DENSITY_HEAD[density])}>\n                  {selectionMode === \"multiple\" ? (\n                    <Checkbox\n                      checked={selectAllChecked}\n                      disabled={interactionsDisabled || table.rows.length === 0}\n                      onCheckedChange={() => table.toggleSelectAll()}\n                      aria-label=\"Select all rows on this page\"\n                      surface={sf.surface}\n                      radius={sf.radius}\n                      animated={sf.animated}\n                      iconWeight={sf.iconWeight}\n                    />\n                  ) : null}\n                </TableHead>\n              ) : null}\n\n              {columns.map((column, index) => {\n                const id = columnId(column, index)\n                const align = column.align ?? \"start\"\n                const direction = table.sortDirectionFor(id)\n                const sortable = Boolean(column.sortable) && !interactionsDisabled\n                const ariaSort: React.AriaAttributes[\"aria-sort\"] =\n                  !column.sortable ? undefined : direction === \"asc\" ? \"ascending\" : direction === \"desc\" ? \"descending\" : \"none\"\n\n                return (\n                  <TableHead\n                    key={id}\n                    aria-sort={ariaSort}\n                    aria-label={column.ariaLabel}\n                    className={cn(ALIGN_TEXT[align], DENSITY_HEAD[density], column.headerClassName)}\n                  >\n                    {sortable ? (\n                      <button\n                        type=\"button\"\n                        onClick={(event) => table.toggleSort(id, event.shiftKey || event.metaKey || event.ctrlKey)}\n                        onKeyDown={(event) => {\n                          if (event.key === \"Enter\" || event.key === \" \") {\n                            event.preventDefault()\n                            table.toggleSort(id, event.shiftKey)\n                          }\n                        }}\n                        className={cn(\n                          \"inline-flex w-full items-center gap-1.5 rounded-sm font-medium outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n                          ALIGN_JUSTIFY[align],\n                        )}\n                      >\n                        <span>{column.header}</span>\n                        <SortIndicator direction={direction} weight={sf.iconWeight} />\n                      </button>\n                    ) : (\n                      column.header\n                    )}\n                  </TableHead>\n                )\n              })}\n            </TableRow>\n          </TableHeader>\n\n          <TableBody>\n            {loading ? (\n              Array.from({ length: skeletonRows }).map((_, rowIndex) => (\n                <TableRow key={`skeleton-${rowIndex}`}>\n                  {hasSelection ? (\n                    <TableCell className={cn(\"w-px\", DENSITY_CELL[density])}>\n                      <Skeleton className=\"size-4\" />\n                    </TableCell>\n                  ) : null}\n                  {columns.map((column, colIndex) => (\n                    <TableCell key={columnId(column, colIndex)} className={cn(DENSITY_CELL[density], column.className)}>\n                      <Skeleton className=\"h-4 w-full max-w-[12rem]\" />\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : table.rows.length === 0 ? (\n              <TableRow>\n                <TableCell colSpan={totalCols} className=\"h-24 text-center text-muted-foreground\">\n                  {emptyState ?? \"No results.\"}\n                </TableCell>\n              </TableRow>\n            ) : (\n              table.rows.map((row, index) => {\n                const id = table.rowId(row, index)\n                const selected = hasSelection && table.isSelected(id)\n                const clickable = Boolean(onRowClick) && !interactionsDisabled\n                return (\n                  <TableRow\n                    key={id}\n                    data-state={selected ? \"selected\" : undefined}\n                    // No role=\"button\" on <tr>: it would strip the row's table\n                    // semantics for AT. Focusable + Enter/Space is enough.\n                    data-clickable={clickable ? \"true\" : undefined}\n                    tabIndex={clickable ? 0 : undefined}\n                    onClick={clickable ? (event) => handleRowActivate(row, index, event.target) : undefined}\n                    onKeyDown={\n                      clickable\n                        ? (event) => {\n                            if (event.key === \"Enter\" || event.key === \" \") {\n                              if (isInteractiveTarget(event.target)) return\n                              event.preventDefault()\n                              onRowClick?.(row, index)\n                            }\n                          }\n                        : undefined\n                    }\n                    className={clickable ? \"cursor-pointer\" : undefined}\n                  >\n                    {renderSelectionCell(row, index)}\n                    {columns.map((column, colIndex) => {\n                      const cid = columnId(column, colIndex)\n                      const align = column.align ?? \"start\"\n                      const content = column.cell\n                        ? column.cell(row, index)\n                        : column.accessorKey !== undefined\n                          ? formatCell(row[column.accessorKey])\n                          : null\n                      return (\n                        <TableCell\n                          key={cid}\n                          className={cn(ALIGN_TEXT[align], DENSITY_CELL[density], column.className)}\n                        >\n                          {content}\n                        </TableCell>\n                      )\n                    })}\n                  </TableRow>\n                )\n              })\n            )}\n          </TableBody>\n\n          {showFooter ? (\n            <TableFooter>\n              <TableRow className=\"hover:bg-transparent\">\n                <TableCell colSpan={totalCols} className=\"bg-transparent\">\n                  <div className=\"flex flex-col items-center justify-between gap-3 sm:flex-row\">\n                    {hasSelection ? (\n                      <span className=\"text-sm font-normal text-muted-foreground\">\n                        {table.selectedKeys.size} of {table.selectableCount} selected\n                      </span>\n                    ) : (\n                      <span />\n                    )}\n                    <Pagination\n                      className=\"mx-0 w-auto justify-end\"\n                      surface={sf.surface}\n                      radius={sf.radius}\n                      animated={sf.animated}\n                      iconWeight={sf.iconWeight}\n                    >\n                      <PaginationContent>\n                        <PaginationItem>\n                          <PaginationPrevious\n                            href=\"#\"\n                            aria-disabled={table.activePage <= 1 || undefined}\n                            tabIndex={table.activePage <= 1 ? -1 : undefined}\n                            className={cn(table.activePage <= 1 && \"pointer-events-none opacity-50\")}\n                            onClick={(event) => {\n                              event.preventDefault()\n                              if (table.activePage > 1) table.setPage(table.activePage - 1)\n                            }}\n                          />\n                        </PaginationItem>\n                        {table.range.map((item, i) =>\n                          item === \"dots\" ? (\n                            <PaginationItem key={`dots-${i}`}>\n                              <PaginationEllipsis />\n                            </PaginationItem>\n                          ) : (\n                            <PaginationItem key={item}>\n                              <PaginationLink\n                                href=\"#\"\n                                isActive={item === table.activePage}\n                                onClick={(event) => {\n                                  event.preventDefault()\n                                  table.setPage(item)\n                                }}\n                              >\n                                {item}\n                              </PaginationLink>\n                            </PaginationItem>\n                          ),\n                        )}\n                        <PaginationItem>\n                          <PaginationNext\n                            href=\"#\"\n                            aria-disabled={table.activePage >= table.pageCount || undefined}\n                            tabIndex={table.activePage >= table.pageCount ? -1 : undefined}\n                            className={cn(table.activePage >= table.pageCount && \"pointer-events-none opacity-50\")}\n                            onClick={(event) => {\n                              event.preventDefault()\n                              if (table.activePage < table.pageCount) table.setPage(table.activePage + 1)\n                            }}\n                          />\n                        </PaginationItem>\n                      </PaginationContent>\n                    </Pagination>\n                  </div>\n                </TableCell>\n              </TableRow>\n            </TableFooter>\n          ) : null}\n        </Table>\n      </div>\n    </div>\n  )\n}\n\n/** Coerces a cell value to a renderable node — leaves React nodes intact, stringifies primitives. */\nfunction formatCell(value: unknown): React.ReactNode {\n  if (value == null) return null\n  if (React.isValidElement(value)) return value\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\") {\n    return String(value)\n  }\n  return String(value)\n}\n\nexport { DataTable, type DataTableProps }\n",
      "target": "components/ui/data-table.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/data-table.json"
  }
}
