{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thumbs-feedback",
  "type": "registry:ui",
  "title": "Thumbs Feedback",
  "description": "Helpful or not, with reasons on a thumbs down and a drawn thanks.",
  "categories": [
    "selection"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/choice-chips.json"
  ],
  "files": [
    {
      "path": "components/ui/thumbs-feedback.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Plus } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { ChoiceChip, ChoiceChips } from \"@/components/ui/choice-chips\";\n\ntype Vote = \"up\" | \"down\";\ntype Phase = \"ask\" | \"sending\" | \"error\" | \"thanks\";\n\nexport type Feedback = { vote: Vote; reasons: string[]; comment: string };\n\nexport type ThumbsFeedbackProps = Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"children\"> & {\n  /** The question beside the thumbs. Pass null for thumbs only (under a chat message, say). */\n  question?: string | null;\n  /** Chips offered after a thumbs down. An empty array skips straight to the comment. */\n  reasons?: string[];\n  reasonsLabel?: string;\n  /** Offer an optional comment after a thumbs down. */\n  allowComment?: boolean;\n  commentPlaceholder?: string;\n  thanksLabel?: string;\n  /** Called with the vote (and, for a thumbs down, reasons and comment). Return a promise to show sending and failure. */\n  onSubmit?: (feedback: Feedback) => void | Promise<void>;\n  /** Show an Undo button with the thanks. */\n  undoable?: boolean;\n  onUndo?: () => void;\n  size?: \"sm\" | \"md\";\n};\n\nconst MAX_COMMENT = 500;\n\n// Measures its content and springs its height, so the panel opens and the swap to thanks never jumps.\nfunction AutoHeight({ children, reduce }: { children: React.ReactNode; reduce: boolean }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div initial={false} animate={{ height }} transition={reduce ? { duration: 0 } : { duration: 0.32, ease: ease.out }} className=\"-m-2 overflow-hidden\">\n      {/* The padding gives focus rings and touch targets room inside the clip; the margin cancels it. */}\n      <div ref={inner} className=\"flow-root p-2\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction Thumb({ dir, filled, size }: { dir: Vote; filled: boolean; size: number }) {\n  const up = \"M5 7.25v6H3a.75.75 0 0 1-.75-.75V8A.75.75 0 0 1 3 7.25zm0 0 2.5-4.5a1.4 1.4 0 0 1 1.9 1.3l-.4 2.2h3.3a1.25 1.25 0 0 1 1.2 1.6l-1.3 4.3a1.25 1.25 0 0 1-1.2.85H5\";\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden style={dir === \"down\" ? { transform: \"rotate(180deg)\" } : undefined}>\n      {/* Filling keeps the same stroke, so the chosen thumb gains weight without changing shape. */}\n      <path d={up} fill={filled ? \"currentColor\" : \"none\"} />\n    </svg>\n  );\n}\n\nexport function ThumbsFeedback({\n  question = \"Was this helpful?\",\n  reasons = [\"Inaccurate\", \"Out of date\", \"Hard to follow\", \"Missing an example\"],\n  reasonsLabel = \"What went wrong?\",\n  allowComment = true,\n  commentPlaceholder = \"Tell us more (optional)\",\n  thanksLabel = \"Thanks for the feedback\",\n  onSubmit,\n  undoable = true,\n  onUndo,\n  size = \"md\",\n  className,\n  ...rest\n}: ThumbsFeedbackProps) {\n  const [vote, setVote] = useState<Vote | null>(null);\n  const [phase, setPhase] = useState<Phase>(\"ask\");\n  const [picked, setPicked] = useState<string[]>([]);\n  const [commenting, setCommenting] = useState(false);\n  const [comment, setComment] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const thanksRef = useRef<HTMLDivElement>(null);\n  const downRef = useRef<HTMLButtonElement>(null);\n  const reduce = !!useReducedMotion();\n  const panelId = useId();\n  const questionId = useId();\n  const icon = size === \"sm\" ? 14 : 16;\n  const open = vote === \"down\" && phase !== \"thanks\";\n\n  const send = async (feedback: Feedback) => {\n    setPhase(\"sending\");\n    // Cleared first so a second failure in a row is announced again.\n    setAnnouncement(\"\");\n    try {\n      await onSubmit?.(feedback);\n      setPhase(\"thanks\");\n      setAnnouncement(thanksLabel);\n      requestAnimationFrame(() => thanksRef.current?.focus());\n    } catch {\n      setPhase(\"error\");\n      setAnnouncement(\"Couldn't send your feedback\");\n    }\n  };\n\n  const choose = (v: Vote) => {\n    if (phase === \"sending\") return;\n    if (v === vote && v === \"down\") {\n      // Pressing thumbs down again takes the vote back.\n      setVote(null);\n      setPhase(\"ask\");\n      return;\n    }\n    setVote(v);\n    setPhase(\"ask\");\n    if (v === \"up\") send({ vote: \"up\", reasons: [], comment: \"\" });\n    else if (!reasons.length && allowComment) setCommenting(true);\n  };\n\n  const reset = () => {\n    setVote(null);\n    setPhase(\"ask\");\n    setPicked([]);\n    setComment(\"\");\n    setCommenting(false);\n    setAnnouncement(\"Feedback removed\");\n    onUndo?.();\n    requestAnimationFrame(() => downRef.current?.parentElement?.querySelector<HTMLElement>(\"[data-thumb]\")?.focus());\n  };\n\n  const thumbButton = (v: Vote) => {\n    const chosen = vote === v;\n    const dim = vote !== null && !chosen;\n    return (\n      <button\n        ref={v === \"down\" ? downRef : undefined}\n        type=\"button\"\n        data-thumb={v}\n        aria-pressed={chosen}\n        aria-label={v === \"up\" ? \"Helpful\" : \"Not helpful\"}\n        aria-expanded={v === \"down\" ? open : undefined}\n        aria-controls={v === \"down\" && open ? panelId : undefined}\n        // aria-disabled rather than disabled, so a focused thumb keeps focus while sending.\n        aria-disabled={phase === \"sending\" || undefined}\n        onClick={() => choose(v)}\n        data-state={chosen ? \"on\" : dim ? \"dim\" : \"off\"}\n        className={cn(\n          \"relative grid shrink-0 place-items-center rounded-lg text-fg-3\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,color,opacity,scale] duration-200 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n          \"data-[state=on]:text-fg data-[state=dim]:opacity-40 data-[state=dim]:hover:opacity-100\",\n          \"aria-disabled:pointer-events-none\",\n          \"after:absolute after:-inset-1.5 after:content-['']\",\n          size === \"sm\" ? \"size-7\" : \"size-8\",\n        )}\n      >\n        <span className=\"relative grid place-items-center\" style={{ width: icon, height: icon }}>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={chosen ? \"on\" : \"off\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : chosen ? { opacity: 0, scale: 0.4, rotate: v === \"up\" ? -18 : 18 } : { opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1, rotate: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, transition: { duration: 0.1 } }}\n              transition={reduce ? { duration: 0.15 } : chosen ? spring.bouncy : spring.pop}\n            >\n              <Thumb dir={v} filled={chosen} size={icon} />\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n    );\n  };\n\n  const canSend = picked.length > 0 || comment.trim().length > 0;\n\n  return (\n    <div\n      className={cn(\"w-full\", className)}\n      {...rest}\n      onKeyDown={(e) => {\n        rest.onKeyDown?.(e);\n        // Escape anywhere in the component (the thumb included) takes the thumbs down back.\n        if (e.key === \"Escape\" && open && phase !== \"sending\" && !e.defaultPrevented) {\n          e.stopPropagation();\n          setVote(null);\n          setPhase(\"ask\");\n          downRef.current?.focus();\n        }\n      }}\n    >\n      <AutoHeight reduce={reduce}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {phase === \"thanks\" ? (\n            <motion.div\n              key=\"thanks\"\n              ref={thanksRef}\n              tabIndex={-1}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.28, ease: ease.out }}\n              className={cn(\"flex w-fit max-w-full items-center gap-2 rounded-lg pr-1 outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\", size === \"sm\" ? \"h-7\" : \"h-8\")}\n            >\n              <svg width={icon} height={icon} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"shrink-0 text-success\">\n                <motion.circle cx=\"8\" cy=\"8\" r=\"5.75\" initial={reduce ? false : { pathLength: 0, rotate: -90 }} animate={{ pathLength: 1, rotate: 0 }} transition={{ duration: 0.4, ease: ease.out }} style={{ originX: \"50%\", originY: \"50%\" }} />\n                <motion.path d=\"m5.5 8.25 1.75 1.75 3.25-3.75\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.26, ease: ease.out, delay: 0.24 }} />\n              </svg>\n              <span className={cn(\"min-w-0 truncate text-fg-2\", size === \"sm\" ? \"text-[12px]\" : \"text-[12.5px]\")}>{thanksLabel}</span>\n              {undoable && (\n                <button\n                  type=\"button\"\n                  onClick={reset}\n                  className={cn(\n                    \"relative ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-fg-3 underline decoration-fg-4 underline-offset-[3px]\",\n                    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                    \"transition-[color,text-decoration-color] hover:text-fg hover:decoration-fg-3\",\n                    \"after:absolute after:-inset-y-2.5 after:inset-x-0 after:content-['']\",\n                    size === \"sm\" ? \"text-[12px]\" : \"text-[12.5px]\",\n                  )}\n                >\n                  Undo\n                </button>\n              )}\n            </motion.div>\n          ) : (\n            <motion.div key=\"ask\" exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.12 } }}>\n              <div role=\"group\" aria-labelledby={question ? questionId : undefined} aria-label={question ? undefined : \"Rate this response\"} className={cn(\"flex items-center gap-1\", !question && \"-ml-1.5\")}>\n                {question && (\n                  <span id={questionId} className={cn(\"mr-2 min-w-0 truncate text-fg-2\", size === \"sm\" ? \"text-[12px]\" : \"text-[12.5px]\")}>\n                    {question}\n                  </span>\n                )}\n                {thumbButton(\"up\")}\n                {thumbButton(\"down\")}\n                {vote === \"up\" && phase === \"sending\" && <Spinner className=\"ml-1.5\" />}\n                {vote === \"up\" && phase === \"error\" && (\n                  <span className=\"ml-2 flex min-w-0 items-center gap-1.5 text-[12px] text-danger\">\n                    <span className=\"truncate\">Couldn’t send.</span>\n                    <button type=\"button\" onClick={() => send({ vote: \"up\", reasons: [], comment: \"\" })} className=\"shrink-0 rounded underline underline-offset-[3px] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\">\n                      Try again\n                    </button>\n                  </span>\n                )}\n              </div>\n\n              <AnimatePresence initial={false}>\n                {open && (\n                  <motion.div\n                    key=\"panel\"\n                    id={panelId}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                    transition={{ duration: 0.26, ease: ease.out, delay: reduce ? 0 : 0.06 }}\n                    className=\"flex flex-col gap-3 pt-3\"\n                  >\n                    {reasons.length > 0 && (\n                      <div className=\"flex flex-col gap-2\">\n                        <span id={`${panelId}-reasons`} className=\"text-[12px] text-fg-3\">\n                          {reasonsLabel}\n                        </span>\n                        <ChoiceChips aria-labelledby={`${panelId}-reasons`} size=\"sm\" clearable={false} value={picked} onValueChange={setPicked}>\n                          {reasons.map((r) => (\n                            <ChoiceChip key={r} value={r}>\n                              {r}\n                            </ChoiceChip>\n                          ))}\n                        </ChoiceChips>\n                      </div>\n                    )}\n\n                    {allowComment &&\n                      (commenting ? (\n                        <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: ease.out }} className=\"relative\">\n                          <textarea\n                            autoFocus\n                            value={comment}\n                            maxLength={MAX_COMMENT}\n                            onChange={(e) => setComment(e.target.value)}\n                            onKeyDown={(e) => {\n                              if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && canSend) send({ vote: \"down\", reasons: picked, comment: comment.trim() });\n                            }}\n                            rows={3}\n                            aria-label=\"Comment\"\n                            placeholder={commentPlaceholder}\n                            className={cn(\n                              \"block w-full resize-none rounded-lg border border-line-2 bg-raised px-2.5 py-2 text-base leading-[1.45] text-fg outline-none sm:text-[13px]\",\n                              \"placeholder:text-fg-4 transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-2 focus:ring-fg/10\",\n                            )}\n                          />\n                          {comment.length > MAX_COMMENT - 80 && (\n                            <span className=\"tabular pointer-events-none absolute bottom-1.5 right-2.5 text-2xs text-fg-3\">\n                              {comment.length}/{MAX_COMMENT}\n                            </span>\n                          )}\n                        </motion.div>\n                      ) : (\n                        <button\n                          type=\"button\"\n                          onClick={() => setCommenting(true)}\n                          className={cn(\n                            \"-ml-1.5 flex h-7 items-center gap-1.5 self-start rounded-md px-1.5 text-[12px] text-fg-3\",\n                            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                            \"transition-[background-color,color] hover:bg-hover hover:text-fg-2 active:scale-[0.97]\",\n                          )}\n                        >\n                          <Plus size={12} />\n                          Add a comment\n                        </button>\n                      ))}\n\n                    <div className=\"flex items-center justify-end gap-2\">\n                      {phase === \"error\" && (\n                        <span className=\"mr-auto min-w-0 truncate text-[12px] text-danger\">\n                          Couldn’t send. Check your connection.\n                        </span>\n                      )}\n                      <button\n                        type=\"button\"\n                        aria-disabled={phase === \"sending\" || undefined}\n                        onClick={() => phase !== \"sending\" && send({ vote: \"down\", reasons: [], comment: \"\" })}\n                        className={cn(\n                          \"h-7 shrink-0 rounded-md px-2 text-[12px] font-medium text-fg-3\",\n                          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                          \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75 aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n                        )}\n                      >\n                        Skip\n                      </button>\n                      <button\n                        type=\"button\"\n                        disabled={!canSend}\n                        aria-busy={phase === \"sending\" || undefined}\n                        onClick={() => phase !== \"sending\" && send({ vote: \"down\", reasons: picked, comment: comment.trim() })}\n                        className={cn(\n                          \"relative grid h-7 shrink-0 place-items-center rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame\",\n                          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                          \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                          \"disabled:pointer-events-none disabled:opacity-40\",\n                        )}\n                      >\n                        {/* Both labels share a cell and the spinner overlays, so the button never changes width. */}\n                        <span className={cn(\"col-start-1 row-start-1 transition-opacity\", phase === \"sending\" && \"opacity-0\")}>\n                          {phase === \"error\" ? \"Try again\" : \"Send feedback\"}\n                        </span>\n                        <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n                          Send feedback\n                        </span>\n                        {phase === \"sending\" && <Spinner className=\"col-start-1 row-start-1\" />}\n                      </button>\n                    </div>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </AutoHeight>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nfunction Spinner({ className }: { className?: string }) {\n  return (\n    <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" aria-hidden className={cn(\"animate-spin motion-reduce:animate-[spin_1.6s_linear_infinite]\", className)}>\n      <path d=\"M8 2.25a5.75 5.75 0 1 0 5.75 5.75\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/thumbs-feedback"
}
