{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "title": "Data Table",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A complete data grid on TanStack Table: sortable headers, search, column visibility, row selection, pagination, a loading state and live result counts.",
  "dependencies": [
    "@tanstack/react-table@^8",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/checkbox.json",
    "https://ui.ballmac.com/r/dropdown-menu.json",
    "https://ui.ballmac.com/r/table.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/data-table.tsx",
      "content": "// Ballmac UI: Data Table. https://ui.ballmac.com/components/data-table\n// Based on shadcn/ui Data Table (MIT, Copyright (c) 2023 shadcn) on TanStack Table (MIT, Copyright (c) 2016 Tanner Linsley), packaged as one component with sorting, search, column visibility, row selection, pagination, a loading state, and live result announcements.\n\"use client\";\n\nimport * as React from \"react\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n  type Column,\n  type ColumnDef,\n  type Row,\n  type RowSelectionState,\n  type SortingState,\n  type Table as TanstackTable,\n  type VisibilityState,\n} from \"@tanstack/react-table\";\nimport { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown, Columns3, Search } from \"lucide-react\";\nimport { Button } from \"@/components/ballmac/button\";\nimport { Checkbox } from \"@/components/ballmac/checkbox\";\nimport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ballmac/dropdown-menu\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ballmac/table\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype DataTableProps<TData, TValue = unknown> = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Column definitions from TanStack Table. Use `DataTableColumnHeader` in `header` for sortable columns, and `meta: { label: \"Name\" }` to name a column in the Columns menu. */\n  columns: ColumnDef<TData, TValue>[];\n  /** Rows to display. */\n  data: TData[];\n  /** Accessible name of the table. Also names the scroll region. */\n  label: string;\n  /** Add a leading checkbox column for selecting rows. */\n  selectable?: boolean;\n  /** Called with the selected row objects whenever the selection changes. */\n  onSelectionChange?: (rows: TData[]) => void;\n  /** Show a search field that filters across every column. */\n  searchable?: boolean;\n  /** Placeholder of the search field. */\n  searchPlaceholder?: string;\n  /** Show a menu for hiding and showing columns. */\n  columnToggle?: boolean;\n  /** Rows per page. Set to 0 to disable pagination. */\n  pageSize?: number;\n  /** Show placeholder rows while data loads. */\n  loading?: boolean;\n  /** Message when there are no rows or no matches. */\n  emptyMessage?: React.ReactNode;\n  /** Initial sort, for example `[{ id: \"amount\", desc: true }]`. */\n  initialSorting?: SortingState;\n  /** Extra content on the right of the toolbar, such as an export button. */\n  toolbar?: React.ReactNode;\n  /** Give every row a stable id. Defaults to the row index. */\n  getRowId?: (row: TData, index: number) => string;\n  /** Keep the header visible while the body scrolls. Combine with `maxHeightClassName`. */\n  stickyHeader?: boolean;\n  /** Height limit for the scroll area, for example `max-h-96`. */\n  maxHeightClassName?: string;\n};\n\nfunction SelectAllCheckbox<TData>({ table }: { table: TanstackTable<TData> }) {\n  const msg = useMessages();\n  return (\n    <Checkbox\n      aria-label={msg(\"data-table.selectAllRows\", \"Select all rows on this page\")}\n      checked={table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? \"indeterminate\" : false}\n      onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}\n    />\n  );\n}\n\nfunction SelectRowCheckbox<TData>({ row }: { row: Row<TData> }) {\n  const msg = useMessages();\n  return (\n    <Checkbox\n      aria-label={msg(\"data-table.selectRow\", \"Select row {n}\", { n: row.index + 1 })}\n      checked={row.getIsSelected()}\n      onCheckedChange={(v) => row.toggleSelected(!!v)}\n    />\n  );\n}\n\nfunction selectionColumn<TData>(): ColumnDef<TData> {\n  return {\n    id: \"select\",\n    enableSorting: false,\n    enableHiding: false,\n    header: SelectAllCheckbox,\n    cell: SelectRowCheckbox,\n  };\n}\n\n/**\n * A complete data grid on TanStack Table: click headers to sort, search, hide columns, select rows and page through results.\n * Sorting, filtering and paging run in the browser; for server data, use TanStack's manual modes by building on the same parts.\n */\nfunction DataTable<TData, TValue = unknown>({\n  columns,\n  data,\n  label,\n  selectable = false,\n  onSelectionChange,\n  searchable = true,\n  searchPlaceholder,\n  columnToggle = true,\n  pageSize = 8,\n  loading = false,\n  emptyMessage,\n  initialSorting = [],\n  toolbar,\n  getRowId,\n  stickyHeader = false,\n  maxHeightClassName,\n  className,\n  ...props\n}: DataTableProps<TData, TValue>) {\n  const msg = useMessages()\n  searchPlaceholder ??= msg(\"data-table.searchPlaceholder\", \"Search\")\n  emptyMessage ??= msg(\"data-table.emptyMessage\", \"No results.\")\n  const [sorting, setSorting] = React.useState<SortingState>(initialSorting);\n  const [globalFilter, setGlobalFilter] = React.useState(\"\");\n  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});\n  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});\n  const allColumns = React.useMemo(\n    () => (selectable ? [selectionColumn<TData>() as ColumnDef<TData, TValue>, ...columns] : columns),\n    [columns, selectable],\n  );\n  const paginated = pageSize > 0;\n  const table = useReactTable({\n    data,\n    columns: allColumns,\n    getRowId,\n    state: { sorting, globalFilter, columnVisibility, rowSelection },\n    onSortingChange: setSorting,\n    onGlobalFilterChange: setGlobalFilter,\n    onColumnVisibilityChange: setColumnVisibility,\n    onRowSelectionChange: setRowSelection,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    ...(paginated ? { getPaginationRowModel: getPaginationRowModel() } : {}),\n    initialState: { pagination: { pageIndex: 0, pageSize: paginated ? pageSize : Number.MAX_SAFE_INTEGER } },\n  });\n\n  const selected = table.getSelectedRowModel().rows;\n  const onSelectionRef = React.useRef(onSelectionChange);\n  React.useEffect(() => {\n    onSelectionRef.current = onSelectionChange;\n  });\n  React.useEffect(() => {\n    onSelectionRef.current?.(selected.map((r) => r.original));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rowSelection]);\n\n  const rows = table.getRowModel().rows;\n  const filteredCount = table.getFilteredRowModel().rows.length;\n  const visibleColumns = table.getVisibleLeafColumns().length;\n  const page = table.getState().pagination;\n  const from = filteredCount === 0 ? 0 : page.pageIndex * page.pageSize + 1;\n  const to = Math.min(filteredCount, (page.pageIndex + 1) * page.pageSize);\n\n  return (\n    <div data-slot=\"data-table\" className={cn(\"grid w-full gap-3\", className)} {...props}>\n      {(searchable || columnToggle || toolbar) && (\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {searchable && (\n            <div className=\"relative min-w-0 flex-1 sm:max-w-xs\">\n              <Search aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 start-2.5 size-4 -translate-y-1/2 text-muted-foreground\" />\n              <input\n                type=\"search\"\n                aria-label={msg(\"data-table.search\", \"Search {label}\", { label })}\n                placeholder={searchPlaceholder}\n                value={globalFilter}\n                onChange={(e) => {\n                  setGlobalFilter(e.target.value);\n                  table.setPageIndex(0);\n                }}\n                className=\"h-9 w-full rounded-md border border-input bg-background pe-3 ps-8 text-sm shadow-xs outline-none transition-[color,border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30\"\n              />\n            </div>\n          )}\n          <div className=\"ms-auto flex items-center gap-2\">\n            {toolbar}\n            {columnToggle && <ColumnMenu table={table} />}\n          </div>\n        </div>\n      )}\n      <Table\n        label={label}\n        aria-label={label}\n        stickyHeader={stickyHeader}\n        containerClassName={maxHeightClassName}\n      >\n        <TableHeader>\n          {table.getHeaderGroups().map((group) => (\n            <TableRow key={group.id}>\n              {group.headers.map((header) => (\n                <TableHead\n                  key={header.id}\n                  sort={\n                    header.column.getCanSort()\n                      ? (header.column.getIsSorted() === \"asc\" ? \"ascending\" : header.column.getIsSorted() === \"desc\" ? \"descending\" : \"none\")\n                      : undefined\n                  }\n                  sortIcon={false}\n                  className={cn(header.column.id === \"select\" && \"w-10\")}\n                >\n                  {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {loading ? (\n            Array.from({ length: Math.min(pageSize || 5, 5) }, (_, r) => (\n              <TableRow key={r} aria-hidden=\"true\">\n                {Array.from({ length: visibleColumns }, (_, c) => (\n                  <TableCell key={c}>\n                    <span className=\"block h-4 animate-pulse rounded bg-muted motion-reduce:animate-none\" style={{ width: `${55 + ((r * 7 + c * 13) % 35)}%` }} />\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : rows.length ? (\n            rows.map((row) => (\n              <TableRow key={row.id} selected={row.getIsSelected()}>\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell colSpan={visibleColumns} className=\"h-24 text-center text-muted-foreground\">\n                {emptyMessage}\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n      <div className=\"flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground\">\n        <p aria-live=\"polite\" role=\"status\">\n          {selectable && selected.length > 0\n            ? `${selected.length} of ${filteredCount} selected`\n            : loading\n              ? \"Loading…\"\n              : filteredCount === 0\n                ? \"0 results\"\n                : `${from}–${to} of ${filteredCount}`}\n        </p>\n        {paginated && (\n          <div className=\"flex items-center gap-1.5\">\n            <Button\n              variant=\"outline\"\n              size=\"icon-sm\"\n              aria-label={msg(\"data-table.previousPage\", \"Previous page\")}\n              onClick={() => table.previousPage()}\n              disabled={!table.getCanPreviousPage()}\n            >\n              <ChevronLeft aria-hidden=\"true\"  className=\"rtl:rotate-180\"/>\n            </Button>\n            <span className=\"min-w-20 text-center tabular-nums\">\n              {msg(\"data-table.pageOf\", \"Page {page} of {total}\", { page: Math.min(page.pageIndex + 1, Math.max(table.getPageCount(), 1)), total: Math.max(table.getPageCount(), 1) })}\n            </span>\n            <Button\n              variant=\"outline\"\n              size=\"icon-sm\"\n              aria-label={msg(\"data-table.nextPage\", \"Next page\")}\n              onClick={() => table.nextPage()}\n              disabled={!table.getCanNextPage()}\n            >\n              <ChevronRight aria-hidden=\"true\"  className=\"rtl:rotate-180\"/>\n            </Button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/** Name shown in the column menu: `meta.label`, else a string header, else the id with its first letter capitalised. */\nfunction columnLabel<TData>(column: Column<TData, unknown>) {\n  const meta = column.columnDef.meta as { label?: string } | undefined;\n  if (meta?.label) return meta.label;\n  if (typeof column.columnDef.header === \"string\") return column.columnDef.header;\n  return column.id.charAt(0).toUpperCase() + column.id.slice(1);\n}\n\nfunction ColumnMenu<TData>({ table }: { table: TanstackTable<TData> }) {\n  const msg = useMessages()\n  const hideable = table.getAllColumns().filter((c) => c.getCanHide());\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger className=\"inline-flex h-9 items-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30\">\n        <Columns3 aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n        Columns\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-48\">\n        <DropdownMenuLabel>{msg(\"data-table.showColumns\", \"Show columns\")}</DropdownMenuLabel>\n        <DropdownMenuSeparator />\n        {hideable.map((column) => (\n          <DropdownMenuCheckboxItem\n            key={column.id}\n            checked={column.getIsVisible()}\n            onCheckedChange={(v) => column.toggleVisibility(!!v)}\n            onSelect={(e) => e.preventDefault()}\n          >\n            {columnLabel(column)}\n          </DropdownMenuCheckboxItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\ntype DataTableColumnHeaderProps<TData, TValue> = React.ComponentProps<\"button\"> & {\n  /** The column from the header context: `header: ({ column }) => <DataTableColumnHeader column={column} title=\"Amount\" />`. */\n  column: Column<TData, TValue>;\n  /** Visible column title. */\n  title: string;\n  /** Right-align the title for numeric columns. */\n  numeric?: boolean;\n};\n\n/** A header button that cycles ascending, descending and unsorted, with a direction icon. */\nfunction DataTableColumnHeader<TData, TValue>({\n  column,\n  title,\n  numeric,\n  className,\n  ...props\n}: DataTableColumnHeaderProps<TData, TValue>) {\n  if (!column.getCanSort()) return <span className={cn(numeric && \"block text-end\")}>{title}</span>;\n  const sorted = column.getIsSorted();\n  const Icon = sorted === \"asc\" ? ArrowUp : sorted === \"desc\" ? ArrowDown : ChevronsUpDown;\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"data-table-column-header\"\n      onClick={column.getToggleSortingHandler()}\n      className={cn(\n        \"-mx-1.5 inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-xs font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        numeric && \"flex-row-reverse\",\n        className,\n      )}\n      {...props}\n    >\n      {title}\n      <Icon aria-hidden=\"true\" className={cn(\"size-3.5\", sorted ? \"text-foreground\" : \"text-muted-foreground\")} />\n    </button>\n  );\n}\n\nexport {\n  DataTable,\n  DataTableColumnHeader,\n  type DataTableProps,\n  type DataTableColumnHeaderProps,\n  type ColumnDef,\n};\n",
      "type": "registry:ui",
      "target": "@components/ballmac/data-table.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "table",
      "sorting",
      "filter",
      "pagination",
      "tanstack"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Pass columns (TanStack ColumnDef) and data. Use DataTableColumnHeader for sortable headers. selectable adds checkboxes; pageSize sets rows per page.",
      "whenToUse": [
        "Lists of records people search, sort and select",
        "Admin and billing screens"
      ],
      "whenNotToUse": [
        "A small static table; use table",
        "Server-side paging for huge datasets without adapting the parts"
      ],
      "composesWith": [
        "table",
        "badge",
        "dropdown-menu"
      ],
      "a11y": [
        {
          "keys": "Tab / Enter",
          "action": "Sort buttons, checkboxes, pagination and the column menu are real controls"
        },
        {
          "keys": "Screen readers",
          "action": "aria-sort on headers; the result count is a polite live region"
        }
      ],
      "customization": [
        "columns and cell renderers",
        "selectable and onSelectionChange",
        "searchable, columnToggle, pageSize",
        "stickyHeader with maxHeightClassName",
        "toolbar slot"
      ]
    },
    "source": {
      "name": "shadcn/ui Data Table",
      "url": "https://github.com/shadcn-ui/ui",
      "license": "MIT",
      "copyright": "Copyright (c) 2023 shadcn",
      "modified": true
    },
    "examples": [
      "data-table-demo",
      "data-table-states"
    ],
    "url": "https://ui.ballmac.com/components/data-table"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}