{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "title": "Data table",
  "description": "The generic data table behind compare, organizer, scoreboards, and leaderboards.",
  "registryDependencies": [
    "@engram/cn",
    "@engram/tokens"
  ],
  "files": [
    {
      "path": "src/components/data-table.tsx",
      "content": "\"use client\";\n\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useId,\n  useMemo,\n  useState,\n} from \"react\";\nimport { cn } from \"../lib/cn.js\";\n\nexport type SortDir = \"asc\" | \"desc\";\nexport interface SortState {\n  key: string;\n  dir: SortDir;\n}\n\nexport interface DataTableColumn<Row> {\n  /** Stable column id (used for sort state + React keys). */\n  key: string;\n  /** Header content. */\n  header: ReactNode;\n  /** Cell renderer; defaults to the `accessor` value. */\n  renderCell?: (row: Row) => ReactNode;\n  /** Value accessor — used for the default cell text and for sorting. */\n  accessor?: (row: Row) => string | number | null | undefined;\n  align?: \"left\" | \"right\" | \"center\";\n  /** Allow sorting on this column. */\n  sortable?: boolean;\n  /** Fixed column width (px or any CSS width). */\n  width?: number | string;\n  /**\n   * Numeric accessor for heat shading — when the table's `heat` is on, cells are\n   * tinted from worst→best across this column's values.\n   */\n  heat?: (row: Row) => number | null | undefined;\n  /** Lower values are better for heat/rank (e.g. charge time, reload). */\n  heatInvert?: boolean;\n}\n\nexport interface DataTableProps<Row> {\n  columns: DataTableColumn<Row>[];\n  rows: Row[];\n  /** Stable row id (also the selection key). */\n  rowKey: (row: Row, index: number) => string;\n  /** Controlled sort; omit to let the table manage it. */\n  sort?: SortState;\n  /** Initial sort when uncontrolled. */\n  defaultSort?: SortState;\n  onSortChange?: (sort: SortState) => void;\n  /** Controlled multi-selection (set of row keys); omit to manage internally. */\n  selection?: Set<string>;\n  onSelectionChange?: (selection: Set<string>) => void;\n  /** Show the leading select-checkbox column. */\n  selectable?: boolean;\n  /** Stick the header to the top on scroll. */\n  sticky?: boolean;\n  /** Tint cells by each column's `heat` accessor (best/worst shading). */\n  heat?: boolean;\n  onRowClick?: (row: Row) => void;\n  /** Empty-state content when there are no rows. */\n  empty?: ReactNode;\n  className?: string;\n}\n\nconst ALIGN: Record<NonNullable<DataTableColumn<unknown>[\"align\"]>, string> = {\n  left: \"text-left\",\n  right: \"text-right\",\n  center: \"text-center\",\n};\n\n/** Worst→best background tint for a normalized 0..1 heat value. */\nfunction heatStyle(norm: number): CSSProperties {\n  // 0 = worst (bad), 0.5 = neutral, 1 = best (ok). Subtle alpha so text stays legible.\n  const color = norm >= 0.5 ? \"var(--engram-ok)\" : \"var(--engram-bad)\";\n  const strength = Math.abs(norm - 0.5) * 2; // 0 at mid, 1 at extremes\n  return {\n    background: `color-mix(in oklch, ${color} ${Math.round(strength * 22)}%, transparent)`,\n  };\n}\n\n/**\n * The generic data table behind compare, organizer, scoreboards, and\n * leaderboards. Columns declare their own `renderCell`/`accessor`/`align`;\n * sorting, multi-selection, sticky header, and per-column heat shading are all\n * built in and work controlled or uncontrolled. Generic over the row type.\n */\nexport function DataTable<Row>({\n  columns,\n  rows,\n  rowKey,\n  sort: sortProp,\n  defaultSort,\n  onSortChange,\n  selection: selectionProp,\n  onSelectionChange,\n  selectable = false,\n  sticky = false,\n  heat = false,\n  onRowClick,\n  empty,\n  className,\n}: DataTableProps<Row>) {\n  const [sortInternal, setSortInternal] = useState<SortState | undefined>(\n    defaultSort,\n  );\n  const sort = sortProp ?? sortInternal;\n  const [selInternal, setSelInternal] = useState<Set<string>>(new Set());\n  const selection = selectionProp ?? selInternal;\n  const headId = useId();\n\n  const colByKey = useMemo(\n    () => new Map(columns.map((c) => [c.key, c])),\n    [columns],\n  );\n\n  function applySort(key: string) {\n    const col = colByKey.get(key);\n    if (!col?.sortable) return;\n    const dir: SortDir =\n      sort?.key === key && sort.dir === \"asc\" ? \"desc\" : \"asc\";\n    const next = { key, dir };\n    if (sortProp === undefined) setSortInternal(next);\n    onSortChange?.(next);\n  }\n\n  function setSelection(next: Set<string>) {\n    if (selectionProp === undefined) setSelInternal(next);\n    onSelectionChange?.(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    setSelection(next);\n  }\n\n  const keyed = useMemo(\n    () => rows.map((row, i) => ({ row, id: rowKey(row, i) })),\n    [rows, rowKey],\n  );\n\n  const sorted = useMemo(() => {\n    if (!sort) return keyed;\n    const col = colByKey.get(sort.key);\n    if (!col?.accessor) return keyed;\n    const dir = sort.dir === \"asc\" ? 1 : -1;\n    return [...keyed].sort((a, b) => {\n      const av = col.accessor?.(a.row);\n      const bv = col.accessor?.(b.row);\n      if (av == null && bv == null) return 0;\n      if (av == null) return 1; // nulls sink\n      if (bv == null) return -1;\n      if (typeof av === \"number\" && typeof bv === \"number\") {\n        return (av - bv) * dir;\n      }\n      return String(av).localeCompare(String(bv)) * dir;\n    });\n  }, [keyed, sort, colByKey]);\n\n  // Per-column heat ranges (min/max) over the current rows.\n  const heatRanges = useMemo(() => {\n    if (!heat) return new Map<string, { min: number; max: number }>();\n    const ranges = new Map<string, { min: number; max: number }>();\n    for (const col of columns) {\n      if (!col.heat) continue;\n      let min = Number.POSITIVE_INFINITY;\n      let max = Number.NEGATIVE_INFINITY;\n      for (const { row } of keyed) {\n        const v = col.heat(row);\n        if (v == null || Number.isNaN(v)) continue;\n        if (v < min) min = v;\n        if (v > max) max = v;\n      }\n      if (min <= max) ranges.set(col.key, { min, max });\n    }\n    return ranges;\n  }, [heat, columns, keyed]);\n\n  const allSelected =\n    keyed.length > 0 && keyed.every((k) => selection.has(k.id));\n\n  const cellHeat = (col: DataTableColumn<Row>, row: Row): CSSProperties => {\n    const range = heatRanges.get(col.key);\n    if (!range || !col.heat) return {};\n    const v = col.heat(row);\n    if (v == null || Number.isNaN(v)) return {};\n    const span = range.max - range.min;\n    let norm = span === 0 ? 0.5 : (v - range.min) / span;\n    if (col.heatInvert) norm = 1 - norm;\n    return heatStyle(norm);\n  };\n\n  return (\n    <div className={cn(\"w-full overflow-x-auto\", className)}>\n      <table className=\"w-full border-collapse text-sm\">\n        <thead\n          className={cn(\"bg-engram-raised-2\", sticky && \"sticky top-0 z-10\")}\n        >\n          <tr className=\"border-engram-border border-b\">\n            {selectable ? (\n              <th className=\"w-9 px-2 py-2\">\n                <input\n                  type=\"checkbox\"\n                  aria-label=\"Select all rows\"\n                  checked={allSelected}\n                  onChange={() =>\n                    setSelection(\n                      allSelected ? new Set() : new Set(keyed.map((k) => k.id)),\n                    )\n                  }\n                  className=\"accent-engram-accent\"\n                />\n              </th>\n            ) : null}\n            {columns.map((col) => {\n              const active = sort?.key === col.key;\n              return (\n                <th\n                  key={col.key}\n                  scope=\"col\"\n                  aria-sort={\n                    col.sortable && active\n                      ? sort?.dir === \"asc\"\n                        ? \"ascending\"\n                        : \"descending\"\n                      : undefined\n                  }\n                  style={col.width != null ? { width: col.width } : undefined}\n                  className={cn(\n                    \"px-3 py-2 font-engram-display font-semibold text-[11px] text-engram-muted uppercase tracking-engram-label\",\n                    ALIGN[col.align ?? \"left\"],\n                  )}\n                >\n                  {col.sortable ? (\n                    <button\n                      type=\"button\"\n                      onClick={() => applySort(col.key)}\n                      className={cn(\n                        \"inline-flex items-center gap-1 uppercase transition-colors hover:text-engram-fg\",\n                        active && \"text-engram-fg\",\n                      )}\n                    >\n                      {col.header}\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"text-[9px]\",\n                          active ? \"opacity-100\" : \"opacity-30\",\n                        )}\n                      >\n                        {active && sort?.dir === \"desc\" ? \"▼\" : \"▲\"}\n                      </span>\n                    </button>\n                  ) : (\n                    col.header\n                  )}\n                </th>\n              );\n            })}\n          </tr>\n        </thead>\n        <tbody>\n          {sorted.length === 0 ? (\n            <tr>\n              <td\n                colSpan={columns.length + (selectable ? 1 : 0)}\n                className=\"px-3 py-8 text-center text-engram-faint text-sm\"\n              >\n                {empty ?? \"No results.\"}\n              </td>\n            </tr>\n          ) : (\n            sorted.map(({ row, id }) => {\n              const selected = selection.has(id);\n              return (\n                <tr\n                  key={id}\n                  aria-labelledby={headId}\n                  onClick={onRowClick ? () => onRowClick(row) : undefined}\n                  className={cn(\n                    \"border-engram-border/60 border-b transition-colors\",\n                    selected ? \"bg-engram-accent/10\" : \"hover:bg-white/[0.03]\",\n                    onRowClick && \"cursor-pointer\",\n                  )}\n                >\n                  {selectable ? (\n                    <td className=\"px-2 py-2\">\n                      <input\n                        type=\"checkbox\"\n                        aria-label=\"Select row\"\n                        checked={selected}\n                        onClick={(e) => e.stopPropagation()}\n                        onChange={() => toggleRow(id)}\n                        className=\"accent-engram-accent\"\n                      />\n                    </td>\n                  ) : null}\n                  {columns.map((col) => (\n                    <td\n                      key={col.key}\n                      style={heat ? cellHeat(col, row) : undefined}\n                      className={cn(\n                        \"px-3 py-2 text-engram-fg\",\n                        ALIGN[col.align ?? \"left\"],\n                      )}\n                    >\n                      {col.renderCell\n                        ? col.renderCell(row)\n                        : (col.accessor?.(row) ?? null)}\n                    </td>\n                  ))}\n                </tr>\n              );\n            })\n          )}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/engram/data-table.tsx"
    }
  ],
  "meta": {
    "level": "component"
  },
  "docs": "Extend without forking: edit the copied source, use `asChild` (Radix Slot) to change the rendered element, pass the typed `annotations` prop for curated data (verdict/tags/per-plug), or use slot / render-prop props for arbitrary content. Requires the @engram/tokens theme (--engram-* CSS variables).",
  "categories": [
    "table"
  ],
  "type": "registry:component"
}