{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback-widget",
  "title": "Feedback Widget",
  "description": "A button in the corner that opens into a small form: how it is going, a message, and Send. A failed send keeps the text.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/feedback-widget/feedback-widget.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface FeedbackSentiment {\n  value: string\n  label: string\n  icon?: React.ReactNode\n}\n\nexport interface FeedbackWidgetValue {\n  sentiment: string | null\n  message: string\n}\n\nexport type FeedbackWidgetState =\n  \"closed\" | \"open\" | \"sending\" | \"sent\" | \"error\"\n\nexport interface FeedbackWidgetProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onSubmit\"\n> {\n  /** A rejection is the failed state, and the text is kept for a retry. */\n  onSubmit: (value: FeedbackWidgetValue) => void | Promise<void>\n  onSubmitError?: (error: unknown) => void\n  /** Pass an empty array to leave the sentiment question out. */\n  sentiments?: FeedbackSentiment[]\n  triggerLabel?: string\n  title?: string\n  sentimentLabel?: string\n  messageLabel?: string\n  placeholder?: string\n  sendLabel?: string\n  sendingLabel?: string\n  sentMessage?: string\n  errorMessage?: string\n  retryLabel?: string\n  closeLabel?: string\n}\n\nfunction Face({ mouth }: { mouth: string }) {\n  return (\n    <svg aria-hidden=\"true\" className=\"size-6\" fill=\"none\" viewBox=\"0 0 24 24\">\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"9.25\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <circle cx=\"9\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" />\n      <circle cx=\"15\" cy=\"10\" r=\"1.1\" fill=\"currentColor\" />\n      <path\n        d={mouth}\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.5\"\n      />\n    </svg>\n  )\n}\n\nexport const defaultFeedbackSentiments: FeedbackSentiment[] = [\n  {\n    value: \"great\",\n    label: \"Great\",\n    icon: <Face mouth=\"M8 14c2.2 2.6 5.8 2.6 8 0\" />,\n  },\n  {\n    value: \"fine\",\n    label: \"Fine\",\n    icon: <Face mouth=\"M8.5 14.5c1.8 1.2 5.2 1.2 7 0\" />,\n  },\n  { value: \"meh\", label: \"Not great\", icon: <Face mouth=\"M9 15h6\" /> },\n  {\n    value: \"bad\",\n    label: \"Bad\",\n    icon: <Face mouth=\"M8 16c2.2-2.4 5.8-2.4 8 0\" />,\n  },\n]\n\nconst CHECK_LENGTH = 20\n\nconst MORPH =\n  \"transition-[width,height,border-radius] duration-300 ease-[cubic-bezier(0.3,1.2,0.5,1)] motion-reduce:transition-none\"\n\nexport const FeedbackWidget = React.forwardRef<\n  HTMLDivElement,\n  FeedbackWidgetProps\n>(function FeedbackWidget(\n  {\n    className,\n    closeLabel = \"Close\",\n    errorMessage = \"Could not send. Your message is still here.\",\n    messageLabel = \"Message\",\n    onKeyDown,\n    onSubmit,\n    onSubmitError,\n    placeholder = \"What happened, or what would help?\",\n    retryLabel = \"Try again\",\n    sendLabel = \"Send\",\n    sendingLabel = \"Sending\",\n    sentMessage = \"Thanks. We read every note.\",\n    sentimentLabel = \"How is it going?\",\n    sentiments = defaultFeedbackSentiments,\n    title = \"Send feedback\",\n    triggerLabel = \"Feedback\",\n    ...rootProps\n  },\n  forwardedRef\n) {\n  const titleId = React.useId()\n  const groupName = React.useId()\n  const rootRef = React.useRef<HTMLDivElement | null>(null)\n  const contentRef = React.useRef<HTMLDivElement | null>(null)\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null)\n  const dialogRef = React.useRef<HTMLDivElement | null>(null)\n  const messageRef = React.useRef<HTMLTextAreaElement | null>(null)\n  const returnFocusRef = React.useRef(false)\n\n  const [open, setOpen] = React.useState(false)\n  const [status, setStatus] = React.useState<\n    \"idle\" | \"sending\" | \"sent\" | \"error\"\n  >(\"idle\")\n  const [sentiment, setSentiment] = React.useState<string | null>(null)\n  const [message, setMessage] = React.useState(\"\")\n  const [size, setSize] = React.useState<{ w: number; h: number } | null>(null)\n\n  const state: FeedbackWidgetState = !open\n    ? \"closed\"\n    : status === \"idle\"\n      ? \"open\"\n      : status\n  const ready = Boolean(message.trim() || sentiment)\n\n  const close = React.useCallback(() => {\n    returnFocusRef.current = true\n    setOpen(false)\n    setStatus((current) => (current === \"sending\" ? current : \"idle\"))\n  }, [])\n\n  React.useEffect(() => {\n    const content = contentRef.current\n    if (!content || typeof ResizeObserver === \"undefined\") return\n\n    const observer = new ResizeObserver(() => {\n      setSize({ w: content.offsetWidth, h: content.offsetHeight })\n    })\n    observer.observe(content)\n    return () => observer.disconnect()\n  }, [])\n\n  React.useEffect(() => {\n    if (open) {\n      if (status === \"sent\") dialogRef.current?.focus()\n      else if (status === \"idle\") messageRef.current?.focus()\n      return\n    }\n    if (returnFocusRef.current) {\n      returnFocusRef.current = false\n      triggerRef.current?.focus()\n    }\n  }, [open, status])\n\n  React.useEffect(() => {\n    if (!open || status !== \"sent\") return\n    const timer = setTimeout(close, 1600)\n    return () => clearTimeout(timer)\n  }, [close, open, status])\n\n  React.useEffect(() => {\n    if (!open) return\n\n    function onPointerDown(event: PointerEvent) {\n      if (!rootRef.current?.contains(event.target as Node)) {\n        setOpen(false)\n        setStatus((current) => (current === \"sending\" ? current : \"idle\"))\n      }\n    }\n\n    document.addEventListener(\"pointerdown\", onPointerDown)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown)\n  }, [open])\n\n  async function send() {\n    if (status === \"sending\" || !ready) return\n    setStatus(\"sending\")\n    try {\n      await onSubmit({ sentiment, message: message.trim() })\n      setStatus(\"sent\")\n      setSentiment(null)\n      setMessage(\"\")\n    } catch (error) {\n      setStatus(\"error\")\n      onSubmitError?.(error)\n    }\n  }\n\n  const announcement =\n    state === \"sending\"\n      ? sendingLabel\n      : state === \"sent\"\n        ? sentMessage\n        : state === \"error\"\n          ? errorMessage\n          : \"\"\n\n  return (\n    <div\n      {...rootProps}\n      ref={(node) => {\n        rootRef.current = node\n        if (typeof forwardedRef === \"function\") forwardedRef(node)\n        else if (forwardedRef) forwardedRef.current = node\n      }}\n      data-slot=\"feedback-widget\"\n      data-state={state}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (event.defaultPrevented || !open) return\n\n        if (event.key === \"Escape\") {\n          event.preventDefault()\n          close()\n          return\n        }\n\n        if (event.key !== \"Tab\" || !dialogRef.current) return\n        const buttons = Array.from(\n          dialogRef.current.querySelectorAll(\"button\")\n        ).filter((node) => !node.closest(\"[inert]\"))\n        const first = buttons[0]\n        const last = buttons[buttons.length - 1]\n        if (!first || !last) return\n\n        if (event.shiftKey && document.activeElement === first) {\n          event.preventDefault()\n          last.focus()\n        } else if (\n          !event.shiftKey &&\n          (document.activeElement === last ||\n            document.activeElement === dialogRef.current)\n        ) {\n          event.preventDefault()\n          first.focus()\n        }\n      }}\n      className={cn(\"fixed right-4 bottom-4 z-50\", className)}\n    >\n      <div\n        className={cn(\n          \"border-border bg-background text-foreground ml-auto overflow-hidden border shadow-lg\",\n          MORPH,\n          open ? \"rounded-[1.25rem]\" : \"rounded-[1.375rem]\"\n        )}\n        style={size ? { width: size.w, height: size.h } : undefined}\n      >\n        <div ref={contentRef} className=\"w-max\">\n          {open ? (\n            <div\n              ref={dialogRef}\n              role=\"dialog\"\n              aria-labelledby={titleId}\n              tabIndex={-1}\n              className=\"w-[min(20rem,calc(100vw-2rem))] p-4 outline-none\"\n            >\n              <div className=\"flex items-center justify-between gap-2\">\n                <p id={titleId} className=\"text-sm font-semibold\">\n                  {title}\n                </p>\n                <button\n                  type=\"button\"\n                  aria-label={closeLabel}\n                  onClick={close}\n                  className=\"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring -mr-1.5 grid size-8 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                >\n                  <svg\n                    aria-hidden=\"true\"\n                    className=\"size-3.5\"\n                    fill=\"none\"\n                    viewBox=\"0 0 14 14\"\n                  >\n                    <path\n                      d=\"m3.5 3.5 7 7m0-7-7 7\"\n                      stroke=\"currentColor\"\n                      strokeLinecap=\"round\"\n                      strokeWidth=\"1.75\"\n                    />\n                  </svg>\n                </button>\n              </div>\n\n              <div className=\"mt-3 grid [&>*]:[grid-area:1/1]\">\n                <form\n                  inert={status === \"sent\"}\n                  className={cn(\"grid gap-3\", status === \"sent\" && \"invisible\")}\n                  aria-busy={status === \"sending\" || undefined}\n                  onSubmit={(event) => {\n                    event.preventDefault()\n                    void send()\n                  }}\n                >\n                  {sentiments.length ? (\n                    <fieldset className=\"grid gap-2\">\n                      <legend className=\"text-muted-foreground mb-2 text-xs\">\n                        {sentimentLabel}\n                      </legend>\n                      <div className=\"grid auto-cols-fr grid-flow-col gap-1.5\">\n                        {sentiments.map((option) => (\n                          <label\n                            key={option.value}\n                            className=\"border-border text-muted-foreground hover:bg-muted has-[:checked]:border-foreground has-[:checked]:text-foreground has-[:focus-visible]:ring-ring flex min-h-14 cursor-pointer flex-col items-center justify-center gap-1 rounded-[var(--radius)] border px-1 py-1.5 text-[11px] leading-tight transition-colors duration-150 has-[:focus-visible]:ring-2 motion-reduce:transition-none\"\n                          >\n                            <input\n                              type=\"radio\"\n                              name={groupName}\n                              value={option.value}\n                              checked={sentiment === option.value}\n                              onChange={() => setSentiment(option.value)}\n                              className=\"sr-only\"\n                            />\n                            {option.icon}\n                            <span>{option.label}</span>\n                          </label>\n                        ))}\n                      </div>\n                    </fieldset>\n                  ) : null}\n\n                  <label className=\"grid gap-1.5\">\n                    <span className=\"text-muted-foreground text-xs\">\n                      {messageLabel}\n                    </span>\n                    <textarea\n                      ref={messageRef}\n                      value={message}\n                      readOnly={status === \"sending\"}\n                      onChange={(event) => setMessage(event.target.value)}\n                      onKeyDown={(event) => {\n                        if (\n                          event.key === \"Enter\" &&\n                          (event.metaKey || event.ctrlKey)\n                        ) {\n                          event.preventDefault()\n                          void send()\n                        }\n                      }}\n                      placeholder={placeholder}\n                      rows={4}\n                      className=\"border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring resize-none rounded-[var(--radius)] border px-3 py-2 text-sm outline-none focus-visible:ring-2\"\n                    />\n                  </label>\n\n                  {status === \"error\" ? (\n                    <p className=\"text-destructive text-xs\">{errorMessage}</p>\n                  ) : null}\n\n                  <button\n                    type=\"submit\"\n                    aria-disabled={!ready || status === \"sending\"}\n                    className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex min-h-10 items-center justify-center gap-2 rounded-[var(--radius)] px-4 text-sm font-medium outline-none focus-visible:ring-4 aria-disabled:opacity-60\"\n                  >\n                    {status === \"sending\" ? (\n                      <svg\n                        aria-hidden=\"true\"\n                        className=\"size-4 animate-spin motion-reduce:animate-none\"\n                        fill=\"none\"\n                        viewBox=\"0 0 24 24\"\n                      >\n                        <circle\n                          className=\"opacity-25\"\n                          cx=\"12\"\n                          cy=\"12\"\n                          r=\"10\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"3\"\n                        />\n                        <path\n                          d=\"M12 2a10 10 0 0 1 10 10\"\n                          stroke=\"currentColor\"\n                          strokeLinecap=\"round\"\n                          strokeWidth=\"3\"\n                        />\n                      </svg>\n                    ) : null}\n                    {status === \"sending\"\n                      ? sendingLabel\n                      : status === \"error\"\n                        ? retryLabel\n                        : sendLabel}\n                  </button>\n                </form>\n                <div\n                  inert={status !== \"sent\"}\n                  className={cn(\n                    \"flex flex-col items-center justify-center gap-3 text-center\",\n                    status !== \"sent\" && \"invisible\"\n                  )}\n                >\n                  <svg\n                    aria-hidden=\"true\"\n                    className=\"text-primary size-10\"\n                    fill=\"none\"\n                    viewBox=\"0 0 40 40\"\n                  >\n                    <circle\n                      cx=\"20\"\n                      cy=\"20\"\n                      r=\"18\"\n                      className=\"stroke-current opacity-20\"\n                      strokeWidth=\"2\"\n                    />\n                    <path\n                      className=\"transition-[stroke-dashoffset] duration-300 ease-out motion-reduce:transition-none\"\n                      d=\"M13 20.5 18 25.5 27.5 15\"\n                      stroke=\"currentColor\"\n                      strokeDasharray={CHECK_LENGTH}\n                      strokeDashoffset={status === \"sent\" ? 0 : CHECK_LENGTH}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      strokeWidth=\"2.5\"\n                    />\n                  </svg>\n                  <p className=\"text-sm\">{sentMessage}</p>\n                </div>\n              </div>\n            </div>\n          ) : (\n            <button\n              ref={triggerRef}\n              type=\"button\"\n              aria-haspopup=\"dialog\"\n              onClick={() => {\n                setOpen(true)\n                setStatus(\"idle\")\n              }}\n              className=\"hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 items-center gap-2 rounded-[1.375rem] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-inset\"\n            >\n              <svg\n                aria-hidden=\"true\"\n                className=\"size-4\"\n                fill=\"none\"\n                viewBox=\"0 0 16 16\"\n              >\n                <path\n                  d=\"M2.5 4a1.5 1.5 0 0 1 1.5-1.5h8A1.5 1.5 0 0 1 13.5 4v5.5A1.5 1.5 0 0 1 12 11H7l-3 2.5V11a1.5 1.5 0 0 1-1.5-1.5Z\"\n                  stroke=\"currentColor\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth=\"1.4\"\n                />\n              </svg>\n              {triggerLabel}\n            </button>\n          )}\n        </div>\n      </div>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "form",
    "feedback",
    "dialog",
    "control"
  ],
  "type": "registry:ui"
}