{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"response-versions","type":"registry:component","title":"Response versions","description":"Step between regenerated answers with \"2 / 3\", and regenerate without losing the old ones.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/response-versions.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight, RotateCcw } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * RESPONSE VERSIONS: \"2 / 3\" between regenerated answers\n *\n *   browsing      the arrows step between versions: the answer\n *                 you leave slides out one way through a blur, the\n *                 next comes in from the other side, the height\n *                 eases between them and \"2 / 3\" rolls\n *   regenerating  the regenerate arrow blurs into a spinner until\n *                 the new version arrives; the count rolls up\n *\n * Wrap the current version's content.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ResponseVersionsProps extends React.HTMLAttributes<HTMLDivElement> {\n  count: number;\n  /** Zero-based index of the version shown. */\n  index: number;\n  onIndexChange: (index: number) => void;\n  onRegenerate?: () => void;\n  regenerating?: boolean;\n  /** The current version's content. */\n  children: React.ReactNode;\n}\n\ntype Leaving = { key: string; node: React.ReactNode; dir: number };\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ICON = `relative flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-35 ${FOCUS}`;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** The version you stepped away from: it leaves toward the side you came from, then removes itself. */\nfunction LeavingPane({ item, onDone }: { item: Leaving; onDone: (key: string) => void }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${-item.dir * 24}px)` },\n      ],\n      { duration: 260, easing: MORPH, fill: \"forwards\" }\n    );\n    animation.onfinish = () => onDone(item.key);\n    return () => animation.cancel();\n  }, [item, onDone]);\n  return (\n    <div ref={ref} inert aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0\">\n      {item.node}\n    </div>\n  );\n}\n\nexport function ResponseVersions({ count, index, onIndexChange, onRegenerate, regenerating = false, children, className = \"\", ...props }: ResponseVersionsProps) {\n  const reduced = useReducedMotion();\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const paneRef = React.useRef<HTMLDivElement>(null);\n  const latest = React.useRef<React.ReactNode>(children);\n  const height = React.useRef(0);\n\n  // A step: what was showing (as it last rendered) leaves, and the new version comes in from the side you stepped toward.\n  const [shownIndex, setShownIndex] = React.useState(index);\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const [dir, setDir] = React.useState(0);\n  if (shownIndex !== index) {\n    const d = index > shownIndex ? 1 : -1;\n    setShownIndex(index);\n    setDir(reduced ? 0 : d);\n    if (!reduced) setLeaving((l) => [...l, { key: `${shownIndex}>${index}`, node: latest.current, dir: d }]);\n  }\n  const done = React.useCallback((key: string) => setLeaving((l) => l.filter((x) => x.key !== key)), []);\n\n  React.useLayoutEffect(() => {\n    if (!dir) return;\n    const pane = paneRef.current;\n    const box = boxRef.current;\n    pane?.animate(\n      [\n        { opacity: 0, filter: \"blur(6px)\", transform: `translateX(${dir * 24}px)` },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 420, easing: MORPH, delay: 60, fill: \"backwards\" }\n    );\n    // The box eases from the old answer's height to the new one's.\n    if (box && height.current && Math.abs(box.offsetHeight - height.current) > 1) {\n      box.style.overflow = \"hidden\";\n      const resize = box.animate([{ height: `${height.current}px` }, { height: `${box.offsetHeight}px` }], { duration: 420, easing: MORPH });\n      resize.onfinish = resize.oncancel = () => {\n        box.style.overflow = \"\";\n      };\n    }\n  }, [index, dir]);\n\n  // Remember what this render showed and how tall it was, for the next step.\n  React.useLayoutEffect(() => {\n    latest.current = children;\n    if (boxRef.current) height.current = boxRef.current.offsetHeight;\n  });\n\n  return (\n    <div className={className} {...props}>\n      <div ref={boxRef} className={`relative ${leaving.length ? \"overflow-hidden\" : \"\"}`}>\n        {leaving.map((l) => (\n          <LeavingPane key={l.key} item={l} onDone={done} />\n        ))}\n        <div key={index} ref={paneRef}>\n          {children}\n        </div>\n      </div>\n\n      <div className=\"mt-3 flex items-center gap-1\">\n        <button type=\"button\" aria-label=\"Previous version\" disabled={index <= 0} onClick={() => onIndexChange(index - 1)} className={ICON}>\n          <ChevronLeft className=\"size-4\" />\n        </button>\n        <span aria-live=\"polite\" className=\"flex min-w-[3.25rem] justify-center gap-[0.5ch] font-mono text-[11.5px] tabular-nums text-muted-foreground\">\n          <span className=\"sr-only\">Version </span>\n          <NumberRoll value={index + 1} duration={600} />\n          <span aria-hidden=\"true\">/</span>\n          <span className=\"sr-only\">of</span>\n          <NumberRoll value={count} duration={600} />\n        </span>\n        <button type=\"button\" aria-label=\"Next version\" disabled={index >= count - 1} onClick={() => onIndexChange(index + 1)} className={ICON}>\n          <ChevronRight className=\"size-4\" />\n        </button>\n        {onRegenerate && (\n          <button type=\"button\" aria-label={regenerating ? \"Regenerating\" : \"Regenerate\"} disabled={regenerating} onClick={onRegenerate} className={`${ICON} ml-1`}>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(!regenerating, reduced)}>\n              <RotateCcw className=\"size-3.5\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(regenerating, reduced)}>\n              <span className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${regenerating ? \"animate-spin\" : \"\"}`} />\n            </span>\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/response-versions"}