{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "server-data-grid",
  "type": "registry:block",
  "title": "Server record grid",
  "description": "TanStack manual pagination, search and sorting, large remote populations, column resizing and retained inline edits.",
  "dependencies": [
    "@tanstack/react-table@^9.2.4"
  ],
  "registryDependencies": [
    "button",
    "input",
    "https://shine-blond.vercel.app/r/async-state.json",
    "https://shine-blond.vercel.app/r/collection.json",
    "https://shine-blond.vercel.app/r/filter-bar.json"
  ],
  "files": [
    {
      "path": "blocks/server-data-grid.tsx",
      "type": "registry:component",
      "target": "components/shine/server-data-grid.tsx",
      "content": "\"use client\";\nimport { useEffect,useRef,useState,type ReactNode } from \"react\";\nimport { columnResizingFeature,columnSizingFeature,columnVisibilityFeature,flexRender,rowPaginationFeature,rowSortingFeature,tableFeatures,useTable,type ColumnDef,type SortingState,type PaginationState } from \"@tanstack/react-table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { AsyncState } from \"@/components/shine/async-state\";\nimport { FilterBar } from \"@/components/shine/filter-bar\";\nimport { Collection } from \"@/components/shine/collection\";\n/** Server-owned sorting and paging with local column sizing (TanStack Table v9). Type columns as `ColumnDef<ServerDataGridFeatures, Row>`. */\nexport const serverDataGridFeatures=tableFeatures({rowSortingFeature,rowPaginationFeature,columnVisibilityFeature,columnSizingFeature,columnResizingFeature});\nexport type ServerDataGridFeatures=typeof serverDataGridFeatures;\nexport type ServerQuery={pagination:PaginationState;sorting:SortingState;search:string;filters?:Record<string,string>};\nexport type ServerPage<T>={rows:T[];rowCount:number};\nfunction EditCell({value,onSave,label}:{value:string;onSave:(value:string)=>Promise<void>;label:string}) {\n const [editing,setEditing]=useState(false),[draft,setDraft]=useState(value),[busy,setBusy]=useState(false),[error,setError]=useState(\"\");const lock=useRef(false);\n return editing?<form className=\"min-w-48 space-y-2\" onSubmit={async event=>{event.preventDefault();if(lock.current)return;lock.current=true;setBusy(true);setError(\"\");try{await onSave(draft);setEditing(false);}catch(cause){setError(cause instanceof Error?cause.message:\"Save failed. Retry.\");}finally{lock.current=false;setBusy(false);}}}><Input aria-label={label} value={draft} disabled={busy} onChange={event=>setDraft(event.target.value)}/><p role=\"alert\" className=\"text-sm text-destructive\">{error}</p><div className=\"flex gap-2\"><Button disabled={busy}>Save</Button><Button type=\"button\" variant=\"outline\" disabled={busy} onClick={()=>setEditing(false)}>Cancel</Button></div></form>:<Button variant=\"ghost\" aria-label={\"Edit \"+label} onClick={()=>{setDraft(value);setEditing(true);}}>{value||\"Empty\"}</Button>;\n}\n/** Partial server results never enter client sorting/filtering/pagination models. */\nexport function ServerDataGrid<T extends {id:string}>({title,columns,query,onQueryChange,load,summary,onOpen,onEdit,filters=[]}:{title:string;filters?:{id:string;label:string;options:{value:string;label:string}[]}[];columns:ColumnDef<ServerDataGridFeatures,T,unknown>[];query:ServerQuery;onQueryChange:(query:ServerQuery)=>void;load:(query:ServerQuery,signal:AbortSignal)=>Promise<ServerPage<T>>;summary:(total:number|null)=>ReactNode;onOpen:(row:T)=>void;onEdit?:(row:T,column:string,value:string)=>Promise<void>}) {\n const [page,setPage]=useState<ServerPage<T>|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState(\"\"),[revision,setRevision]=useState(0);const generation=useRef(0);\n if(!Number.isInteger(query.pagination.pageIndex)||query.pagination.pageIndex<0||![10,25,50,100].includes(query.pagination.pageSize))throw Error(\"Use a nonnegative page index and a supported bounded page size\");\n const key=JSON.stringify(query);\n useEffect(()=>{const controller=new AbortController(),request=++generation.current;setLoading(true);setError(\"\");setPage(null);void load(query,controller.signal).then(result=>{if(controller.signal.aborted||generation.current!==request)return;if(!Number.isInteger(result.rowCount)||result.rowCount<0||result.rows.length>query.pagination.pageSize||result.rows.length>result.rowCount||new Set(result.rows.map(row=>row.id)).size!==result.rows.length)throw Error(\"Invalid server page\");setPage(result);}).catch(cause=>{if(!controller.signal.aborted&&generation.current===request)setError(cause instanceof Error?cause.message:\"Could not load records.\");}).finally(()=>{if(!controller.signal.aborted&&generation.current===request)setLoading(false);});return()=>controller.abort();},[key,load,revision]);\n const table=useTable({features:serverDataGridFeatures,data:page?.rows||[],columns,getRowId:row=>row.id,manualPagination:true,manualSorting:true,rowCount:page?.rowCount||0,columnResizeMode:\"onChange\",defaultColumn:{minSize:120,maxSize:600},state:{pagination:query.pagination,sorting:query.sorting},onSortingChange:update=>onQueryChange({...query,pagination:{...query.pagination,pageIndex:0},sorting:typeof update===\"function\"?update(query.sorting):update}),onPaginationChange:update=>onQueryChange({...query,pagination:typeof update===\"function\"?update(query.pagination):update})});\n return <div data-product-pattern=\"server-data-grid\" className=\"min-w-0 space-y-4\"><FilterBar label={\"Search \"+title} query={query.search} onQueryChange={search=>onQueryChange({...query,search,pagination:{...query.pagination,pageIndex:0}})} filters={filters.map(filter=>({...filter,value:query.filters?.[filter.id]||\"\",onChange:value=>onQueryChange({...query,filters:{...query.filters,[filter.id]:value},pagination:{...query.pagination,pageIndex:0}})}))} onClear={()=>onQueryChange({...query,search:\"\",filters:{},pagination:{...query.pagination,pageIndex:0}})} resultLabel={page?new Intl.NumberFormat().format(page.rowCount)+\" matching records\":\"Loading matching records\"}/><Collection title={title} total={page?.rowCount??null} summary={summary(page?.rowCount??null)}>{loading?<AsyncState state=\"loading\"/>:error?<AsyncState state=\"error\" message={error} onRetry={()=>setRevision(value=>value+1)}/>:!page?.rows.length?<AsyncState state=\"empty\" message={page?.rowCount?\"This page no longer has records.\":\"No records match this search.\"} action={<Button variant=\"outline\" onClick={()=>onQueryChange({...query,search:\"\",pagination:{...query.pagination,pageIndex:0}})}>Reset search and page</Button>}/>:<div className=\"space-y-4\"><div role=\"region\" aria-label={title+\" table\"} tabIndex={0} className=\"overflow-x-auto rounded-md border border-border\"><table className=\"w-full border-collapse text-sm\" style={{minWidth:table.getTotalSize()+120}}><caption className=\"sr-only\">{title}, {page.rowCount} matching records. Sorting applies to all matching records.</caption><thead className=\"bg-muted\">{table.getHeaderGroups().map(group=><tr key={group.id}>{group.headers.map(header=><th key={header.id} scope=\"col\" style={{width:header.getSize()}} aria-sort={header.column.getIsSorted()===\"asc\"?\"ascending\":header.column.getIsSorted()===\"desc\"?\"descending\":\"none\"} className=\"relative border-b border-border p-3 text-left\"><Button variant=\"ghost\" disabled={!header.column.getCanSort()} onClick={header.column.getToggleSortingHandler()}>{flexRender(header.column.columnDef.header,header.getContext())}{header.column.getIsSorted()===\"asc\"?\" ↑\":header.column.getIsSorted()===\"desc\"?\" ↓\":\"\"}</Button><div className=\"flex gap-1\"><Button variant=\"ghost\" aria-label={\"Narrow \"+header.column.id} disabled={header.getSize()<=120} onClick={()=>table.setColumnSizing(current=>({...current,[header.column.id]:Math.max(120,header.getSize()-40)}))}>−</Button><Button variant=\"ghost\" aria-label={\"Widen \"+header.column.id} disabled={header.getSize()>=600} onClick={()=>table.setColumnSizing(current=>({...current,[header.column.id]:Math.min(600,header.getSize()+40)}))}>+</Button></div><div aria-hidden=\"true\" onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} className=\"absolute inset-y-0 right-0 w-2 cursor-col-resize touch-none\"/></th>)}<th scope=\"col\" className=\"p-3 text-left\">Record</th></tr>)}</thead><tbody>{table.getRowModel().rows.map(row=><tr key={row.id} className=\"border-b border-border\">{row.getVisibleCells().map(cell=><td key={cell.id} className=\"p-3\">{onEdit&&cell.column.columnDef.meta&&'editable' in cell.column.columnDef.meta&&cell.column.columnDef.meta.editable?<EditCell value={String(cell.getValue()??\"\")} label={cell.column.id+\" for \"+row.id} onSave={async value=>{await onEdit(row.original,cell.column.id,value);setRevision(current=>current+1);}}/>:flexRender(cell.column.columnDef.cell,cell.getContext())}</td>)}<td className=\"p-3\"><Button variant=\"outline\" aria-label={\"Open record \"+row.id} onClick={()=>onOpen(row.original)}>Open</Button></td></tr>)}</tbody></table></div><div className=\"flex flex-wrap items-center justify-between gap-3\"><p role=\"status\" className=\"text-sm\">Page {query.pagination.pageIndex+1} of {Math.max(1,Math.ceil(page.rowCount/query.pagination.pageSize))} · {page.rowCount} records</p><label className=\"flex items-center gap-2 text-sm\">Rows per page<select aria-label=\"Rows per page\" value={query.pagination.pageSize} onChange={event=>onQueryChange({...query,pagination:{pageIndex:0,pageSize:Number(event.target.value)}})} className=\"h-11 rounded-md border border-input bg-background px-3\">{[10,25,50,100].map(size=><option key={size}>{size}</option>)}</select></label><div className=\"flex gap-2\"><Button variant=\"outline\" disabled={!table.getCanPreviousPage()} onClick={()=>table.previousPage()}>Previous</Button><Button variant=\"outline\" disabled={!table.getCanNextPage()} onClick={()=>table.nextPage()}>Next</Button></div></div></div>}</Collection></div>;\n}\n"
    }
  ]
}
