{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diff-viewer",
  "title": "Diff Viewer",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A line-by-line text diff with change counts, line numbers, and accessible change labels.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/diff-viewer.tsx",
      "content": "// Ballmac UI: Diff Viewer. https://ui.ballmac.com/components/diff-viewer\n\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype DiffViewerProps = React.ComponentProps<\"div\"> & {\n  /** Original text. */\n  before: string\n  /** Updated text. */\n  after: string\n  /** Accessible name, usually a filename. */\n  label: string\n  /** Show line numbers in the gutter. */\n  lineNumbers?: boolean\n}\ntype DiffLine = {\n  kind: \"same\" | \"added\" | \"removed\"\n  text: string\n  before?: number\n  after?: number\n}\nfunction compare(before: string, after: string): DiffLine[] {\n  const oldLines = before.split(\"\\n\")\n  const newLines = after.split(\"\\n\")\n  const table = Array.from(\n    { length: oldLines.length + 1 },\n    () => new Uint32Array(newLines.length + 1),\n  )\n  for (let i = oldLines.length - 1; i >= 0; i--)\n    for (let j = newLines.length - 1; j >= 0; j--)\n      table[i][j] =\n        oldLines[i] === newLines[j]\n          ? 1 + table[i + 1][j + 1]\n          : Math.max(table[i + 1][j], table[i][j + 1])\n  const result: DiffLine[] = []\n  let i = 0,\n    j = 0\n  while (i < oldLines.length || j < newLines.length) {\n    if (\n      i < oldLines.length &&\n      j < newLines.length &&\n      oldLines[i] === newLines[j]\n    ) {\n      result.push({ kind: \"same\", text: oldLines[i], before: ++i, after: ++j })\n      continue\n    }\n    if (\n      i < oldLines.length &&\n      (j >= newLines.length || table[i + 1][j] >= table[i][j + 1])\n    ) {\n      result.push({ kind: \"removed\", text: oldLines[i], before: ++i })\n      continue\n    }\n    result.push({ kind: \"added\", text: newLines[j], after: ++j })\n  }\n  return result\n}\nfunction DiffViewer({\n  className,\n  before,\n  after,\n  label,\n  lineNumbers = true,\n  ...props\n}: DiffViewerProps) {\n  const msg = useMessages()\n  const lines = compare(before, after)\n  const added = lines.filter((line) => line.kind === \"added\").length\n  const removed = lines.filter((line) => line.kind === \"removed\").length\n  return (\n    <div\n      data-slot=\"diff-viewer\"\n      dir=\"ltr\"\n      role=\"region\"\n      aria-label={msg(\"diff-viewer.diff\", \"{label} diff\", { label })}\n      tabIndex={0}\n      className={cn(\n        \"bg-card min-w-0 max-w-full overflow-auto rounded-xl border border-border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"diff-viewer-header\"\n        className=\"flex min-w-max items-center justify-between gap-4 border-b border-border px-4 py-2 text-xs\"\n      >\n        <span className=\"truncate font-mono font-medium\">{label}</span>\n        <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n          {msg(\"diff-viewer.summary\", \"{added} added · {removed} removed\", { added, removed })}\n        </span>\n      </div>\n      <div className=\"min-w-max py-2 font-mono text-xs leading-6\">\n        {lines.map((line, index) => (\n          <div\n            key={index}\n            data-slot=\"diff-viewer-line\"\n            data-kind={line.kind}\n            className={cn(\n              \"relative flex min-h-6 pe-4\",\n              line.kind === \"added\" && \"bg-primary/10\",\n              line.kind === \"removed\" && \"bg-destructive/10\",\n            )}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"text-muted-foreground w-10 shrink-0 select-none pe-2 text-end tabular-nums\"\n            >\n              {lineNumbers ? (line.after ?? line.before) : \"\"}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"text-muted-foreground w-5 shrink-0 select-none text-center\"\n            >\n              {line.kind === \"added\"\n                ? \"+\"\n                : line.kind === \"removed\"\n                  ? \"−\"\n                  : \" \"}\n            </span>\n            <code className=\"whitespace-pre\">\n              {line.kind !== \"same\" && (\n                <span className=\"sr-only select-none\">\n                  {line.kind === \"added\" ? \"Added\" : \"Removed\"}:{\" \"}\n                </span>\n              )}\n              {line.text || \" \"}\n            </code>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\nexport { DiffViewer, type DiffViewerProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/diff-viewer.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "diff",
      "code",
      "comparison"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A line-by-line text diff with change counts, line numbers, and accessible change labels.",
      "whenToUse": [
        "Review before-and-after text changes"
      ],
      "whenNotToUse": [
        "Use Comparison Table for product features"
      ],
      "composesWith": [
        "code-block"
      ],
      "a11y": [
        {
          "keys": "Tab / Arrow keys",
          "action": "Focuses and scrolls long diffs"
        }
      ],
      "customization": [
        "Controlled and uncontrolled state",
        "Content and token-based className styling"
      ]
    },
    "examples": [
      "diff-viewer-demo",
      "diff-viewer-states"
    ],
    "url": "https://ui.ballmac.com/components/diff-viewer"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}