{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/data-grid",
  "title": "Data Grid",
  "description": "Advanced data grid with sorting, filtering, pagination, and cell editing",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-style.json",
    "https://termcn.dev/r/ink/terminal-text.json",
    "https://termcn.dev/r/ink/types.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/data-grid.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useEffect, useMemo, useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport type { InteractionProps } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveBorderStyle } from \"@/lib/terminal-style\";\nimport {\n  padToTerminalWidth,\n  removeGraphemeBefore,\n  terminalWidth,\n  truncateToTerminalWidth,\n} from \"@/lib/terminal-text\";\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> extends InteractionProps {\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  \"aria-label\"?: string;\n  getRowKey?: (row: T, index: number) => React.Key;\n}\n\nconst pad = (\n  str: string,\n  width: number,\n  align: \"left\" | \"right\" | \"center\" = \"left\"\n): string => {\n  const s = truncateToTerminalWidth(String(str), width);\n  const diff = Math.max(0, width - terminalWidth(s));\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 padToTerminalWidth(s, width);\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  filterPlaceholder = \"Type to filter\",\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"Data grid\",\n  getRowKey,\n}: DataGridProps<T>) => {\n  const unicode = useUnicode();\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 = terminalWidth(col.header);\n        const dataLen = Math.max(\n          0,\n          ...data.map((row) => terminalWidth(String(row[col.key] ?? \"\")))\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  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (filterMode) {\n        if (key.escape) {\n          setFilterMode(false);\n        } else if (key.return) {\n          setFilterMode(false);\n        } else if (key.backspace || key.delete) {\n          setFilter(\n            (f) => removeGraphemeBefore(f, Number.POSITIVE_INFINITY).value\n          );\n        } else if (input && !key.ctrl && !key.meta) {\n          setFilter((f) => f + input);\n        }\n        return;\n      }\n\n      if (key.upArrow) {\n        setSelectedRow((r) => Math.max(0, r - 1));\n      } else if (key.downArrow) {\n        setSelectedRow((r) => Math.min(pageData.length - 1, r + 1));\n      } else if (key.home) {\n        setSelectedRow(0);\n      } else if (key.end) {\n        setSelectedRow(Math.max(0, pageData.length - 1));\n      } else if (key.return || input === \" \") {\n        if (pageData[selectedRow]) {\n          onRowSelect?.(pageData[selectedRow]);\n        }\n      } else if (key.pageDown || input === \"n\") {\n        setPage((p) => Math.min(totalPages - 1, p + 1));\n        setSelectedRow(0);\n      } else if (key.pageUp || input === \"p\") {\n        setPage((p) => Math.max(0, p - 1));\n        setSelectedRow(0);\n      } else if (input === \"/\") {\n        setFilterMode(true);\n      } else if (input === \"s\" && sortKey === null) {\n        setSortKey(columns[0]?.key ?? null);\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  useEffect(() => {\n    setSelectedRow((row) => Math.min(row, Math.max(0, pageData.length - 1)));\n  }, [pageData.length]);\n\n  useEffect(() => {\n    setPage((current) => Math.min(current, totalPages - 1));\n  }, [totalPages]);\n\n  const colSep = unicode ? \" │ \" : \" | \";\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 flexDirection=\"row\">\n        {rowNumStr && <Text dimColor>{rowNumStr}</Text>}\n        <Text\n          backgroundColor={isSelected ? theme.colors.primary : undefined}\n          color={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 =\n      sortDir === \"asc\" ? (unicode ? \" ↑\" : \" up\") : unicode ? \" ↓\" : \" down\";\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\n      flexDirection=\"column\"\n      aria-role=\"table\"\n      aria-state={{ disabled: disabled || undefined }}\n    >\n      <Text\n        aria-label={`${ariaLabel}. Page ${page + 1} of ${totalPages}. ${sorted.length} rows.`}\n      >\n        {\"\"}\n      </Text>\n      {isFocused && pageData[selectedRow] && (\n        <Text\n          aria-label={`Selected row ${page * pageSize + selectedRow + 1}: ${columns\n            .map(\n              (column) =>\n                `${column.header}: ${String(pageData[selectedRow]?.[column.key] ?? \"\")}`\n            )\n            .join(\", \")}`}\n        >\n          {\"\"}\n        </Text>\n      )}\n      {(filterMode || filter) && (\n        <Box flexDirection=\"row\" marginBottom={1}>\n          <Text color={theme.colors.primary}>{\"Filter: \"}</Text>\n          <Text>{filter}</Text>\n          {filterMode && (\n            <Text aria-hidden color={theme.colors.focusRing}>\n              {unicode ? \"█\" : \"|\"}\n            </Text>\n          )}\n          {!filter && <Text aria-label={filterPlaceholder}>{\"\"}</Text>}\n        </Box>\n      )}\n\n      <Box\n        aria-hidden\n        borderStyle={resolveBorderStyle(borderStyle, unicode)}\n        borderColor={resolvedBorderColor}\n        flexDirection=\"column\"\n      >\n        <Box flexDirection=\"row\" paddingX={1}>\n          {rowNumHeader && <Text dimColor>{rowNumHeader}</Text>}\n          <Text bold color={theme.colors.primary}>\n            {headerCells.join(colSep)}\n          </Text>\n        </Box>\n        <Text color={resolvedBorderColor}>\n          {(unicode ? \"─\" : \"-\").repeat(headerCells.join(colSep).length + 2)}\n        </Text>\n\n        {pageData.length > 0 ? (\n          pageData.map((row, i) => (\n            <Box key={getRowKey?.(row, i) ?? i} paddingX={1}>\n              {renderRow(row, i, i === selectedRow)}\n            </Box>\n          ))\n        ) : (\n          <Box paddingX={1}>\n            <Text dimColor>No data</Text>\n          </Box>\n        )}\n      </Box>\n\n      {pageData.map((row, rowIdx) => (\n        <Text\n          key={`accessible-${String(getRowKey?.(row, rowIdx) ?? rowIdx)}`}\n          aria-label={`Row ${page * pageSize + rowIdx + 1}: ${columns\n            .map(\n              (column) => `${column.header}: ${String(row[column.key] ?? \"\")}`\n            )\n            .join(\", \")}`}\n        >\n          {\"\"}\n        </Text>\n      ))}\n\n      <Box flexDirection=\"row\" gap={2} marginTop={1}>\n        <Text dimColor>\n          {`Page ${page + 1}/${totalPages} (${sorted.length} rows)`}\n        </Text>\n        <Text aria-hidden dimColor>\n          {unicode\n            ? \"↑↓ navigate PgUp/PgDn page / filter Enter select\"\n            : \"up/down navigate PgUp/PgDn page / filter Enter select\"}\n        </Text>\n      </Box>\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:ui"
}