{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "type": "registry:block",
  "title": "Data Table",
  "description": "Sortable table with loading skeletons, click-through rows and an empty state.",
  "dependencies": [
    "@mantine/core@^9",
    "@tabler/icons-react@^3"
  ],
  "registryDependencies": [
    "@house/empty-state"
  ],
  "files": [
    {
      "path": "registry/blocks/data-table/data-table.tsx",
      "type": "registry:ui",
      "content": "import { Group, Skeleton, Stack, Table, Text, UnstyledButton } from \"@mantine/core\";\nimport { IconChevronDown, IconChevronUp, IconSelector } from \"@tabler/icons-react\";\nimport type { ReactNode } from \"react\";\n\nimport { EmptyState } from \"@/components/ui/empty-state\";\nimport { useDataTable } from \"@/hooks/use-data-table\";\n\nexport interface DataTableColumn<T> {\n  key: keyof T;\n  header: ReactNode;\n  sortable?: boolean;\n  width?: number | string;\n  render?: (row: T) => ReactNode;\n}\n\nexport interface DataTableProps<T extends Record<string, unknown>> {\n  data: T[];\n  columns: DataTableColumn<T>[];\n  loading?: boolean;\n  /** Number of skeleton rows to show while `loading`. */\n  loadingRows?: number;\n  emptyTitle?: string;\n  emptyDescription?: ReactNode;\n  onRowClick?: (row: T) => void;\n  getRowKey?: (row: T, index: number) => string | number;\n}\n\nexport function DataTable<T extends Record<string, unknown>>({\n  data,\n  columns,\n  loading = false,\n  loadingRows = 5,\n  emptyTitle = \"No results\",\n  emptyDescription = \"Try adjusting your filters.\",\n  onRowClick,\n  getRowKey = (_row, index) => index,\n}: DataTableProps<T>) {\n  const { rows, sort, toggleSort } = useDataTable(data);\n\n  if (loading) {\n    return (\n      <Stack gap=\"xs\">\n        {Array.from({ length: loadingRows }, (_, i) => (\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length loading placeholders with no identity and no reordering, so the index IS the stable key.\n          <Skeleton key={i} height={40} radius=\"sm\" />\n        ))}\n      </Stack>\n    );\n  }\n\n  if (!rows.length) {\n    return <EmptyState title={emptyTitle} description={emptyDescription} />;\n  }\n\n  return (\n    // minWidth is the point below which the table scrolls horizontally instead of squeezing\n    // columns into wrapped, multi-line text. 420 was measured against this demo's four columns\n    // (id, name, currency + sort icon, status badge) with a small safety margin; it does not\n    // eliminate horizontal scroll on common phones (390-412px) but is 60px closer than the\n    // previous 480. Consumers adding wider or more columns should raise it — a lower floor\n    // forces wrapping sooner as column count/content grows.\n    <Table.ScrollContainer minWidth={420}>\n      <Table highlightOnHover={Boolean(onRowClick)} verticalSpacing=\"sm\">\n        <Table.Thead>\n          <Table.Tr>\n            {columns.map((column) => (\n              <Table.Th key={String(column.key)} w={column.width}>\n                {column.sortable ? (\n                  <UnstyledButton onClick={() => toggleSort(column.key)}>\n                    <Group gap={4} wrap=\"nowrap\">\n                      <Text size=\"sm\" fw={600}>\n                        {column.header}\n                      </Text>\n                      <SortIcon active={sort.key === column.key} direction={sort.direction} />\n                    </Group>\n                  </UnstyledButton>\n                ) : (\n                  <Text size=\"sm\" fw={600}>\n                    {column.header}\n                  </Text>\n                )}\n              </Table.Th>\n            ))}\n          </Table.Tr>\n        </Table.Thead>\n\n        <Table.Tbody>\n          {rows.map((row, index) => (\n            <Table.Tr\n              key={getRowKey(row, index)}\n              onClick={onRowClick ? () => onRowClick(row) : undefined}\n              style={onRowClick ? { cursor: \"pointer\" } : undefined}\n            >\n              {columns.map((column) => (\n                <Table.Td key={String(column.key)}>\n                  {column.render ? column.render(row) : String(row[column.key] ?? \"—\")}\n                </Table.Td>\n              ))}\n            </Table.Tr>\n          ))}\n        </Table.Tbody>\n      </Table>\n    </Table.ScrollContainer>\n  );\n}\n\nfunction SortIcon({ active, direction }: { active: boolean; direction: \"asc\" | \"desc\" }) {\n  if (!active) return <IconSelector size={14} opacity={0.5} />;\n  return direction === \"asc\" ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />;\n}\n"
    },
    {
      "path": "registry/blocks/data-table/use-data-table.ts",
      "type": "registry:hook",
      "content": "import { useMemo, useState } from \"react\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface SortState<T> {\n  key: keyof T | null;\n  direction: SortDirection;\n}\n\n/**\n * Client-side sorting for `<DataTable />`.\n *\n * Swap this out for a server-driven implementation by keeping the same return\n * shape — the table component only cares about `rows`, `sort` and `toggleSort`.\n */\nexport function useDataTable<T extends Record<string, unknown>>(data: T[]) {\n  const [sort, setSort] = useState<SortState<T>>({ key: null, direction: \"asc\" });\n\n  const rows = useMemo(() => {\n    if (!sort.key) return data;\n\n    const key = sort.key;\n    return [...data].sort((a, b) => {\n      const av = a[key];\n      const bv = b[key];\n\n      if (av === bv) return 0;\n      if (av == null) return 1;\n      if (bv == null) return -1;\n\n      const result =\n        typeof av === \"number\" && typeof bv === \"number\"\n          ? av - bv\n          : String(av).localeCompare(String(bv));\n\n      return sort.direction === \"asc\" ? result : -result;\n    });\n  }, [data, sort]);\n\n  function toggleSort(key: keyof T) {\n    setSort((current) =>\n      current.key === key\n        ? { key, direction: current.direction === \"asc\" ? \"desc\" : \"asc\" }\n        : { key, direction: \"asc\" },\n    );\n  }\n\n  return { rows, sort, toggleSort };\n}\n"
    }
  ],
  "meta": {
    "mantine": {
      "requires": ">=9",
      "props": {
        "DataTable": [
          {
            "name": "data",
            "type": "T[]",
            "required": true,
            "description": "Rows to render. Sorting a column header does not mutate this array — internally the table sorts a copy of it and re-renders with the copy."
          },
          {
            "name": "columns",
            "type": "DataTableColumn<T>[]",
            "required": true,
            "description": "Column definitions controlling header text, width, sortability and per-cell rendering."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows skeleton placeholder rows instead of the table body while true."
          },
          {
            "name": "loadingRows",
            "type": "number",
            "default": "5",
            "description": "Number of skeleton rows rendered while loading."
          },
          {
            "name": "emptyTitle",
            "type": "string",
            "default": "\"No results\"",
            "description": "Title shown by the empty state when there are no rows to display."
          },
          {
            "name": "emptyDescription",
            "type": "ReactNode",
            "default": "\"Try adjusting your filters.\"",
            "description": "Description shown by the empty state when there are no rows to display."
          },
          {
            "name": "onRowClick",
            "type": "(row: T) => void",
            "description": "Makes rows clickable, enabling hover highlighting and a pointer cursor, and fires with the clicked row."
          },
          {
            "name": "getRowKey",
            "type": "(row: T, index: number) => string | number",
            "default": "(_row, index) => index",
            "description": "Derives the React key for each row; defaults to the row's index."
          }
        ]
      },
      "usage": {
        "path": "registry/blocks/data-table/data-table.usage.tsx",
        "content": "import { Badge } from \"@mantine/core\";\nimport { DataTable } from \"@/components/ui/data-table\";\n\ninterface Invoice extends Record<string, unknown> {\n  id: string;\n  client: string;\n  amount: number;\n  status: \"paid\" | \"overdue\" | \"pending\";\n}\n\nconst invoices: Invoice[] = [\n  { id: \"INV-1042\", client: \"Bluefin Studio\", amount: 1280, status: \"paid\" },\n  { id: \"INV-1043\", client: \"Northwind Labs\", amount: 640, status: \"pending\" },\n  { id: \"INV-1044\", client: \"Harbor & Co.\", amount: 2150, status: \"overdue\" },\n];\n\nconst statusColor: Record<Invoice[\"status\"], string> = {\n  paid: \"teal\",\n  pending: \"yellow\",\n  overdue: \"red\",\n};\n\nexport function InvoicesTable() {\n  return (\n    <DataTable<Invoice>\n      data={invoices}\n      columns={[\n        { key: \"id\", header: \"Invoice\", sortable: true },\n        { key: \"client\", header: \"Client\", sortable: true },\n        {\n          key: \"amount\",\n          header: \"Amount\",\n          sortable: true,\n          render: (row) => `$${row.amount.toFixed(2)}`,\n        },\n        {\n          key: \"status\",\n          header: \"Status\",\n          render: (row) => <Badge color={statusColor[row.status]}>{row.status}</Badge>,\n        },\n      ]}\n      onRowClick={(row) => console.log(\"open invoice\", row.id)}\n      emptyTitle=\"No invoices yet\"\n    />\n  );\n}\n"
      },
      "themeFragment": {
        "path": "registry/lib/data-table.theme.ts",
        "content": "import { createTheme, Skeleton, Table } from \"@mantine/core\";\n\n/**\n * Theme fragment for the `data-table` item.\n *\n * Declared as `themeFragment` in manteen.registry.json. The Mantine client\n * merges it into the project's existing theme via tools/merge-theme, so two\n * items can both contribute `theme.components` entries without clobbering.\n */\nexport const theme = createTheme({\n  components: {\n    Table: Table.extend({\n      defaultProps: {\n        verticalSpacing: \"sm\",\n        highlightOnHover: true,\n      },\n    }),\n    Skeleton: Skeleton.extend({\n      defaultProps: {\n        radius: \"sm\",\n      },\n    }),\n  },\n});\n"
      }
    }
  }
}
