{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "opentui/data-grid",
  "title": "Data Grid",
  "description": "Advanced data grid with sorting, filtering, pagination, and cell editing",
  "dependencies": [
    "@opentui/react"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/opentui/use-theme.json"
  ],
  "files": [
    {
      "path": "registry/opentui/ui/data-grid.tsx",
      "content": "/* @jsxImportSource @opentui/react */\nimport { useKeyboard } from \"@opentui/react\";\nimport { useState, useMemo } from \"react\";\n\nimport { useTheme } from \"@/hooks/use-theme\";\nimport type { BorderStyle } from \"@/components/ui/types\";\n\nexport interface DataGridColumn<T = Record<string, unknown>> {\n  key: keyof T & string;\n  header: string;\n  width?: number;\n  align?: \"left\" | \"right\" | \"center\";\n  render?: (value: unknown, row: T) => string;\n  filterable?: boolean;\n  sortable?: boolean;\n}\n\nexport interface DataGridProps<\n  T extends Record<string, unknown> = Record<string, unknown>,\n> {\n  data: T[];\n  columns: DataGridColumn<T>[];\n  pageSize?: number;\n  onRowSelect?: (row: T) => void;\n  onCellEdit?: (row: T, key: string, value: string) => void;\n  borderColor?: string;\n  borderStyle?: BorderStyle;\n  showRowNumbers?: boolean;\n  filterPlaceholder?: string;\n}\n\nconst pad = (\n  str: string,\n  width: number,\n  align: \"left\" | \"right\" | \"center\" = \"left\"\n): string => {\n  const s = String(str);\n  if (s.length >= width) {\n    return s.slice(0, width);\n  }\n  const diff = width - s.length;\n  if (align === \"right\") {\n    return \" \".repeat(diff) + s;\n  }\n  if (align === \"center\") {\n    const left = Math.floor(diff / 2);\n    return \" \".repeat(left) + `${s} `.repeat(diff - left);\n  }\n  return `${s} `.repeat(diff);\n};\n\nexport const DataGrid = <\n  T extends Record<string, unknown> = Record<string, unknown>,\n>({\n  data,\n  columns,\n  pageSize = 10,\n  onRowSelect,\n  borderColor,\n  borderStyle = \"single\",\n  showRowNumbers = false,\n}: DataGridProps<T>) => {\n  const theme = useTheme();\n  const [selectedRow, setSelectedRow] = useState(0);\n  const [page, setPage] = useState(0);\n  const [sortKey, setSortKey] = useState<string | null>(null);\n  const [sortDir, _setSortDir] = useState<\"asc\" | \"desc\">(\"asc\");\n  const [filter, setFilter] = useState(\"\");\n  const [filterMode, setFilterMode] = useState(false);\n\n  const resolvedBorderColor = borderColor ?? theme.colors.border;\n\n  const colWidths = useMemo(\n    () =>\n      columns.map((col) => {\n        if (col.width) {\n          return col.width;\n        }\n        const headerLen = col.header.length;\n        const dataLen = Math.max(\n          ...data.map((row) => String(row[col.key] ?? \"\").length)\n        );\n        return Math.max(headerLen, dataLen, 6);\n      }),\n    [columns, data]\n  );\n\n  const filtered = useMemo(() => {\n    if (!filter) {\n      return data;\n    }\n    const q = filter.toLowerCase();\n    return data.filter((row) =>\n      columns.some((col) =>\n        String(row[col.key] ?? \"\")\n          .toLowerCase()\n          .includes(q)\n      )\n    );\n  }, [data, filter, columns]);\n\n  const sorted = useMemo(() => {\n    if (!sortKey) {\n      return filtered;\n    }\n    return [...filtered].toSorted((a, b) => {\n      const av = String(a[sortKey] ?? \"\");\n      const bv = String(b[sortKey] ?? \"\");\n      const cmp = av.localeCompare(bv);\n      return sortDir === \"asc\" ? cmp : -cmp;\n    });\n  }, [filtered, sortKey, sortDir]);\n\n  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));\n  const pageData = sorted.slice(page * pageSize, (page + 1) * pageSize);\n\n  useKeyboard((key) => {\n    if (filterMode) {\n      if (key.name === \"escape\") {\n        setFilterMode(false);\n      } else if (key.name === \"return\") {\n        setFilterMode(false);\n      } else if (key.name === \"backspace\" || key.name === \"delete\") {\n        setFilter((f) => f.slice(0, -1));\n      } else if (key.name.length === 1 && !key.ctrl && !key.meta) {\n        setFilter((f) => f + key.name);\n      }\n      return;\n    }\n    if (key.name === \"up\") {\n      setSelectedRow((r) => Math.max(0, r - 1));\n    } else if (key.name === \"down\") {\n      setSelectedRow((r) => Math.min(pageData.length - 1, r + 1));\n    } else if (key.name === \"return\" || key.name === \"space\") {\n      if (pageData[selectedRow]) {\n        onRowSelect?.(pageData[selectedRow]);\n      }\n    } else if (key.name === \"pagedown\" || key.name === \"n\") {\n      setPage((p) => Math.min(totalPages - 1, p + 1));\n      setSelectedRow(0);\n    } else if (key.name === \"pageup\" || key.name === \"p\") {\n      setPage((p) => Math.max(0, p - 1));\n      setSelectedRow(0);\n    } else if (key.name === \"/\") {\n      setFilterMode(true);\n    } else if (key.name === \"s\" && sortKey === null) {\n      setSortKey(columns[0]?.key ?? null);\n    }\n  });\n\n  const colSep = \" │ \";\n\n  const renderRow = (row: T, rowIdx: number, isSelected: boolean) => {\n    const cells = columns.map((col, ci) => {\n      const raw = col.render\n        ? col.render(row[col.key], row)\n        : String(row[col.key] ?? \"\");\n      return pad(raw, colWidths[ci], col.align);\n    });\n\n    const rowNumStr = showRowNumbers\n      ? `${String(page * pageSize + rowIdx + 1).padStart(3)} `\n      : \"\";\n\n    return (\n      <box key={rowIdx} flexDirection=\"row\">\n        {rowNumStr && <text fg=\"#666\">{rowNumStr}</text>}\n        <text\n          bg={isSelected ? theme.colors.primary : undefined}\n          fg={isSelected ? theme.colors.background : undefined}\n        >\n          {cells.join(colSep)}\n        </text>\n      </box>\n    );\n  };\n\n  const headerCells = columns.map((col, ci) => {\n    const isSorted = sortKey === col.key;\n    const sortArrow = sortDir === \"asc\" ? \" ↑\" : \" ↓\";\n    const indicator = isSorted ? sortArrow : \"\";\n    return pad(col.header + indicator, colWidths[ci], col.align);\n  });\n\n  const rowNumHeader = showRowNumbers ? \"    \" : \"\";\n\n  return (\n    <box flexDirection=\"column\">\n      {(filterMode || filter) && (\n        <box flexDirection=\"row\" marginBottom={1}>\n          <text fg={theme.colors.primary}>{\"Filter: \"}</text>\n          <text>{filter}</text>\n          {filterMode && <text fg={theme.colors.focusRing}>█</text>}\n        </box>\n      )}\n\n      <box\n        borderStyle={borderStyle}\n        borderColor={resolvedBorderColor}\n        flexDirection=\"column\"\n      >\n        <box flexDirection=\"row\" paddingLeft={1} paddingRight={1}>\n          {rowNumHeader && <text fg=\"#666\">{rowNumHeader}</text>}\n          <text fg={theme.colors.primary}>\n            <b>{headerCells.join(colSep)}</b>\n          </text>\n        </box>\n        <text fg={resolvedBorderColor}>\n          {\"─\".repeat(headerCells.join(colSep).length + 2)}\n        </text>\n\n        {pageData.length > 0 ? (\n          pageData.map((row, i) => (\n            <box key={i} paddingLeft={1} paddingRight={1}>\n              {renderRow(row, i, i === selectedRow)}\n            </box>\n          ))\n        ) : (\n          <box paddingLeft={1} paddingRight={1}>\n            <text fg=\"#666\">No data</text>\n          </box>\n        )}\n      </box>\n\n      <box flexDirection=\"row\" gap={2} marginTop={1}>\n        <text fg=\"#666\">{`Page ${page + 1}/${totalPages} (${sorted.length} rows)`}</text>\n        <text fg=\"#666\">↑↓ navigate n/p page / filter Enter select</text>\n      </box>\n    </box>\n  );\n};\n",
      "type": "registry:ui",
      "target": "components/ui/data-grid.tsx"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:ui"
}