{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gummy-data-table",
  "type": "registry:ui",
  "title": "Gummy Data Table",
  "description": "Generic typed Data Table with native table semantics, filtering, sorting, pagination, row selection, empty state, and controlled selection.",
  "registryDependencies": [
    "https://gummyui.dev/r/gummy-table.json",
    "https://gummyui.dev/r/gummy-primitives-styles.json"
  ],
  "files": [
    {
      "path": "components/ui/GummyDataTable.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  GummyTable,\n  GummyTableBody,\n  GummyTableCaption,\n  GummyTableCell,\n  GummyTableHead,\n  GummyTableHeader,\n  GummyTableRow,\n} from \"./GummyTable\";\n\nfunction joinClassNames(...values: Array<string | false | null | undefined>) {\n  return values.filter(Boolean).join(\" \");\n}\n\nexport type GummyDataTableColumn<Row> = {\n  id: string;\n  header: React.ReactNode;\n  cell: (row: Row) => React.ReactNode;\n  sortValue?: (row: Row) => string | number | Date | null | undefined;\n  filterValue?: (row: Row) => string;\n  align?: \"start\" | \"center\" | \"end\";\n};\n\nexport type GummyDataTableSort = {\n  columnId: string;\n  direction: \"ascending\" | \"descending\";\n};\n\nexport type GummyDataTableProps<Row> = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onSelect\"\n> & {\n  rows: readonly Row[];\n  columns: readonly GummyDataTableColumn<Row>[];\n  getRowId: (row: Row) => string;\n  caption: string;\n  filterLabel?: string;\n  filterPlaceholder?: string;\n  pageSize?: number;\n  initialSort?: GummyDataTableSort;\n  selectable?: boolean;\n  selectedRowIds?: readonly string[];\n  defaultSelectedRowIds?: readonly string[];\n  onSelectedRowIdsChange?: (rowIds: string[]) => void;\n  getRowLabel?: (row: Row) => string;\n  emptyMessage?: string;\n};\n\nfunction compareValues(\n  first: string | number | Date | null | undefined,\n  second: string | number | Date | null | undefined,\n) {\n  if (first == null && second == null) return 0;\n  if (first == null) return 1;\n  if (second == null) return -1;\n  if (first instanceof Date && second instanceof Date) return first.getTime() - second.getTime();\n  if (typeof first === \"number\" && typeof second === \"number\") return first - second;\n  return String(first).localeCompare(String(second), undefined, {\n    numeric: true,\n    sensitivity: \"base\",\n  });\n}\n\nfunction GummyDataTableInner<Row>(\n  {\n    rows,\n    columns,\n    getRowId,\n    caption,\n    filterLabel = \"Filter rows\",\n    filterPlaceholder = \"Search…\",\n    pageSize = 10,\n    initialSort,\n    selectable = false,\n    selectedRowIds,\n    defaultSelectedRowIds = [],\n    onSelectedRowIdsChange,\n    getRowLabel = (row) => getRowId(row),\n    emptyMessage = \"No matching rows.\",\n    className,\n    ...props\n  }: GummyDataTableProps<Row>,\n  ref: React.ForwardedRef<HTMLDivElement>,\n) {\n  const safePageSize = Math.max(1, Math.floor(pageSize));\n  const [query, setQuery] = React.useState(\"\");\n  const [sort, setSort] = React.useState<GummyDataTableSort | undefined>(initialSort);\n  const [page, setPage] = React.useState(0);\n  const [internalSelection, setInternalSelection] = React.useState(\n    () => new Set(defaultSelectedRowIds),\n  );\n  const selection = React.useMemo(\n    () => new Set(selectedRowIds ?? Array.from(internalSelection)),\n    [internalSelection, selectedRowIds],\n  );\n  const normalizedQuery = query.trim().toLocaleLowerCase();\n  const filteredRows = React.useMemo(\n    () => rows.filter((row) => {\n      if (!normalizedQuery) return true;\n      return columns.some((column) => {\n        const value = column.filterValue?.(row)\n          ?? column.sortValue?.(row)\n          ?? column.cell(row);\n        return typeof value === \"string\" || typeof value === \"number\"\n          ? String(value).toLocaleLowerCase().includes(normalizedQuery)\n          : false;\n      });\n    }),\n    [columns, normalizedQuery, rows],\n  );\n  const sortedRows = React.useMemo(() => {\n    if (!sort) return filteredRows;\n    const column = columns.find((candidate) => candidate.id === sort.columnId);\n    if (!column?.sortValue) return filteredRows;\n    const multiplier = sort.direction === \"ascending\" ? 1 : -1;\n    return [...filteredRows].sort(\n      (first, second) => compareValues(column.sortValue?.(first), column.sortValue?.(second)) * multiplier,\n    );\n  }, [columns, filteredRows, sort]);\n  const pageCount = Math.max(1, Math.ceil(sortedRows.length / safePageSize));\n  const currentPage = Math.min(page, pageCount - 1);\n  const visibleRows = sortedRows.slice(\n    currentPage * safePageSize,\n    currentPage * safePageSize + safePageSize,\n  );\n  const visibleIds = visibleRows.map(getRowId);\n  const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selection.has(id));\n  const someVisibleSelected = visibleIds.some((id) => selection.has(id)) && !allVisibleSelected;\n\n  function updateSelection(next: Set<string>) {\n    if (selectedRowIds === undefined) setInternalSelection(next);\n    onSelectedRowIdsChange?.(Array.from(next));\n  }\n\n  function toggleRow(id: string) {\n    const next = new Set(selection);\n    if (next.has(id)) next.delete(id);\n    else next.add(id);\n    updateSelection(next);\n  }\n\n  function toggleVisibleRows() {\n    const next = new Set(selection);\n    if (allVisibleSelected) visibleIds.forEach((id) => next.delete(id));\n    else visibleIds.forEach((id) => next.add(id));\n    updateSelection(next);\n  }\n\n  function toggleSort(column: GummyDataTableColumn<Row>) {\n    if (!column.sortValue) return;\n    setSort((current) => current?.columnId === column.id\n      ? {\n          columnId: column.id,\n          direction: current.direction === \"ascending\" ? \"descending\" : \"ascending\",\n        }\n      : { columnId: column.id, direction: \"ascending\" });\n    setPage(0);\n  }\n\n  return (\n    <div {...props} ref={ref} className={joinClassNames(\"gummy-data-table\", className)}>\n      <div className=\"gummy-data-table__toolbar\">\n        <label>\n          <span>{filterLabel}</span>\n          <input\n            type=\"search\"\n            value={query}\n            placeholder={filterPlaceholder}\n            onChange={(event) => {\n              setQuery(event.currentTarget.value);\n              setPage(0);\n            }}\n          />\n        </label>\n        <span aria-live=\"polite\">{filteredRows.length} {filteredRows.length === 1 ? \"row\" : \"rows\"}</span>\n      </div>\n      <div className=\"gummy-table-wrap\">\n        <GummyTable>\n          <GummyTableCaption>{caption}</GummyTableCaption>\n          <GummyTableHeader>\n            <GummyTableRow>\n              {selectable ? (\n                <GummyTableHead className=\"gummy-data-table__selection\">\n                  <input\n                    type=\"checkbox\"\n                    aria-label={allVisibleSelected ? \"Deselect visible rows\" : \"Select visible rows\"}\n                    checked={allVisibleSelected}\n                    ref={(node) => {\n                      if (node) node.indeterminate = someVisibleSelected;\n                    }}\n                    onChange={toggleVisibleRows}\n                  />\n                </GummyTableHead>\n              ) : null}\n              {columns.map((column) => {\n                const activeSort = sort?.columnId === column.id ? sort.direction : undefined;\n                return (\n                  <GummyTableHead\n                    key={column.id}\n                    aria-sort={activeSort ?? (column.sortValue ? \"none\" : undefined)}\n                    data-align={column.align}\n                  >\n                    {column.sortValue ? (\n                      <button type=\"button\" onClick={() => toggleSort(column)}>\n                        <span>{column.header}</span>\n                        <span aria-hidden=\"true\">\n                          {activeSort === \"ascending\" ? \"↑\" : activeSort === \"descending\" ? \"↓\" : \"↕\"}\n                        </span>\n                      </button>\n                    ) : column.header}\n                  </GummyTableHead>\n                );\n              })}\n            </GummyTableRow>\n          </GummyTableHeader>\n          <GummyTableBody>\n            {visibleRows.length ? visibleRows.map((row) => {\n              const rowId = getRowId(row);\n              return (\n                <GummyTableRow key={rowId} data-selected={selection.has(rowId) || undefined}>\n                  {selectable ? (\n                    <GummyTableCell className=\"gummy-data-table__selection\">\n                      <input\n                        type=\"checkbox\"\n                        aria-label={`Select ${getRowLabel(row)}`}\n                        checked={selection.has(rowId)}\n                        onChange={() => toggleRow(rowId)}\n                      />\n                    </GummyTableCell>\n                  ) : null}\n                  {columns.map((column) => (\n                    <GummyTableCell key={column.id} data-align={column.align}>\n                      {column.cell(row)}\n                    </GummyTableCell>\n                  ))}\n                </GummyTableRow>\n              );\n            }) : (\n              <GummyTableRow>\n                <GummyTableCell colSpan={columns.length + (selectable ? 1 : 0)} className=\"gummy-data-table__empty\">\n                  {emptyMessage}\n                </GummyTableCell>\n              </GummyTableRow>\n            )}\n          </GummyTableBody>\n        </GummyTable>\n      </div>\n      <div className=\"gummy-data-table__pagination\" aria-label=\"Table pagination\">\n        <span>Page {currentPage + 1} of {pageCount}</span>\n        <div>\n          <button type=\"button\" disabled={currentPage === 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>\n            Previous\n          </button>\n          <button type=\"button\" disabled={currentPage >= pageCount - 1} onClick={() => setPage((value) => Math.min(pageCount - 1, value + 1))}>\n            Next\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport const GummyDataTable = React.forwardRef(GummyDataTableInner) as <Row>(\n  props: GummyDataTableProps<Row> & React.RefAttributes<HTMLDivElement>,\n) => React.ReactElement;\n"
    }
  ]
}
