{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-grid",
  "title": "Data Grid",
  "description": "Sortable, searchable, filterable table with selection, column toggles and pagination, built on TanStack Table.",
  "dependencies": [
    "@tanstack/react-table@^8.21.3",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "dropdown-menu",
    "input",
    "select",
    "skeleton",
    "table"
  ],
  "files": [
    {
      "path": "src/components/velora/data-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  getFacetedRowModel,\n  getFacetedUniqueValues,\n  getFilteredRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n  type Column,\n  type ColumnDef,\n  type ColumnFiltersState,\n  type FilterFn,\n  type RowSelectionState,\n  type SortingState,\n  type VisibilityState,\n} from \"@tanstack/react-table\";\nimport {\n  ArrowDownIcon,\n  ArrowUpIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ChevronsLeftIcon,\n  ChevronsRightIcon,\n  ChevronsUpDownIcon,\n  PlusCircleIcon,\n  SearchIcon,\n  Settings2Icon,\n  XIcon,\n} from \"lucide-react\";\n\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { cn } from \"@/lib/utils\";\n\nexport type { ColumnDef } from \"@tanstack/react-table\";\n\nexport interface DataGridFilter {\n  /** Column id (usually the accessorKey) to filter */\n  column: string;\n  /** Label on the filter button */\n  title: string;\n  /** Choices; read from the data when omitted */\n  options?: { label: string; value: string }[];\n}\n\nexport interface DataGridProps<TData, TValue> {\n  /** Rows to display */\n  data: TData[];\n  /** TanStack Table column definitions; set `meta.label` for a readable name in menus */\n  columns: ColumnDef<TData, TValue>[];\n  /** Stable row id, so selection survives sorting and new data. Defaults to the row index */\n  getRowId?: (row: TData, index: number) => string;\n  /** Show a search box that matches text in every column @default true */\n  searchable?: boolean;\n  /** @default \"Search…\" */\n  searchPlaceholder?: string;\n  /** Multi-select filter buttons, one per column */\n  filters?: DataGridFilter[];\n  /** Add a checkbox column @default false */\n  selectable?: boolean;\n  /** Called with the selected rows' data whenever the selection changes */\n  onSelectionChange?: (rows: TData[]) => void;\n  /** Actions shown in a bar while rows are selected */\n  bulkActions?: (rows: TData[], clearSelection: () => void) => React.ReactNode;\n  /** Show the column visibility menu @default true */\n  columnToggle?: boolean;\n  /** @default 10 */\n  pageSize?: number;\n  /** Options in the rows-per-page menu @default [10, 20, 50] */\n  pageSizes?: number[];\n  /** Initial sort, e.g. `[{ id: \"date\", desc: true }]` */\n  defaultSort?: SortingState;\n  /** Show skeleton rows instead of data @default false */\n  loading?: boolean;\n  /** Shown when no row matches @default \"No results.\" */\n  empty?: React.ReactNode;\n  /** Row height @default \"comfortable\" */\n  density?: \"compact\" | \"comfortable\";\n  /** Accessible name for the table @default \"Data table\" */\n  label?: string;\n  className?: string;\n}\n\n// Faceted filters store an array of accepted values; an empty array means \"any\".\nconst inSet: FilterFn<unknown> = (row, id, value: string[]) =>\n  !value?.length || value.includes(String(row.getValue(id)));\n\nconst labelOf = <TData,>(column: Column<TData, unknown>) =>\n  (column.columnDef.meta as { label?: string } | undefined)?.label ??\n  (typeof column.columnDef.header === \"string\"\n    ? column.columnDef.header\n    : column.id);\n\n// A native checkbox, so selection works the same whether the project's\n// shadcn/ui primitives are built on Radix or Base UI.\nfunction SelectBox({\n  checked,\n  indeterminate = false,\n  onChange,\n  label,\n}: {\n  checked: boolean;\n  indeterminate?: boolean;\n  onChange: (value: boolean) => void;\n  label: string;\n}) {\n  const ref = React.useRef<HTMLInputElement>(null);\n  React.useEffect(() => {\n    if (ref.current) ref.current.indeterminate = indeterminate;\n  }, [indeterminate]);\n  return (\n    <input\n      ref={ref}\n      type=\"checkbox\"\n      checked={checked}\n      onChange={(e) => onChange(e.target.checked)}\n      aria-label={label}\n      className=\"size-4 translate-y-0.5 cursor-pointer rounded accent-primary\"\n    />\n  );\n}\n\nfunction FacetFilter<TData>({\n  column,\n  title,\n  options,\n}: {\n  column: Column<TData, unknown>;\n  title: string;\n  options?: DataGridFilter[\"options\"];\n}) {\n  const facets = column.getFacetedUniqueValues();\n  const choices =\n    options ??\n    [...facets.keys()]\n      .map((v) => ({ label: String(v), value: String(v) }))\n      .sort((a, b) => a.label.localeCompare(b.label));\n  const selected = new Set(\n    (column.getFilterValue() as string[] | undefined) ?? [],\n  );\n\n  const toggle = (value: string, on: boolean) => {\n    const next = new Set(selected);\n    if (on) next.add(value);\n    else next.delete(value);\n    column.setFilterValue(next.size ? [...next] : undefined);\n  };\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        className={cn(\n          buttonVariants({ variant: \"outline\", size: \"sm\" }),\n          \"border-dashed\",\n        )}\n      >\n        <PlusCircleIcon aria-hidden />\n        <span className=\"sr-only\">Filter by </span>\n        {title}\n        {selected.size > 0 && (\n          <span className=\"rounded-sm bg-primary/10 px-1.5 text-xs font-medium tabular-nums text-foreground\">\n            {selected.size}\n            <span className=\"sr-only\"> selected</span>\n          </span>\n        )}\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\" className=\"min-w-48\">\n        <DropdownMenuGroup>\n          <DropdownMenuLabel>{title}</DropdownMenuLabel>\n        </DropdownMenuGroup>\n        <DropdownMenuSeparator />\n        {choices.map((choice) => (\n          <DropdownMenuCheckboxItem\n            key={choice.value}\n            checked={selected.has(choice.value)}\n            onCheckedChange={(on) => toggle(choice.value, on === true)}\n            onSelect={(e) => e.preventDefault()}\n          >\n            <span className=\"flex-1\">{choice.label}</span>\n            <span className=\"ml-4 text-xs text-muted-foreground tabular-nums\">\n              {facets.get(choice.value) ?? 0}\n            </span>\n          </DropdownMenuCheckboxItem>\n        ))}\n        {selected.size > 0 && (\n          <>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem\n              onClick={() => column.setFilterValue(undefined)}\n              className=\"justify-center text-muted-foreground\"\n            >\n              Clear filter\n            </DropdownMenuItem>\n          </>\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\n/**\n * Sortable, filterable, paginated table built on TanStack Table and the\n * shadcn/ui table primitives. Pass column definitions and rows; search,\n * faceted filters, selection and column visibility are opt-in props.\n */\nexport function DataGrid<TData, TValue>({\n  data,\n  columns,\n  getRowId,\n  searchable = true,\n  searchPlaceholder = \"Search…\",\n  filters = [],\n  selectable = false,\n  onSelectionChange,\n  bulkActions,\n  columnToggle = true,\n  pageSize = 10,\n  pageSizes = [10, 20, 50],\n  defaultSort = [],\n  loading = false,\n  empty = \"No results.\",\n  density = \"comfortable\",\n  label = \"Data table\",\n  className,\n}: DataGridProps<TData, TValue>) {\n  const [sorting, setSorting] = React.useState<SortingState>(defaultSort);\n  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(\n    [],\n  );\n  const [globalFilter, setGlobalFilter] = React.useState(\"\");\n  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});\n  const [columnVisibility, setColumnVisibility] =\n    React.useState<VisibilityState>({});\n\n  const allColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n    if (!selectable) return columns;\n    const select: ColumnDef<TData, TValue> = {\n      id: \"__select\",\n      enableSorting: false,\n      enableHiding: false,\n      header: ({ table }) => (\n        <SelectBox\n          label=\"Select all rows on this page\"\n          checked={table.getIsAllPageRowsSelected()}\n          indeterminate={table.getIsSomePageRowsSelected()}\n          onChange={(v) => table.toggleAllPageRowsSelected(v)}\n        />\n      ),\n      cell: ({ row }) => (\n        <SelectBox\n          label={`Select row ${row.index + 1}`}\n          checked={row.getIsSelected()}\n          onChange={(v) => row.toggleSelected(v)}\n        />\n      ),\n    };\n    return [select, ...columns];\n  }, [columns, selectable]);\n\n  // TanStack returns a mutable table instance, so React Compiler skips this\n  // component instead of memoizing it — the intended behavior.\n  // eslint-disable-next-line react-hooks/incompatible-library\n  const table = useReactTable({\n    data,\n    columns: allColumns,\n    getRowId,\n    state: {\n      sorting,\n      columnFilters,\n      globalFilter,\n      rowSelection,\n      columnVisibility,\n    },\n    initialState: { pagination: { pageSize } },\n    defaultColumn: { filterFn: inSet as FilterFn<TData> },\n    globalFilterFn: \"includesString\",\n    enableRowSelection: selectable,\n    onSortingChange: setSorting,\n    onColumnFiltersChange: setColumnFilters,\n    onGlobalFilterChange: setGlobalFilter,\n    onRowSelectionChange: setRowSelection,\n    onColumnVisibilityChange: setColumnVisibility,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    getFacetedRowModel: getFacetedRowModel(),\n    getFacetedUniqueValues: getFacetedUniqueValues(),\n  });\n\n  const selectedRows = table.getSelectedRowModel().rows.map((r) => r.original);\n  const onSelectionChangeRef = React.useRef(onSelectionChange);\n  onSelectionChangeRef.current = onSelectionChange;\n  React.useEffect(() => {\n    onSelectionChangeRef.current?.(\n      table.getSelectedRowModel().rows.map((r) => r.original),\n    );\n  }, [rowSelection, table]);\n\n  const filtered = table.getFilteredRowModel().rows.length;\n  const { pageIndex, pageSize: size } = table.getState().pagination;\n  const first = filtered ? pageIndex * size + 1 : 0;\n  const last = Math.min((pageIndex + 1) * size, filtered);\n  const isFiltered = globalFilter !== \"\" || columnFilters.length > 0;\n  const cellPad = density === \"compact\" ? \"py-1.5\" : \"py-3\";\n  const visibleColumns = table.getVisibleLeafColumns().length;\n  const clearSelection = () => table.resetRowSelection();\n\n  return (\n    <div data-slot=\"data-grid\" className={cn(\"flex flex-col gap-3\", className)}>\n      <div className=\"flex flex-wrap items-center gap-2\">\n        {searchable && (\n          <div className=\"relative w-full sm:w-64\">\n            <SearchIcon\n              aria-hidden\n              className=\"pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground\"\n            />\n            <Input\n              type=\"search\"\n              value={globalFilter}\n              onChange={(e) => {\n                setGlobalFilter(e.target.value);\n                table.setPageIndex(0);\n              }}\n              placeholder={searchPlaceholder}\n              aria-label={searchPlaceholder.replace(/…$/, \"\")}\n              className=\"h-8 pl-8\"\n            />\n          </div>\n        )}\n        {filters.map((f) => {\n          const column = table.getColumn(f.column);\n          return column ? (\n            <FacetFilter\n              key={f.column}\n              column={column as Column<TData, unknown>}\n              title={f.title}\n              options={f.options}\n            />\n          ) : null;\n        })}\n        {isFiltered && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              setGlobalFilter(\"\");\n              setColumnFilters([]);\n            }}\n          >\n            Reset\n            <XIcon aria-hidden />\n          </Button>\n        )}\n        {columnToggle && (\n          <DropdownMenu>\n            <DropdownMenuTrigger\n              className={cn(\n                buttonVariants({ variant: \"outline\", size: \"sm\" }),\n                \"ml-auto\",\n              )}\n            >\n              <Settings2Icon aria-hidden />\n              Columns\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" className=\"min-w-40\">\n              <DropdownMenuGroup>\n                <DropdownMenuLabel>Show columns</DropdownMenuLabel>\n              </DropdownMenuGroup>\n              <DropdownMenuSeparator />\n              {table\n                .getAllLeafColumns()\n                .filter((c) => c.getCanHide())\n                .map((c) => (\n                  <DropdownMenuCheckboxItem\n                    key={c.id}\n                    checked={c.getIsVisible()}\n                    onCheckedChange={(v) => c.toggleVisibility(v === true)}\n                    onSelect={(e) => e.preventDefault()}\n                  >\n                    {labelOf(c as Column<TData, unknown>)}\n                  </DropdownMenuCheckboxItem>\n                ))}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n\n      {selectable && selectedRows.length > 0 && (\n        <div className=\"flex flex-wrap items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-1\">\n          <span className=\"font-medium tabular-nums\">\n            {selectedRows.length} selected\n          </span>\n          <div className=\"ml-auto flex items-center gap-2\">\n            {bulkActions?.(selectedRows, clearSelection)}\n            <Button variant=\"ghost\" size=\"sm\" onClick={clearSelection}>\n              Clear\n            </Button>\n          </div>\n        </div>\n      )}\n\n      <div className=\"overflow-hidden rounded-lg border\">\n        <Table aria-label={label} aria-busy={loading || undefined}>\n          <TableHeader className=\"bg-muted/40\">\n            {table.getHeaderGroups().map((group) => (\n              <TableRow key={group.id} className=\"hover:bg-transparent\">\n                {group.headers.map((header) => {\n                  const sorted = header.column.getIsSorted();\n                  const content = header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext(),\n                      );\n                  return (\n                    <TableHead\n                      key={header.id}\n                      className=\"px-3\"\n                      aria-sort={\n                        sorted === \"asc\"\n                          ? \"ascending\"\n                          : sorted === \"desc\"\n                            ? \"descending\"\n                            : header.column.getCanSort()\n                              ? \"none\"\n                              : undefined\n                      }\n                    >\n                      {header.column.getCanSort() ? (\n                        <button\n                          type=\"button\"\n                          onClick={header.column.getToggleSortingHandler()}\n                          className=\"-mx-1.5 inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                        >\n                          {content}\n                          {sorted === \"asc\" ? (\n                            <ArrowUpIcon aria-hidden className=\"size-3.5\" />\n                          ) : sorted === \"desc\" ? (\n                            <ArrowDownIcon aria-hidden className=\"size-3.5\" />\n                          ) : (\n                            <ChevronsUpDownIcon\n                              aria-hidden\n                              className=\"size-3.5 text-muted-foreground/60\"\n                            />\n                          )}\n                        </button>\n                      ) : (\n                        content\n                      )}\n                    </TableHead>\n                  );\n                })}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {loading ? (\n              Array.from({ length: size }, (_, i) => (\n                <TableRow key={i} className=\"hover:bg-transparent\">\n                  {table.getVisibleLeafColumns().map((c) => (\n                    <TableCell key={c.id} className={cn(\"px-3\", cellPad)}>\n                      <Skeleton className=\"h-4 w-full max-w-32\" />\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : table.getRowModel().rows.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  key={row.id}\n                  data-state={row.getIsSelected() ? \"selected\" : undefined}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id} className={cn(\"px-3\", cellPad)}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow className=\"hover:bg-transparent\">\n                <TableCell\n                  colSpan={visibleColumns}\n                  className=\"h-28 text-center text-muted-foreground\"\n                >\n                  {empty}\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground\">\n        <p aria-live=\"polite\" className=\"tabular-nums\">\n          {loading\n            ? \"Loading…\"\n            : filtered\n              ? `${first}–${last} of ${filtered}`\n              : \"0 results\"}\n        </p>\n        <div className=\"flex items-center gap-4\">\n          <div className=\"flex items-center gap-2\">\n            <span aria-hidden className=\"hidden sm:inline\">\n              Rows per page\n            </span>\n            <Select\n              value={String(size)}\n              onValueChange={(v) => table.setPageSize(Number(v))}\n            >\n              <SelectTrigger\n                size=\"sm\"\n                aria-label=\"Rows per page\"\n                className=\"w-[4.5rem]\"\n              >\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {pageSizes.map((n) => (\n                  <SelectItem key={n} value={String(n)}>\n                    {n}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n          <span className=\"tabular-nums\">\n            Page {table.getPageCount() ? pageIndex + 1 : 0} of{\" \"}\n            {table.getPageCount()}\n          </span>\n          <div className=\"flex items-center gap-1\">\n            {(\n              [\n                [\n                  \"First page\",\n                  ChevronsLeftIcon,\n                  () => table.firstPage(),\n                  !table.getCanPreviousPage(),\n                ],\n                [\n                  \"Previous page\",\n                  ChevronLeftIcon,\n                  () => table.previousPage(),\n                  !table.getCanPreviousPage(),\n                ],\n                [\n                  \"Next page\",\n                  ChevronRightIcon,\n                  () => table.nextPage(),\n                  !table.getCanNextPage(),\n                ],\n                [\n                  \"Last page\",\n                  ChevronsRightIcon,\n                  () => table.lastPage(),\n                  !table.getCanNextPage(),\n                ],\n              ] as const\n            ).map(([name, Icon, go, disabled]) => (\n              <Button\n                key={name}\n                variant=\"outline\"\n                size=\"icon-sm\"\n                onClick={go}\n                disabled={disabled}\n                aria-label={name}\n              >\n                <Icon aria-hidden />\n              </Button>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/data-grid.tsx"
    }
  ],
  "type": "registry:ui"
}