{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heat-grid",
  "title": "Heat Grid",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A matrix of values shaded by size, such as models against test cases or services against regions: a real ARIA grid with arrow-key navigation, value text in every cell, a tone mode with icons and words, hatched empty cells and a legend.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/direction.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/heat-grid.tsx",
      "content": "// Ballmac UI: Heat Grid. https://ui.ballmac.com/components/heat-grid\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, Minus, TriangleAlert } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useDirection } from \"@/lib/ballmac/direction\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype HeatTone = \"good\" | \"warn\" | \"bad\"\n\ntype HeatAxis = {\n  id: string\n  label: string\n}\n\ntype HeatGridProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** The rows, top to bottom. */\n  rows: HeatAxis[]\n  /** The columns, left to right. */\n  columns: HeatAxis[]\n  /** The value of each cell, by row then column. Use null for no data. */\n  values: (number | null)[][]\n  /** What the rows are, read by assistive technology, for example \"Models\". */\n  rowLabel?: string\n  /** What the columns are, for example \"Test cases\". */\n  columnLabel?: string\n  /** The lowest and highest value for the colour scale. Defaults to the data's own range. */\n  domain?: [number, number]\n  /** Turns a value into text. */\n  format?: (value: number) => string\n  /**\n   * Classify a value instead of shading it: good, warn or bad cells get a check, a triangle or a cross as well as a tint, so the meaning is never colour alone.\n   */\n  tone?: (value: number) => HeatTone\n  /** The selected cell, as \"row,column\" indexes. Controlled. */\n  selected?: [number, number] | null\n  /** Called when a cell is chosen. */\n  onSelectedChange?: (cell: [number, number]) => void\n  /** Shows the colour scale under the grid. */\n  legend?: boolean\n}\n\nconst TONES: Record<HeatTone, { icon: typeof Check; word: string; tint: string }> = {\n  good: { icon: Check, word: \"Good\", tint: \"color-mix(in oklab, var(--chart-2) 32%, var(--card))\" },\n  warn: { icon: TriangleAlert, word: \"Warning\", tint: \"color-mix(in oklab, var(--chart-3) 38%, var(--card))\" },\n  bad: { icon: CircleAlert, word: \"Problem\", tint: \"color-mix(in oklab, var(--destructive) 30%, var(--card))\" },\n}\n\n/**\n * A matrix of values shaded by size, such as models against test cases or services against regions. Cells are a real ARIA grid: Tab enters once, the arrow\n * keys, Home and End move between cells, Enter or Space chooses one. Every cell shows its value as text; with `tone` it also shows an icon and the word, so\n * colour is only ever a second signal. Cells with no data are hatched and say so.\n */\nfunction HeatGrid({\n  rows,\n  columns,\n  values,\n  rowLabel,\n  columnLabel,\n  domain,\n  format = (v) => String(Math.round(v * 100) / 100),\n  tone,\n  selected: selectedProp,\n  onSelectedChange,\n  legend = true,\n  dir,\n  className,\n  ...props\n}: HeatGridProps) {\n  const msg = useMessages()\n  const direction = useDirection(dir === \"rtl\" || dir === \"ltr\" ? dir : undefined)\n  const [inner, setInner] = React.useState<[number, number] | null>(null)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const [focus, setFocus] = React.useState<[number, number]>([0, 0])\n  const refs = React.useRef(new Map<string, HTMLButtonElement>())\n  const flat = values.flat().filter((v): v is number => typeof v === \"number\")\n  const [lo, hi] = domain ?? [Math.min(...flat, 0), Math.max(...flat, 1)]\n  const span = hi - lo || 1\n  const key = (r: number, c: number) => `${r},${c}`\n\n  function choose(r: number, c: number) {\n    const cell: [number, number] = [r, c]\n    if (selectedProp === undefined) setInner(cell)\n    onSelectedChange?.(cell)\n  }\n  function move(r: number, c: number) {\n    const rr = Math.min(Math.max(r, 0), rows.length - 1)\n    const cc = Math.min(Math.max(c, 0), columns.length - 1)\n    setFocus([rr, cc])\n    refs.current.get(key(rr, cc))?.focus()\n  }\n  function onKeyDown(e: React.KeyboardEvent, r: number, c: number) {\n    const rtl = direction === \"rtl\"\n    const map: Record<string, [number, number]> = {\n      ArrowRight: [r, c + (rtl ? -1 : 1)],\n      ArrowLeft: [r, c + (rtl ? 1 : -1)],\n      ArrowDown: [r + 1, c],\n      ArrowUp: [r - 1, c],\n      Home: e.ctrlKey ? [0, 0] : [r, 0],\n      End: e.ctrlKey ? [rows.length - 1, columns.length - 1] : [r, columns.length - 1],\n    }\n    const to = map[e.key]\n    if (to) {\n      e.preventDefault()\n      move(to[0], to[1])\n    }\n  }\n\n  return (\n    <div data-slot=\"heat-grid\" dir={dir} className={cn(\"grid w-full min-w-0 gap-3\", className)} {...props}>\n      <div className=\"overflow-x-auto rounded-xl border bg-card text-card-foreground\">\n        <div role=\"grid\" aria-label={[rowLabel, columnLabel].filter(Boolean).join(\" by \") || msg(\"heat-grid.label\", \"Values\")} aria-rowcount={rows.length + 1} aria-colcount={columns.length + 1} className=\"grid min-w-max\" style={{ gridTemplateColumns: `minmax(7rem, auto) repeat(${columns.length}, minmax(4.5rem, 1fr))` }}>\n          <div role=\"row\" className=\"contents\">\n            <div role=\"columnheader\" className=\"sticky start-0 z-10 border-b border-e bg-card px-3 py-2 text-xs font-medium text-muted-foreground\">{rowLabel}</div>\n            {columns.map((c) => (\n              <div key={c.id} role=\"columnheader\" className=\"border-b px-2 py-2 text-center text-xs font-medium text-muted-foreground\">{c.label}</div>\n            ))}\n          </div>\n          {rows.map((row, r) => (\n            <div key={row.id} role=\"row\" className=\"contents\">\n              <div role=\"rowheader\" className=\"sticky start-0 z-10 flex items-center border-e bg-card px-3 py-2 text-sm font-medium [&:not(:last-child)]:border-b\">{row.label}</div>\n              {columns.map((col, c) => {\n                const v = values[r]?.[c] ?? null\n                const t = v !== null && tone ? tone(v) : undefined\n                const on = selected?.[0] === r && selected[1] === c\n                const share = v === null ? 0 : Math.round(((v - lo) / span) * 70)\n                const bg = v === null ? undefined : t ? TONES[t].tint : `color-mix(in oklab, var(--chart-1) ${share}%, var(--card))`\n                const Icon = t ? TONES[t].icon : v === null ? Minus : null\n                const text = v === null ? msg(\"heat-grid.none\", \"No data\") : format(v)\n                return (\n                  <div key={col.id} role=\"gridcell\" aria-selected={on} className=\"border-b p-1\">\n                    <button\n                      ref={(el) => {\n                        if (el) refs.current.set(key(r, c), el)\n                        else refs.current.delete(key(r, c))\n                      }}\n                      type=\"button\"\n                      tabIndex={focus[0] === r && focus[1] === c ? 0 : -1}\n                      data-tone={t}\n                      aria-label={msg(\"heat-grid.cell\", \"{row}, {column}: {value}\", { row: row.label, column: col.label, value: t ? `${text}, ${msg(`heat-grid.${t}`, TONES[t].word)}` : text })}\n                      onClick={() => choose(r, c)}\n                      onFocus={() => setFocus([r, c])}\n                      onKeyDown={(e) => onKeyDown(e, r, c)}\n                      className={cn(\n                        \"flex h-11 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium tabular-nums text-foreground outline-none transition-shadow motion-reduce:transition-none\",\n                        \"hover:shadow-[inset_0_0_0_1.5px_var(--foreground)] focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n                        v === null && \"bg-[repeating-linear-gradient(135deg,var(--muted)_0_4px,transparent_4px_8px)] font-normal text-muted-foreground\",\n                        on && \"shadow-[inset_0_0_0_2px_var(--foreground)]\"\n                      )}\n                      style={{ backgroundColor: bg }}\n                    >\n                      {Icon && <Icon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />}\n                      {v !== null && <span>{text}</span>}\n                      {v === null && <span className=\"sr-only\">{text}</span>}\n                    </button>\n                  </div>\n                )\n              })}\n            </div>\n          ))}\n        </div>\n      </div>\n      {legend && !tone && flat.length > 0 && (\n        <div className=\"flex items-center gap-3 text-xs text-muted-foreground tabular-nums\" aria-hidden=\"true\">\n          <span>{format(lo)}</span>\n          <span className=\"h-2 w-40 rounded-full\" style={{ background: \"linear-gradient(to right, color-mix(in oklab, var(--chart-1) 0%, var(--card)), color-mix(in oklab, var(--chart-1) 70%, var(--card)))\", boxShadow: \"inset 0 0 0 1px var(--border)\" }} />\n          <span>{format(hi)}</span>\n        </div>\n      )}\n      {legend && tone && (\n        <ul className=\"flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground\" aria-label={msg(\"heat-grid.legend\", \"Legend\")}>\n          {(Object.keys(TONES) as HeatTone[]).map((t) => {\n            const I = TONES[t].icon\n            return (\n              <li key={t} className=\"flex items-center gap-1.5\">\n                <span aria-hidden=\"true\" className=\"flex size-5 items-center justify-center rounded\" style={{ backgroundColor: TONES[t].tint }}><I className=\"size-3 text-foreground\" /></span>\n                {msg(`heat-grid.${t}`, TONES[t].word)}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </div>\n  )\n}\n\nexport { HeatGrid, type HeatAxis, type HeatGridProps, type HeatTone }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/heat-grid.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "heatmap",
      "matrix",
      "grid",
      "evals",
      "status",
      "comparison",
      "chart",
      "table"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "rows and columns are [{ id, label }]; values is number[][] (rows then columns, null for no data). domain [min, max], format(value), selected / onSelectedChange as [row, col]. tone(value) returns 'good' | 'warn' | 'bad' to classify instead of shade; those cells also show an icon and a word.",
      "whenToUse": [
        "Comparing many things against many conditions at a glance",
        "Eval results, service health by region, usage by hour and weekday"
      ],
      "whenNotToUse": [
        "A trend over time; use chart-line or sparkline",
        "Exact records people sort and filter; use data-table",
        "A calendar of activity; use contribution-graph"
      ],
      "composesWith": [
        "chart-frame",
        "data-table",
        "agent-roster",
        "run-ledger"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Enters the grid once, on one cell"
        },
        {
          "keys": "Arrow keys",
          "action": "Move between cells (left and right swap in right-to-left); Home and End go to the row ends, with Ctrl to the grid corners"
        },
        {
          "keys": "Enter / Space",
          "action": "Chooses the cell and reports it"
        },
        {
          "keys": "Screen readers",
          "action": "A grid with row and column headers; each cell reads 'Model A, Case 3: 92, Good' and an empty cell reads 'No data'"
        },
        {
          "keys": "Colour",
          "action": "Every value is text; tone cells add an icon and a word"
        }
      ],
      "customization": [
        "domain and format",
        "tone for pass, warn and fail",
        "rowLabel and columnLabel",
        "Controlled selection"
      ]
    },
    "statesNone": "A presentational chart: you pass the values in, so it has nothing of its own to load or fail.",
    "examples": [
      "heat-grid-demo",
      "heat-grid-tone"
    ],
    "url": "https://ui.ballmac.com/components/heat-grid"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}