{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:ui",
  "title": "Data Table",
  "description": "TanStack Table engine: filtering, pagination, sorting, selection, inline edit, and row actions.",
  "dependencies": [
    "@tanstack/react-table",
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://seamui.dev/r/utils.json",
    "https://seamui.dev/r/motion.json",
    "https://seamui.dev/r/haptics.json",
    "https://seamui.dev/r/table.json",
    "https://seamui.dev/r/button.json",
    "https://seamui.dev/r/input.json",
    "https://seamui.dev/r/select.json",
    "https://seamui.dev/r/dropdown-menu.json"
  ],
  "files": [
    {
      "path": "registry/seam/ui/data-table.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  type Column,\n  type ColumnDef,\n  type ColumnFiltersState,\n  type RowData,\n  type RowSelectionState,\n  type SortingState,\n  type Table as TanstackTable,\n  type TableOptions,\n  type VisibilityState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\"\nimport { motion, useAnimate } from \"motion/react\"\nimport {\n  ArrowUp,\n  ArrowUpDown,\n  ChevronLeft,\n  ChevronRight,\n  ChevronsLeft,\n  ChevronsRight,\n  MoreHorizontal,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { fades, reduced, shake, springs, useReducedMotion } from \"@/lib/motion\"\nimport { useHaptics } from \"@/lib/haptics\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"./table\"\nimport { Button, buttonVariants } from \"./button\"\nimport { Input } from \"./input\"\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"./select\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"./dropdown-menu\"\n\n// Let column cell renderers reach a commit hook for inline edits. TanStack\n// threads `meta` from useReactTable to every cell context, so a `cell:`\n// renderer can call `table.options.meta?.updateData(...)`.\ndeclare module \"@tanstack/react-table\" {\n  interface TableMeta<TData extends RowData> {\n    updateData?: (rowIndex: number, columnId: string, value: unknown) => void\n  }\n}\n\n// The body fades on every reflow (page / sort / filter) rather than springing\n// row positions — reflowing content must never bounce (same rule as Response).\n// A remount keyed to the reflow signature replays the opacity-only entrance;\n// under reduced motion `fades.fast` is already opacity-only, so it stays alive.\nconst MotionTableBody = motion.create(TableBody)\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[]\n  data: TData[]\n  /** Stable row identity (survives sort/filter/paginate for selection + edits). */\n  getRowId?: (row: TData, index: number) => string\n  /** Fires when an editable cell commits; wired into `meta.updateData`. */\n  onDataChange?: (rowIndex: number, columnId: string, value: unknown) => void\n  /** Toolbar (filter input, faceted filters); receives the live table instance. */\n  toolbar?: (table: TanstackTable<TData>) => React.ReactNode\n  /** Show the pagination footer. */\n  pagination?: boolean\n  /** Initial rows per page. */\n  pageSize?: number\n  /** Accessible name for the scrollable table region. */\n  label?: string\n  className?: string\n  /**\n   * Escape hatch merged into `useReactTable`. Pass controlled `state` +\n   * `on*Change`, `manualPagination`/`manualSorting`/`manualFiltering` with\n   * `pageCount`/`rowCount` for server-side data, or any other TanStack option.\n   * A `state` here shallow-merges over the built-in slices, and an\n   * `on*Change` overrides the matching built-in setter, so you can control one\n   * slice (e.g. sorting) and leave the rest internal.\n   */\n  options?: Partial<TableOptions<TData>>\n}\n\nfunction DataTable<TData, TValue>({\n  columns,\n  data,\n  getRowId,\n  onDataChange,\n  toolbar,\n  pagination = true,\n  pageSize = 10,\n  label,\n  className,\n  options,\n}: DataTableProps<TData, TValue>) {\n  const [sorting, setSorting] = React.useState<SortingState>([])\n  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(\n    []\n  )\n  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})\n  const [columnVisibility, setColumnVisibility] =\n    React.useState<VisibilityState>({})\n\n  const table = useReactTable({\n    getRowId,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    getPaginationRowModel: pagination ? getPaginationRowModel() : undefined,\n    initialState: { pagination: { pageSize } },\n    // Consumer options override the framework defaults above…\n    ...options,\n    // …but data/columns/state/setters/meta stay controlled here, merging any\n    // slice the consumer chose to take over.\n    data,\n    columns,\n    state: {\n      sorting,\n      columnFilters,\n      rowSelection,\n      columnVisibility,\n      ...options?.state,\n    },\n    onSortingChange: options?.onSortingChange ?? setSorting,\n    onColumnFiltersChange: options?.onColumnFiltersChange ?? setColumnFilters,\n    onRowSelectionChange: options?.onRowSelectionChange ?? setRowSelection,\n    onColumnVisibilityChange:\n      options?.onColumnVisibilityChange ?? setColumnVisibility,\n    meta: { updateData: onDataChange, ...options?.meta },\n  })\n\n  const rows = table.getRowModel().rows\n  const columnCount = table.getVisibleFlatColumns().length\n  const tableState = table.getState()\n  // Signature of what's on screen — changing it refades the body. Read from\n  // the live table state so it tracks reflows in controlled mode too.\n  const reflowKey = `${tableState.pagination.pageIndex}:${JSON.stringify(\n    tableState.sorting\n  )}:${JSON.stringify(tableState.columnFilters)}`\n\n  return (\n    <div\n      data-slot=\"data-table\"\n      className={cn(\"flex flex-col gap-3\", className)}\n    >\n      {toolbar?.(table)}\n      <Table aria-label={label}>\n        <TableHeader>\n          {table.getHeaderGroups().map((group) => (\n            <TableRow key={group.id}>\n              {group.headers.map((header) => (\n                <TableHead\n                  key={header.id}\n                  colSpan={header.colSpan}\n                  aria-sort={ariaSort(header.column)}\n                >\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext()\n                      )}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <MotionTableBody\n          key={reflowKey}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={fades.fast}\n        >\n          {rows.length ? (\n            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}>\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell\n                colSpan={columnCount}\n                className=\"text-muted-foreground h-24 text-center\"\n              >\n                No results.\n              </TableCell>\n            </TableRow>\n          )}\n        </MotionTableBody>\n      </Table>\n      {pagination ? <DataTablePagination table={table} /> : null}\n    </div>\n  )\n}\n\n/** aria-sort for a header cell, absent unless the column is actually sortable. */\nfunction ariaSort<TData, TValue>(\n  column: Column<TData, TValue>\n): React.AriaAttributes[\"aria-sort\"] {\n  if (!column.getCanSort()) return undefined\n  const sorted = column.getIsSorted()\n  if (sorted === \"asc\") return \"ascending\"\n  if (sorted === \"desc\") return \"descending\"\n  return \"none\"\n}\n\n/**\n * Sortable header. Dogfoods the seam `Button` (ghost) so it presses and gives a\n * haptic like every other key. The indicator springs when it flips asc⇄desc and\n * jumps instantly under reduced motion — never a dead sort.\n */\nfunction DataTableColumnHeader<TData, TValue>({\n  column,\n  title,\n  align = \"left\",\n  className,\n}: {\n  column: Column<TData, TValue>\n  title: string\n  align?: \"left\" | \"right\"\n  className?: string\n}) {\n  if (!column.getCanSort()) {\n    return (\n      <span\n        className={cn(\n          \"text-muted-foreground text-xs font-medium\",\n          align === \"right\" && \"block text-right\",\n          className\n        )}\n      >\n        {title}\n      </span>\n    )\n  }\n\n  const sorted = column.getIsSorted()\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"sm\"\n      onClick={() => column.toggleSorting(sorted === \"asc\")}\n      className={cn(\n        \"text-muted-foreground hover:text-foreground -mx-2.5 h-8 gap-1.5 px-2.5 text-xs font-medium\",\n        sorted && \"text-foreground\",\n        align === \"right\" && \"ml-auto\",\n        className\n      )}\n    >\n      {title}\n      <SortIndicator sorted={sorted} />\n    </Button>\n  )\n}\n\nfunction SortIndicator({ sorted }: { sorted: false | \"asc\" | \"desc\" }) {\n  const reduceMotion = useReducedMotion()\n\n  if (!sorted) {\n    return <ArrowUpDown className=\"size-3.5 opacity-50\" aria-hidden />\n  }\n  return (\n    <motion.span\n      className=\"flex\"\n      initial={false}\n      // One arrow springs 180° between ascending (points up) and descending\n      // (points down) — a single token flipping, so the two states never look\n      // alike. Jumps instantly under reduced motion.\n      animate={{ rotate: sorted === \"desc\" ? 180 : 0 }}\n      transition={reduceMotion ? reduced.instant : springs.snappy}\n      aria-hidden\n    >\n      <ArrowUp className=\"size-3.5\" />\n    </motion.span>\n  )\n}\n\n/** Toolbar shell — a flex row for a filter input, faceted filters, view options. */\nfunction DataTableToolbar({\n  className,\n  children,\n}: {\n  className?: string\n  children: React.ReactNode\n}) {\n  return (\n    <div\n      data-slot=\"data-table-toolbar\"\n      className={cn(\"flex flex-wrap items-center gap-2\", className)}\n    >\n      {children}\n    </div>\n  )\n}\n\nconst PAGE_SIZES = [10, 20, 30, 50] as const\n\nfunction DataTablePagination<TData>({\n  table,\n  className,\n  ...props\n}: {\n  table: TanstackTable<TData>\n} & Omit<React.ComponentProps<\"div\">, \"children\">) {\n  const { pageIndex, pageSize } = table.getState().pagination\n  const pageCount = Math.max(table.getPageCount(), 1)\n  const selected = table.getFilteredSelectedRowModel().rows.length\n  const total = table.getFilteredRowModel().rows.length\n  // Always include the active pageSize so the trigger never renders blank when\n  // a consumer passes a value outside the default set.\n  const sizeOptions = Array.from(new Set([...PAGE_SIZES, pageSize])).sort(\n    (a, b) => a - b\n  )\n\n  return (\n    <div\n      data-slot=\"data-table-pagination\"\n      className={cn(\n        \"flex flex-col gap-3 px-1 sm:flex-row sm:items-center sm:justify-between\",\n        className\n      )}\n      {...props}\n    >\n      <p className=\"text-muted-foreground text-sm\" aria-live=\"polite\">\n        {selected > 0\n          ? `${selected} of ${total} row${total === 1 ? \"\" : \"s\"} selected`\n          : `${total} row${total === 1 ? \"\" : \"s\"}`}\n      </p>\n\n      <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n        <div className=\"flex items-center gap-2\">\n          <span className=\"text-muted-foreground text-sm\">Rows per page</span>\n          <Select\n            value={String(pageSize)}\n            onValueChange={(value: unknown) => table.setPageSize(Number(value))}\n          >\n            <SelectTrigger className=\"h-8 w-[4.5rem]\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {sizeOptions.map((size) => (\n                <SelectItem key={size} value={String(size)}>\n                  {size}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n\n        <p className=\"text-muted-foreground text-sm\" aria-live=\"polite\">\n          Page {pageIndex + 1} of {pageCount}\n        </p>\n\n        <div className=\"flex items-center gap-1\">\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"size-8\"\n            aria-label=\"Go to first page\"\n            disabled={!table.getCanPreviousPage()}\n            onClick={() => table.setPageIndex(0)}\n          >\n            <ChevronsLeft />\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"size-8\"\n            aria-label=\"Go to previous page\"\n            disabled={!table.getCanPreviousPage()}\n            onClick={() => table.previousPage()}\n          >\n            <ChevronLeft />\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"size-8\"\n            aria-label=\"Go to next page\"\n            disabled={!table.getCanNextPage()}\n            onClick={() => table.nextPage()}\n          >\n            <ChevronRight />\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"icon\"\n            className=\"size-8\"\n            aria-label=\"Go to last page\"\n            disabled={!table.getCanNextPage()}\n            onClick={() => table.setPageIndex(pageCount - 1)}\n          >\n            <ChevronsRight />\n          </Button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\n/**\n * Inline-editable cell. Display mode is a flat, keyboard-reachable trigger; on\n * activate it becomes a debossed entry well (`Input`) — the slot/token rule at\n * cell scale. Enter commits, Escape reverts and returns focus to the trigger.\n * A rejected commit shakes (opacity flash under reduced motion) and fires the\n * error haptic; a good commit ticks. Blur commits, or silently reverts if the\n * draft is invalid (clicking away shouldn't trap focus).\n */\nfunction DataTableEditableCell({\n  value,\n  onCommit,\n  validate,\n  align = \"left\",\n  type = \"text\",\n  label,\n  className,\n}: {\n  value: string | number\n  onCommit: (value: string) => void\n  /** Return false to reject the draft on Enter (shake + error haptic). */\n  validate?: (value: string) => boolean\n  align?: \"left\" | \"right\"\n  type?: \"text\" | \"number\"\n  /** Accessible name for the edit input. */\n  label?: string\n  className?: string\n}) {\n  const original = String(value)\n  const [editing, setEditing] = React.useState(false)\n  const [draft, setDraft] = React.useState(original)\n  const [invalid, setInvalid] = React.useState(false)\n  const reduceMotion = useReducedMotion()\n  const { trigger } = useHaptics()\n  const [scope, animate] = useAnimate()\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n\n  function begin() {\n    setDraft(original)\n    setInvalid(false)\n    setEditing(true)\n  }\n\n  function refocusTrigger() {\n    // let the trigger remount before focusing it.\n    requestAnimationFrame(() => triggerRef.current?.focus())\n  }\n\n  // `refocus` only when the user ended the edit by keyboard (Enter/Escape) —\n  // returning focus to the cell is right there. On blur the user is *leaving*\n  // for somewhere else (Tab, a click), so stealing focus back would fight them.\n  function cancel(refocus: boolean) {\n    setInvalid(false)\n    setEditing(false)\n    if (refocus) refocusTrigger()\n  }\n\n  function commit(fromBlur: boolean) {\n    const next = draft.trim()\n    if (validate && !validate(next)) {\n      // Blur shouldn't trap the user in an invalid cell — revert, let focus go.\n      if (fromBlur) {\n        cancel(false)\n        return\n      }\n      setInvalid(true)\n      trigger(\"error\")\n      animate(\n        scope.current,\n        reduceMotion ? reduced.flash.animate : shake.animate,\n        reduceMotion ? reduced.flash.transition : shake.transition\n      )\n      return\n    }\n    if (next !== original) {\n      trigger(\"tick\")\n      onCommit(next)\n    }\n    setInvalid(false)\n    setEditing(false)\n    if (!fromBlur) refocusTrigger()\n  }\n\n  if (!editing) {\n    return (\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        data-slot=\"data-table-edit-trigger\"\n        onClick={begin}\n        // Dogfoods the foundation's ghost button (base classes + focus ring) on\n        // the native element — the cell owns triggerRef for refocus, so it stays\n        // a plain <button> rather than the Button component (§5A option 2).\n        className={cn(\n          buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n          \"hover:bg-muted/60 h-8 w-full justify-start px-2 font-normal\",\n          align === \"right\" && \"justify-end text-right\",\n          className\n        )}\n      >\n        {original || <span className=\"text-muted-foreground\">—</span>}\n      </button>\n    )\n  }\n\n  return (\n    <motion.div\n      ref={scope}\n      className={cn(\"flex\", align === \"right\" && \"justify-end\")}\n    >\n      <Input\n        // entering edit mode should land the cursor in the field immediately.\n        autoFocus\n        type={type}\n        value={draft}\n        aria-label={label}\n        aria-invalid={invalid || undefined}\n        onChange={(event) => {\n          setDraft(event.target.value)\n          if (invalid) setInvalid(false)\n        }}\n        onBlur={() => commit(true)}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\") {\n            event.preventDefault()\n            commit(false)\n          } else if (event.key === \"Escape\") {\n            event.preventDefault()\n            cancel(true)\n          }\n        }}\n        className={cn(\n          \"h-8 py-0\",\n          align === \"right\" && \"text-right\",\n          invalid && \"border-destructive ring-2 ring-destructive/30\"\n        )}\n      />\n    </motion.div>\n  )\n}\n\n/**\n * Row action menu — a ghost icon key opening a DropdownMenu. Pass the menu\n * items as children; every row's trigger takes a distinct label.\n */\nfunction DataTableRowActions({\n  children,\n  label = \"Open row actions\",\n}: {\n  children: React.ReactNode\n  label?: string\n}) {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"text-muted-foreground data-[popup-open]:bg-accent size-8\"\n            aria-label={label}\n          >\n            <MoreHorizontal className=\"size-4\" />\n          </Button>\n        }\n      />\n      <DropdownMenuContent align=\"end\" className=\"min-w-36\">\n        {children}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n\nexport {\n  DataTable,\n  DataTableColumnHeader,\n  DataTableToolbar,\n  DataTablePagination,\n  DataTableEditableCell,\n  DataTableRowActions,\n  type DataTableProps,\n}\n",
      "type": "registry:ui",
      "target": "components/ui/data-table.tsx"
    }
  ]
}