{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback-popover",
  "title": "Feedback Popover",
  "description": "A Motion-powered feedback popover that expands from a trigger, supports keyboard submission, and confirms receipt inline.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base/ui/feedback-popover.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CheckCircle2 } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\n\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype FeedbackState = \"idle\" | \"loading\" | \"success\";\n\nconst INSTANT_TRANSITION = { duration: 0 } as const;\nconst CONTENT_TRANSITION = {\n  type: \"spring\",\n  duration: 0.4,\n  bounce: 0,\n} as const;\nconst SUBMIT_TRANSITION = {\n  type: \"spring\",\n  duration: 0.3,\n  bounce: 0,\n} as const;\n// Stable dependencies skip layout measurement while typing. Keeping the two\n// values different still lets Motion promote between the shared elements.\nconst TRIGGER_LAYOUT_DEPENDENCY = \"trigger\";\nconst POPOVER_LAYOUT_DEPENDENCY = \"popover\";\n\nexport type FeedbackPopoverProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"onSubmit\"\n> & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  onSubmit?: (feedback: string) => void | Promise<void>;\n  /**\n   * Called when `onSubmit` rejects. Without this the form silently returns to\n   * idle with the draft intact and no way to surface the failure.\n   */\n  onError?: (error: unknown) => void;\n  triggerLabel?: React.ReactNode;\n  /**\n   * Doubles as the placeholder: it floats over the empty textarea and clears\n   * once there is text, so there is no separate `placeholder` prop.\n   */\n  textareaLabel?: string;\n  submitLabel?: React.ReactNode;\n  successTitle?: React.ReactNode;\n  successDescription?: React.ReactNode;\n  loadingAnnouncement?: string;\n  /**\n   * Minimum time in ms the loading state stays visible, so quick submissions\n   * don't flash. Slow ones keep loading until `onSubmit` settles.\n   */\n  loadingDuration?: number;\n  /** Time in ms the success screen stays visible before the popover closes. */\n  successDuration?: number;\n};\n\nfunction Spinner({ className }: { className?: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent opacity-70 motion-reduce:animate-none\",\n        className,\n      )}\n    />\n  );\n}\n\nfunction wait(duration: number) {\n  return new Promise((resolve) => {\n    window.setTimeout(resolve, duration);\n  });\n}\n\nexport function FeedbackPopover({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  onSubmit,\n  onError,\n  triggerLabel = \"Feedback\",\n  textareaLabel = \"Feedback\",\n  submitLabel = \"Send feedback\",\n  successTitle = \"Feedback received!\",\n  successDescription = \"Thanks for helping us improve.\",\n  loadingAnnouncement = \"Sending feedback\",\n  loadingDuration = 1500,\n  successDuration = 1800,\n  className,\n  ...props\n}: FeedbackPopoverProps) {\n  const reactId = React.useId();\n  const titleId = `${reactId}-title`;\n  const descriptionId = `${reactId}-description`;\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const timers = React.useRef<number[]>([]);\n  const isMountedRef = React.useRef(true);\n  const shouldReduceMotion = useReducedMotion();\n  const isControlled = open !== undefined;\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);\n  const [feedback, setFeedback] = React.useState(\"\");\n  const [formState, setFormState] = React.useState<FeedbackState>(\"idle\");\n  const isOpen = isControlled ? open : uncontrolledOpen;\n  const trimmedFeedback = feedback.trim();\n\n  const clearTimers = React.useCallback(() => {\n    timers.current.forEach(window.clearTimeout);\n    timers.current = [];\n  }, []);\n\n  const setOpen = React.useCallback(\n    (nextOpen: boolean) => {\n      if (!isControlled) {\n        setUncontrolledOpen(nextOpen);\n      }\n\n      onOpenChange?.(nextOpen);\n    },\n    [isControlled, onOpenChange],\n  );\n\n  const closePopover = React.useCallback(\n    (options?: { restoreFocus?: boolean }) => {\n      clearTimers();\n      setOpen(false);\n\n      // Keyboard and programmatic closes return focus to the trigger so the\n      // user isn't dropped on <body>; pointer dismissals leave focus where\n      // the user clicked.\n      if (options?.restoreFocus) {\n        triggerRef.current?.focus();\n      }\n    },\n    [clearTimers, setOpen],\n  );\n\n  const openPopover = React.useCallback(() => {\n    clearTimers();\n    setFormState(\"idle\");\n    setFeedback(\"\");\n    setOpen(true);\n  }, [clearTimers, setOpen]);\n\n  // Controlled parents can flip `open` without going through `openPopover`;\n  // reset on the closed -> open transition so a reopen never shows the\n  // previous session's stale draft or success screen.\n  const wasOpenRef = React.useRef(isOpen);\n\n  React.useEffect(() => {\n    const wasOpen = wasOpenRef.current;\n    wasOpenRef.current = isOpen;\n\n    if (isOpen && !wasOpen) {\n      clearTimers();\n      setFormState(\"idle\");\n      setFeedback(\"\");\n    }\n  }, [clearTimers, isOpen]);\n\n  const submitFeedback = React.useCallback(async () => {\n    if (!trimmedFeedback || formState !== \"idle\") return;\n\n    clearTimers();\n    setFormState(\"loading\");\n\n    try {\n      await Promise.all([onSubmit?.(trimmedFeedback), wait(loadingDuration)]);\n    } catch (error) {\n      if (!isMountedRef.current) return;\n      setFormState(\"idle\");\n      onError?.(error);\n      return;\n    }\n\n    if (!isMountedRef.current) return;\n\n    setFormState(\"success\");\n    timers.current = [\n      window.setTimeout(() => {\n        setOpen(false);\n        triggerRef.current?.focus();\n      }, successDuration),\n    ];\n  }, [\n    clearTimers,\n    formState,\n    loadingDuration,\n    onError,\n    onSubmit,\n    setOpen,\n    successDuration,\n    trimmedFeedback,\n  ]);\n\n  React.useEffect(() => {\n    isMountedRef.current = true;\n    return () => {\n      isMountedRef.current = false;\n      clearTimers();\n    };\n  }, [clearTimers]);\n\n  React.useEffect(() => {\n    if (!isOpen) return;\n\n    const handlePointerDown = (event: PointerEvent) => {\n      const popover = popoverRef.current;\n\n      if (!popover || popover.contains(event.target as Node)) return;\n\n      closePopover();\n    };\n\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [closePopover, isOpen]);\n\n  React.useEffect(() => {\n    if (!isOpen) return;\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        closePopover({ restoreFocus: true });\n        return;\n      }\n\n      if (\n        (event.ctrlKey || event.metaKey) &&\n        event.key === \"Enter\" &&\n        formState === \"idle\" &&\n        // Only submit when focus is inside the popover, so Cmd/Ctrl+Enter in\n        // some other form on the page doesn't send this one.\n        popoverRef.current?.contains(document.activeElement)\n      ) {\n        event.preventDefault();\n        void submitFeedback();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [closePopover, formState, isOpen, submitFeedback]);\n\n  // Match the reference: the popover morph uses Motion's default layout transition\n  // (tween, 0.45s, ease [0.4, 0, 0.1, 1]) by leaving `transition` undefined.\n  const layoutTransition = shouldReduceMotion ? INSTANT_TRANSITION : undefined;\n  const contentTransition = shouldReduceMotion\n    ? INSTANT_TRANSITION\n    : CONTENT_TRANSITION;\n  const submitTransition = shouldReduceMotion\n    ? INSTANT_TRANSITION\n    : SUBMIT_TRANSITION;\n\n  return (\n    <div\n      data-slot=\"feedback-popover\"\n      className={cn(\n        \"relative inline-flex max-w-full [--feedback-popover-width:min(22rem,calc(100vw-2rem))]\",\n        className,\n      )}\n      {...props}\n    >\n      <LayoutGroup id={reactId}>\n        <motion.button\n          ref={triggerRef}\n          type=\"button\"\n          layoutId=\"feedback-popover-wrapper\"\n          layoutDependency={TRIGGER_LAYOUT_DEPENDENCY}\n          aria-haspopup=\"dialog\"\n          aria-expanded={isOpen}\n          aria-controls={isOpen ? reactId : undefined}\n          onClick={openPopover}\n          transition={layoutTransition}\n          style={{ borderRadius: 8 }}\n          className={cn(\n            buttonVariants({ variant: \"outline\", size: \"default\" }),\n            \"relative overflow-hidden transition-colors motion-reduce:transition-none\",\n          )}\n        >\n          <motion.span\n            layoutId=\"feedback-popover-title\"\n            layoutDependency={TRIGGER_LAYOUT_DEPENDENCY}\n            transition={layoutTransition}\n            className=\"leading-6\"\n          >\n            {triggerLabel}\n          </motion.span>\n        </motion.button>\n\n        <AnimatePresence>\n          {isOpen ? (\n            <motion.div\n              key=\"popover\"\n              id={reactId}\n              ref={popoverRef}\n              layoutId=\"feedback-popover-wrapper\"\n              layoutDependency={POPOVER_LAYOUT_DEPENDENCY}\n              role=\"dialog\"\n              aria-labelledby={titleId}\n              aria-describedby={\n                formState === \"success\" ? descriptionId : undefined\n              }\n              transition={layoutTransition}\n              style={{ borderRadius: 12 }}\n              className=\"absolute left-1/2 top-1/2 z-50 h-48 w-(--feedback-popover-width) -translate-x-1/2 -translate-y-1/2 overflow-hidden border bg-background text-foreground shadow-sm\"\n            >\n              <motion.span\n                id={titleId}\n                layoutId=\"feedback-popover-title\"\n                layoutDependency={POPOVER_LAYOUT_DEPENDENCY}\n                transition={layoutTransition}\n                data-state={formState}\n                data-has-feedback={feedback ? \"true\" : \"false\"}\n                className=\"pointer-events-none absolute left-3 top-3 text-sm font-medium leading-6 text-muted-foreground data-[has-feedback=true]:opacity-0! data-[state=success]:opacity-0!\"\n              >\n                {textareaLabel}\n              </motion.span>\n\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {formState === \"success\" ? (\n                  <motion.div\n                    key=\"success\"\n                    initial={\n                      shouldReduceMotion\n                        ? false\n                        : {\n                            opacity: 0,\n                            transform: \"translateY(-32px)\",\n                            filter: \"blur(4px)\",\n                          }\n                    }\n                    animate={{\n                      opacity: 1,\n                      transform: \"translateY(0px)\",\n                      filter: \"blur(0px)\",\n                    }}\n                    exit={\n                      shouldReduceMotion\n                        ? undefined\n                        : {\n                            opacity: 0,\n                            transform: \"translateY(8px)\",\n                            filter: \"blur(4px)\",\n                          }\n                    }\n                    transition={contentTransition}\n                    className=\"flex h-full flex-col items-center justify-center gap-3 px-8 py-8 text-center\"\n                  >\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary\"\n                    >\n                      <CheckCircle2 className=\"size-6\" />\n                    </span>\n                    <div className=\"flex flex-col gap-1\">\n                      <h3 className=\"text-base font-semibold\">\n                        {successTitle}\n                      </h3>\n                      <p\n                        id={descriptionId}\n                        className=\"text-sm leading-5 text-muted-foreground\"\n                      >\n                        {successDescription}\n                      </p>\n                    </div>\n                  </motion.div>\n                ) : (\n                  <motion.form\n                    key=\"form\"\n                    exit={\n                      shouldReduceMotion\n                        ? undefined\n                        : {\n                            opacity: 0,\n                            transform: \"translateY(8px)\",\n                            filter: \"blur(4px)\",\n                          }\n                    }\n                    transition={contentTransition}\n                    onSubmit={(event) => {\n                      event.preventDefault();\n                      void submitFeedback();\n                    }}\n                    className=\"flex h-full flex-col\"\n                  >\n                    <label htmlFor={`${reactId}-textarea`} className=\"sr-only\">\n                      {textareaLabel}\n                    </label>\n                    <textarea\n                      id={`${reactId}-textarea`}\n                      autoFocus\n                      required\n                      value={feedback}\n                      disabled={formState === \"loading\"}\n                      onChange={(event) => setFeedback(event.target.value)}\n                      className=\"min-h-0 flex-1 resize-none bg-transparent px-3 pb-3 pt-3 text-sm leading-6 outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60\"\n                    />\n\n                    <div className=\"relative flex items-center justify-end border-t border-dashed bg-muted/30 px-3 py-2.5\">\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"absolute -left-px top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background\"\n                      />\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"absolute -right-px top-1/2 size-3 -translate-y-1/2 translate-x-1/2 rounded-full border bg-background\"\n                      />\n                      <Button\n                        type=\"submit\"\n                        size=\"sm\"\n                        disabled={!trimmedFeedback || formState === \"loading\"}\n                        className=\"min-w-32 overflow-hidden\"\n                      >\n                        <AnimatePresence mode=\"popLayout\" initial={false}>\n                          <motion.span\n                            key={formState}\n                            initial={\n                              shouldReduceMotion\n                                ? false\n                                : {\n                                    opacity: 0,\n                                    transform: \"translateY(-25px)\",\n                                  }\n                            }\n                            animate={{\n                              opacity: 1,\n                              transform: \"translateY(0px)\",\n                            }}\n                            exit={\n                              shouldReduceMotion\n                                ? undefined\n                                : {\n                                    opacity: 0,\n                                    transform: \"translateY(25px)\",\n                                  }\n                            }\n                            transition={submitTransition}\n                            className=\"inline-flex items-center justify-center gap-1.5\"\n                          >\n                            {formState === \"loading\" ? (\n                              <>\n                                <Spinner />\n                                <span className=\"sr-only\">\n                                  {loadingAnnouncement}\n                                </span>\n                              </>\n                            ) : (\n                              <span>{submitLabel}</span>\n                            )}\n                          </motion.span>\n                        </AnimatePresence>\n                      </Button>\n                    </div>\n                  </motion.form>\n                )}\n              </AnimatePresence>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </LayoutGroup>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/feedback-popover.tsx"
    }
  ],
  "meta": {
    "tags": [
      "textarea",
      "submit",
      "confirmation",
      "keyboard-submit",
      "popover"
    ],
    "effects": [
      "expand",
      "success-state",
      "shared-layout"
    ]
  },
  "categories": [
    "overlay"
  ],
  "type": "registry:ui"
}