{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"response-rating","type":"registry:ui","title":"Response Rating","description":"Thumbs up and down that ask the one follow-up question worth asking — with a state for feedback that has been sent and cannot be taken back.","author":"Scrim UI (https://scrimui.dev)","categories":["feedback"],"docs":"https://scrimui.dev/components/response-rating","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/response-rating/response-rating.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Thumbs up, thumbs down, and the one follow-up question worth asking.\n *\n * The naive version is a toggle: click the thumb, it fills in, nothing else\n * happens. Three things go wrong with that, and all three are about what a\n * rating *is*.\n *\n * **A rating is a submission, not a toggle.** It leaves the browser. Undo has\n * to actually delete it on the server, or the affordance is a lie — so\n * `rating` is what the server has, and clicking the active thumb clears it\n * rather than doing nothing.\n *\n * **A thumbs-down with no reason is unactionable**, and a modal asking for\n * one is worse than no reason at all: it blocks the conversation to collect\n * data for someone who is not in the room. So the vote is recorded\n * immediately and the reasons open *inline* underneath, optional, dismissible.\n * Whoever answers has helped; whoever ignores it has still voted.\n *\n * **The reasons are the whole value.** \"Wrong\" and \"unhelpful\" are the same\n * label with different tempers. Offer categories a reader can pick without\n * thinking and one free-text box for the case the categories missed — and\n * keep the list short enough to read in one glance, because a fourteen-item\n * taxonomy is a form, and nobody fills in a form to be helpful.\n */\n\nexport type Rating = \"up\" | \"down\";\n\nexport type ResponseRatingProps = {\n  /** What the server has recorded. Undefined means unrated. */\n  rating?: Rating;\n  /**\n   * Reason chips offered after a thumbs-down. Keep it to four or five —\n   * this is a glance, not a taxonomy.\n   */\n  reasons?: string[];\n  /** True once the detail has been sent. The panel collapses to a receipt. */\n  submitted?: boolean;\n  /** Clicking the active thumb passes `undefined` — that is a delete. */\n  onRate?: (rating: Rating | undefined) => void;\n  onSubmitDetail?: (reasons: string[], comment: string) => void;\n  /** Hide the labels and keep only the icons, for a row of message actions. */\n  compact?: boolean;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction ThumbUpIcon({ filled, ...props }: { filled?: boolean } & React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill={filled ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"M7 10v11H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z\" />\n      <path d=\"M7 10l4.2-7.4a1.6 1.6 0 0 1 3 .8V9h4.6a2 2 0 0 1 2 2.4l-1.4 7A2 2 0 0 1 17.4 20H7z\" />\n    </svg>\n  );\n}\n\nfunction ThumbDownIcon({ filled, ...props }: { filled?: boolean } & React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill={filled ? \"currentColor\" : \"none\"} stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"14\" height=\"14\" {...props}>\n      <path d=\"M17 14V3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1z\" />\n      <path d=\"M17 14l-4.2 7.4a1.6 1.6 0 0 1-3-.8V15H5.2a2 2 0 0 1-2-2.4l1.4-7A2 2 0 0 1 6.6 4H17z\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* ResponseRating                                                      */\n/* ------------------------------------------------------------------ */\n\nexport function ResponseRating({\n  rating,\n  reasons = [\"Incorrect\", \"Missed the question\", \"Too long\", \"Unsafe\"],\n  submitted = false,\n  onRate,\n  onSubmitDetail,\n  compact = false,\n  className = \"\",\n}: ResponseRatingProps) {\n  const [picked, setPicked] = React.useState<string[]>([]);\n  const [comment, setComment] = React.useState(\"\");\n  const [dismissed, setDismissed] = React.useState(false);\n\n  /* The panel follows the rating rather than a second piece of state: it is\n     open when there is a down-vote, no detail has been sent, and the reader\n     has not waved it away. Storing \"open\" separately is how it ends up open\n     under a thumbs-up. */\n  const detailOpen = rating === \"down\" && !submitted && !dismissed;\n\n  function rate(next: Rating) {\n    const cleared = rating === next;\n    if (!cleared) setDismissed(false);\n    onRate?.(cleared ? undefined : next);\n  }\n\n  return (\n    <div className={className}>\n      <div className=\"flex items-center gap-1\">\n        <button\n          type=\"button\"\n          onClick={() => rate(\"up\")}\n          aria-pressed={rating === \"up\"}\n          aria-label=\"Good response\"\n          className={`inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors ${\n            rating === \"up\"\n              ? \"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400\"\n              : \"text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n          }`}\n        >\n          <ThumbUpIcon filled={rating === \"up\"} />\n          {!compact && \"Good\"}\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => rate(\"down\")}\n          aria-pressed={rating === \"down\"}\n          aria-label=\"Bad response\"\n          className={`inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors ${\n            rating === \"down\"\n              ? \"bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400\"\n              : \"text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n          }`}\n        >\n          <ThumbDownIcon filled={rating === \"down\"} />\n          {!compact && \"Bad\"}\n        </button>\n\n        {/* The vote is already in. Saying so is what makes the reason panel\n            optional rather than a toll gate. */}\n        {rating !== undefined && !detailOpen && (\n          <span className=\"ml-1.5 inline-flex items-center gap-1 text-[11px] text-zinc-400 dark:text-zinc-500\">\n            <CheckIcon width=\"10\" height=\"10\" />\n            {submitted ? \"Thanks — sent\" : \"Recorded\"}\n          </span>\n        )}\n      </div>\n\n      {detailOpen && (\n        <div className=\"mt-2 rounded-xl border border-zinc-200 bg-zinc-50/60 p-3 dark:border-zinc-800 dark:bg-zinc-800/30\">\n          <div className=\"flex items-start justify-between gap-3\">\n            <p className=\"text-xs font-medium text-zinc-700 dark:text-zinc-200\">\n              Already recorded. What went wrong?\n            </p>\n            <button\n              type=\"button\"\n              onClick={() => setDismissed(true)}\n              className=\"-mr-1 -mt-0.5 shrink-0 rounded-md px-1.5 py-0.5 text-[11px] text-zinc-400 transition-colors hover:bg-zinc-200/60 hover:text-zinc-600 dark:hover:bg-zinc-700/60 dark:hover:text-zinc-300\"\n            >\n              Skip\n            </button>\n          </div>\n\n          <div className=\"mt-2 flex flex-wrap gap-1.5\">\n            {reasons.map((reason) => {\n              const on = picked.includes(reason);\n              return (\n                <button\n                  key={reason}\n                  type=\"button\"\n                  aria-pressed={on}\n                  onClick={() =>\n                    setPicked((prev) => (on ? prev.filter((r) => r !== reason) : [...prev, reason]))\n                  }\n                  className={`inline-flex h-7 items-center rounded-lg border px-2.5 text-[11px] font-medium transition-colors ${\n                    on\n                      ? \"border-zinc-900 bg-zinc-900 text-white dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900\"\n                      : \"border-zinc-200 text-zinc-600 hover:bg-white dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\"\n                  }`}\n                >\n                  {reason}\n                </button>\n              );\n            })}\n          </div>\n\n          <textarea\n            value={comment}\n            onChange={(e) => setComment(e.target.value)}\n            rows={2}\n            placeholder=\"Anything the chips missed (optional)\"\n            className=\"mt-2 w-full resize-none rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-[13px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-500\"\n          />\n\n          <button\n            type=\"button\"\n            onClick={() => onSubmitDetail?.(picked, comment)}\n            /* Enabled with nothing selected on purpose. The reader may have\n               nothing to add beyond the vote, and a disabled button reads as\n               \"your feedback is not good enough yet\". */\n            className=\"mt-2 inline-flex h-8 items-center rounded-lg bg-zinc-900 px-3.5 text-xs font-medium text-white transition-opacity hover:opacity-90 dark:bg-zinc-100 dark:text-zinc-900\"\n          >\n            Send detail\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/response-rating.tsx"}]}