{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-grid",
  "type": "registry:block",
  "title": "Record data grid",
  "description": "Client-side TanStack v9 grid with sorting, search, filters, columns, pagination, selection, actions and recovery. Never use for a partial server-paged dataset.",
  "dependencies": [
    "@tanstack/react-table@^9.2.4"
  ],
  "registryDependencies": [
    "button",
    "input",
    "checkbox",
    "dropdown-menu",
    "table",
    "https://shine-blond.vercel.app/r/async-state.json",
    "https://shine-blond.vercel.app/r/collection.json"
  ],
  "files": [
    {
      "path": "blocks/data-grid.tsx",
      "type": "registry:component",
      "target": "components/shine/data-grid.tsx",
      "content": "\"use client\";\nimport { useId, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { columnFilteringFeature, columnVisibilityFeature, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFns, flexRender, globalFilteringFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, useTable, type ColumnDef, type ColumnFiltersState, type RowData, type RowSelectionState, type SortingState, type ColumnVisibilityState } from \"@tanstack/react-table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from \"@/components/ui/dropdown-menu\";\nimport { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from \"@/components/ui/table\";\nimport { AsyncState } from \"@/components/shine/async-state\";\nimport { Collection } from \"@/components/shine/collection\";\n\n/** Everything this grid uses, registered once (TanStack Table v9). Type consumer columns as `ColumnDef<DataGridFeatures, Row>`. */\nexport const dataGridFeatures = tableFeatures({\n  columnFilteringFeature, globalFilteringFeature, rowSortingFeature, rowPaginationFeature, rowSelectionFeature, columnVisibilityFeature,\n  filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(), paginatedRowModel: createPaginatedRowModel(),\n  filterFns, sortFns,\n});\nexport type DataGridFeatures = typeof dataGridFeatures;\ntype Filter = { column: string; label: string; options: { value: string; label: string }[] };\nexport type DataGridProps<T extends RowData> = {\n  title: string; rows: T[]; columns: ColumnDef<DataGridFeatures, T>[]; getRowId: (row: T) => string; rowLabel: (row: T) => string;\n  onOpen: (row: T) => void; columnLabels: Record<string, string>; filters?: Filter[]; summary?: ReactNode;\n  state?: \"ready\" | \"loading\" | \"error\"; errorMessage?: string; onRetry: () => void; emptyMessage: string;\n  bulkAction?: { label: string; run: (rows: T[]) => Promise<void> }; revealKey?: string;\n};\n/** Complete client-side grid. Remote paging must use the consumer's server grid, never silently page a partial dataset. */\nexport function DataGrid<T extends RowData>({ title, rows, columns, getRowId, rowLabel, onOpen, columnLabels, filters = [], summary, state = \"ready\", errorMessage = \"The source could not be read.\", onRetry, emptyMessage, bulkAction, revealKey }: DataGridProps<T>) {\n  const id = useId(), running = useRef(false);\n  const callbacks = useRef({ rowLabel, onOpen });\n  callbacks.current = { rowLabel, onOpen };\n  const selectable = Boolean(bulkAction);\n  const [query, setQuery] = useState(\"\"), [sorting, setSorting] = useState<SortingState>([]);\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]), [visibility, setVisibility] = useState<ColumnVisibilityState>({});\n  const [selection, setSelection] = useState<RowSelectionState>({}), [busy, setBusy] = useState(false), [actionError, setActionError] = useState(\"\"), [notice, setNotice] = useState(\"\");\n  const resolvedColumns = useMemo<ColumnDef<DataGridFeatures, T>[]>(() => [\n    ...(selectable ? [{ id: \"selection\", enableSorting: false, enableHiding: false, header: ({ table }) => <Checkbox aria-label=\"Select this page\" checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && \"indeterminate\")} onCheckedChange={checked => table.toggleAllPageRowsSelected(Boolean(checked))} />, cell: ({ row }) => <Checkbox aria-label={`Select ${callbacks.current.rowLabel(row.original)}`} checked={row.getIsSelected()} onCheckedChange={checked => row.toggleSelected(Boolean(checked))} /> } satisfies ColumnDef<DataGridFeatures, T>] : []),\n    ...columns,\n    { id: \"actions\", enableSorting: false, enableHiding: false, header: \"Actions\", cell: ({ row }) => <Button className=\"min-h-11\" variant=\"ghost\" type=\"button\" aria-label={`Open ${callbacks.current.rowLabel(row.original)}`} onClick={() => callbacks.current.onOpen(row.original)}>Open</Button> },\n  ], [columns, selectable]);\n  const table = useTable({ features: dataGridFeatures, data: rows, columns: resolvedColumns, getRowId, state: { globalFilter: query, sorting, columnFilters, columnVisibility: visibility, rowSelection: selection }, onGlobalFilterChange: setQuery, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setVisibility, onRowSelectionChange: setSelection, initialState: { pagination: { pageIndex: 0, pageSize: 10 } } });\n  const clear = () => { setQuery(\"\"); setColumnFilters([]); table.setPageIndex(0); };\n  const filtered = table.getFilteredRowModel().rows.length, selected = table.getSelectedRowModel().rows;\n  const page = table.state.pagination, count = table.getRowModel().rows.length;\n  return <Collection title={title} total={state === \"ready\" ? rows.length : null} summary={summary} revealKey={revealKey}>\n    <div data-product-pattern=\"data-grid\" data-grid className=\"min-w-0 space-y-4\" aria-busy={state === \"loading\"}>\n      <div data-grid-toolbar className=\"flex flex-wrap items-end gap-3\">\n        <label htmlFor={`${id}-search`} className=\"flex w-full min-w-0 flex-none flex-col gap-2 text-sm sm:w-auto sm:min-w-64 sm:flex-1\">Search {title.toLowerCase()}<Input className=\"h-11\" id={`${id}-search`} value={query} onChange={event => { setQuery(event.target.value); table.setPageIndex(0); }} /></label>\n        {filters.map(filter => <label key={filter.column} className=\"flex flex-col gap-2 text-sm\">{filter.label}<select aria-label={filter.label} className=\"h-11 rounded-md border bg-background px-3 text-foreground\" value={String(table.getColumn(filter.column)?.getFilterValue() || \"\")} onChange={event => { table.getColumn(filter.column)?.setFilterValue(event.target.value || undefined); table.setPageIndex(0); }}><option value=\"\">All</option>{filter.options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}\n        <Button className=\"min-h-11\" type=\"button\" variant=\"outline\" disabled={!query && !columnFilters.length} onClick={clear}>Clear filters</Button>\n        <DropdownMenu><DropdownMenuTrigger asChild><Button className=\"min-h-11\" type=\"button\" variant=\"outline\">Columns</Button></DropdownMenuTrigger><DropdownMenuContent align=\"end\">{table.getAllLeafColumns().filter(column => column.getCanHide()).map(column => <DropdownMenuCheckboxItem key={column.id} checked={column.getIsVisible()} disabled={column.getIsVisible() && table.getVisibleLeafColumns().filter(c => c.getCanHide()).length <= 1} onSelect={event => event.preventDefault()} onCheckedChange={checked => column.toggleVisibility(Boolean(checked))}>{columnLabels[column.id] || column.id}</DropdownMenuCheckboxItem>)}</DropdownMenuContent></DropdownMenu>\n      </div>\n      {bulkAction && <div className=\"flex flex-wrap items-center justify-between gap-3 rounded-md border p-3\"><span role=\"status\" className=\"text-sm\">{selected.length} selected</span><div className=\"flex gap-2\"><Button className=\"min-h-11\" type=\"button\" variant=\"ghost\" disabled={!selected.length || busy} onClick={() => setSelection({})}>Clear selection</Button><Button className=\"min-h-11\" type=\"button\" variant=\"outline\" disabled={!selected.length || busy || state !== \"ready\"} onClick={async () => { if (running.current) return; running.current = true; setBusy(true); setActionError(\"\"); setNotice(\"\"); try { await bulkAction.run(selected.map(row => row.original)); setSelection({}); setNotice(`${bulkAction.label} completed`); } catch (cause) { setActionError(cause instanceof Error ? cause.message : \"Action failed. Selection retained; try again.\"); } finally { running.current = false; setBusy(false); } }}>{busy ? \"Working…\" : bulkAction.label}</Button></div></div>}\n      {actionError && <p role=\"alert\" className=\"text-sm\">{actionError}</p>}<p role=\"status\" className=\"text-sm\">{notice}</p>\n      {state === \"loading\" ? <AsyncState state=\"loading\" /> : state === \"error\" ? <AsyncState state=\"error\" message={errorMessage} onRetry={onRetry} /> : rows.length === 0 ? <AsyncState state=\"empty\" message={emptyMessage} /> : filtered === 0 ? <AsyncState state=\"filtered-empty\" onClear={clear} /> : <>\n        <div className=\"max-w-full overflow-x-auto rounded-lg border\" tabIndex={0} role=\"region\" aria-label={`${title} table`}>\n          <Table><TableHeader>{table.getHeaderGroups().map(group => <TableRow key={group.id}>{group.headers.map(header => <TableHead key={header.id} aria-sort={header.column.getCanSort() ? (header.column.getIsSorted() === \"asc\" ? \"ascending\" : header.column.getIsSorted() === \"desc\" ? \"descending\" : \"none\") : undefined}>{header.isPlaceholder ? null : header.column.getCanSort() ? <Button className=\"min-h-11\" type=\"button\" variant=\"ghost\" onClick={header.column.getToggleSortingHandler()}>{flexRender(header.column.columnDef.header, header.getContext())}<span aria-hidden>{header.column.getIsSorted() === \"asc\" ? \"↑\" : header.column.getIsSorted() === \"desc\" ? \"↓\" : \"↕\"}</span></Button> : flexRender(header.column.columnDef.header, header.getContext())}</TableHead>)}</TableRow>)}</TableHeader>\n          <TableBody>{table.getRowModel().rows.map(row => <TableRow key={row.id} data-state={row.getIsSelected() ? \"selected\" : undefined}>{row.getVisibleCells().map(cell => <TableCell key={cell.id} className={typeof cell.getValue() === \"number\" ? \"text-right tabular-nums\" : undefined}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>)}</TableRow>)}</TableBody></Table>\n        </div>\n        <div className=\"flex flex-wrap items-center justify-between gap-3 text-sm\"><label className=\"flex items-center gap-2\">Rows per page<select className=\"h-11 rounded-md border bg-background px-2\" value={page.pageSize} onChange={event => table.setPageSize(Number(event.target.value))}>{[10, 20, 50].map(size => <option key={size} value={size}>{size}</option>)}</select></label><span role=\"status\">{page.pageIndex * page.pageSize + 1}–{page.pageIndex * page.pageSize + count} of {filtered}</span><div className=\"flex gap-2\"><Button className=\"min-h-11\" type=\"button\" variant=\"outline\" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>Previous</Button><Button className=\"min-h-11\" type=\"button\" variant=\"outline\" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>Next</Button></div></div>\n      </>}\n    </div>\n  </Collection>;\n}\n"
    }
  ]
}
