{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["lucide-react", "motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "feedback.tsx",
      "content": "\"use client\";\n\nimport { Loader2 } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/** Horizontal padding on the collapsed pill (px-3 on each side). */\nconst COLLAPSED_PADDING_X = 14;\n/** Starts at 0 — corrected to the real content width before the pill fades in. */\nconst DEFAULT_COLLAPSED_WIDTH = 0;\nconst COLLAPSED_HEIGHT = 36;\nconst EXPANDED_WIDTH = 336;\nconst EXPANDED_HEIGHT = 220;\nconst EXPANDED_RADIUS = 20;\n\n/**\n * The exact clamp CSS would apply anyway (min(width,height)/2) for a fully\n * pill-shaped collapsed state. Animating *to* an oversized value like 999\n * hits that clamp almost immediately — while the box is still wide — so the\n * corners look fully round way too early and the shape reads as a blob\n * mid-shrink instead of a rectangle that gradually rounds off at the end.\n */\nconst COLLAPSED_RADIUS = COLLAPSED_HEIGHT / 2;\n\n/** Box grows with a snappy spring on open. */\nconst OPEN_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 380,\n  damping: 34,\n  mass: 0.9,\n};\n\n/**\n * Box shrinks back down with a slow, even ease (no sharp acceleration at the\n * end, unlike a spring settling) so it doesn't feel like a quick snap. Real\n * width/height/border-radius are animated directly here — not Motion's\n * `layout` scale trick — so the border, shadow, and corners never stretch or\n * ring mid-transition.\n */\nconst CLOSE_TRANSITION: Transition = {\n  type: \"tween\",\n  duration: 0.55,\n  ease: [0.65, 0, 0.35, 1],\n};\n\n/**\n * The dot + \"Feedback\" label travel between the pill and the panel header\n * via a shared layoutId. This transition is intentionally the same for both\n * open and close — the box's own resize can be asymmetric (spring vs ease),\n * but the label should trace the exact same path in reverse, not a\n * different one, or the two directions read as unrelated motions.\n */\nconst LABEL_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 40,\n  mass: 0.8,\n};\n\nconst CONTENT_FADE: Transition = { duration: 0.4, ease: [0.65, 0, 0.35, 1] };\nconst INSTANT: Transition = { duration: 0 };\n\nfunction SendIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      fill=\"none\"\n      height=\"13\"\n      viewBox=\"0 0 14 14\"\n      width=\"13\"\n    >\n      <path\n        d=\"M7 12V2M7 2L2.5 6.5M7 2L11.5 6.5\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.75\"\n      />\n    </svg>\n  );\n}\n\ntype SharedElementProps = {\n  dotLayoutId: string;\n  labelLayoutId: string;\n  labelTransition: Transition;\n};\n\ntype CollapsedTriggerProps = SharedElementProps & {\n  fadeTransition: Transition;\n  label: string;\n  onMeasuredWidth: (width: number) => void;\n  onOpen: () => void;\n  reduceMotion: boolean;\n  width: number;\n  height: number;\n};\n\nfunction CollapsedTrigger({\n  dotLayoutId,\n  fadeTransition,\n  height,\n  label,\n  labelLayoutId,\n  labelTransition,\n  onMeasuredWidth,\n  onOpen,\n  reduceMotion,\n  width,\n}: CollapsedTriggerProps) {\n  const contentRef = useRef<HTMLSpanElement>(null);\n\n  // Measure the content so the pill hugs \"label | Feedback\" exactly instead\n  // of sitting inside a fixed, guessed-at width. Runs fresh on every mount\n  // (the trigger unmounts while the panel is open), so it always measures a\n  // live, currently-attached node rather than a stale one from a previous\n  // mount. The shared layoutId dot/label can transiently report zero\n  // natural size mid-projection while Motion sets up their shared-element\n  // transition — a real render never measures at 0, so that reading is\n  // simply discarded rather than shrinking the pill down to a sliver.\n  useLayoutEffect(() => {\n    const node = contentRef.current;\n    if (!node) return;\n\n    const measure = () => {\n      if (node.scrollWidth === 0) return;\n      onMeasuredWidth(node.scrollWidth + COLLAPSED_PADDING_X * 2);\n    };\n\n    measure();\n\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [onMeasuredWidth]);\n\n  return (\n    <motion.button\n      animate={{ opacity: 1, transition: { delay: 0.08 } }}\n      aria-expanded={false}\n      className=\"absolute top-0 left-0 flex items-center justify-center\"\n      data-slot=\"feedback-form-trigger\"\n      exit={{ opacity: 0, transition: fadeTransition }}\n      initial={reduceMotion ? false : { opacity: 0 }}\n      key=\"collapsed\"\n      onClick={onOpen}\n      style={{ width, height }}\n      transition={reduceMotion ? INSTANT : undefined}\n      type=\"button\"\n    >\n      <span className=\"flex items-center gap-1.5\" ref={contentRef}>\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"size-2.5 shrink-0 rounded-full bg-[var(--color-brand,#3b82f6)]\"\n          layoutId={dotLayoutId}\n          transition={labelTransition}\n        />\n        <span className=\"whitespace-nowrap font-semibold text-foreground text-xs leading-none\">\n          {label}\n        </span>\n        <span aria-hidden=\"true\" className=\"h-3 w-px shrink-0 bg-border\" />\n        <motion.span\n          className=\"shrink-0 font-medium text-muted-foreground text-xs leading-none\"\n          layoutId={labelLayoutId}\n          transition={labelTransition}\n        >\n          Feedback\n        </motion.span>\n      </span>\n    </motion.button>\n  );\n}\n\ntype ExpandedPanelProps = SharedElementProps & {\n  fadeTransition: Transition;\n  hasValue: boolean;\n  height: number;\n  onChange: (value: string) => void;\n  onSubmit: () => void;\n  placeholder: string | undefined;\n  reduceMotion: boolean;\n  submitting: boolean;\n  textareaRef: RefObject<HTMLTextAreaElement | null>;\n  value: string;\n  width: number;\n};\n\nfunction ExpandedPanel({\n  dotLayoutId,\n  fadeTransition,\n  hasValue,\n  height,\n  labelLayoutId,\n  labelTransition,\n  onChange,\n  onSubmit,\n  placeholder,\n  reduceMotion,\n  submitting,\n  textareaRef,\n  value,\n  width,\n}: ExpandedPanelProps) {\n  return (\n    <motion.div\n      animate={{ opacity: 1, transition: { delay: 0.08 } }}\n      aria-label=\"Feedback\"\n      className=\"absolute top-0 left-0 flex flex-col gap-3 p-4\"\n      data-slot=\"feedback-form-panel\"\n      exit={{ opacity: 0, transition: fadeTransition }}\n      initial={reduceMotion ? false : { opacity: 0 }}\n      key=\"expanded\"\n      role=\"dialog\"\n      style={{ width, height }}\n      transition={reduceMotion ? INSTANT : undefined}\n    >\n      <div className=\"flex items-center gap-2\">\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"size-2.5 shrink-0 rounded-full bg-[var(--color-brand,#3b82f6)]\"\n          layoutId={dotLayoutId}\n          transition={labelTransition}\n        />\n        <motion.span\n          className=\"font-medium text-foreground leading-none\"\n          layoutId={labelLayoutId}\n          transition={labelTransition}\n        >\n          Feedback\n        </motion.span>\n      </div>\n\n      <motion.textarea\n        animate={{ opacity: 1, transition: { delay: 0.1 } }}\n        aria-label=\"Feedback message\"\n        className=\"w-full flex-1 resize-none bg-transparent text-foreground leading-relaxed outline-none placeholder:text-muted-foreground disabled:opacity-60\"\n        disabled={submitting}\n        initial={reduceMotion ? false : { opacity: 0 }}\n        onChange={(event) => onChange(event.target.value)}\n        onKeyDown={(event) => {\n          if ((event.metaKey || event.ctrlKey) && event.key === \"Enter\") {\n            event.preventDefault();\n            onSubmit();\n          }\n        }}\n        placeholder={placeholder}\n        ref={textareaRef}\n        transition={reduceMotion ? INSTANT : undefined}\n        value={value}\n      />\n\n      <motion.div\n        animate={{ opacity: 1, transition: { delay: 0.16 } }}\n        className=\"flex items-center justify-between gap-3\"\n        initial={reduceMotion ? false : { opacity: 0 }}\n        transition={reduceMotion ? INSTANT : undefined}\n      >\n        <span className=\"flex items-center gap-1 text-muted-foreground text-xs\">\n          <kbd className=\"rounded-md bg-muted px-1.5 py-1 font-sans\">⌘</kbd>\n          <kbd className=\"rounded-md bg-muted px-1.5 py-1 font-sans\">Enter</kbd>\n          <span>to send</span>\n        </span>\n        <button\n          aria-label={submitting ? \"Sending feedback\" : \"Send feedback\"}\n          className=\"flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 font-medium text-background text-xs transition-opacity disabled:opacity-50\"\n          disabled={submitting || !hasValue}\n          onClick={onSubmit}\n          type=\"button\"\n        >\n          {submitting ? (\n            <Loader2 aria-hidden=\"true\" className=\"size-3.5 animate-spin\" />\n          ) : (\n            <SendIcon />\n          )}\n          {submitting ? \"Sending\" : \"Send\"}\n        </button>\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport type FeedbackFormProps = {\n  /** Label shown next to the indicator dot in the collapsed pill. */\n  label?: string;\n  /** Placeholder for the expanded textarea. Unset by default. */\n  placeholder?: string;\n  /**\n   * Called with the trimmed value on submit (Send button or Cmd/Ctrl+Enter).\n   * May return a promise — the Send button shows a loading state until it\n   * resolves, then the panel collapses back to the pill. Throwing keeps the\n   * panel open so the user can retry.\n   */\n  onSubmit?: (value: string) => void | Promise<void>;\n  className?: string;\n};\n\nexport function FeedbackForm({\n  label = \"Iconiq UI\",\n  placeholder,\n  onSubmit,\n  className,\n}: FeedbackFormProps) {\n  const [open, setOpen] = useState(false);\n  const [value, setValue] = useState(\"\");\n  const [submitting, setSubmitting] = useState(false);\n  const [collapsedWidth, setCollapsedWidth] = useState(DEFAULT_COLLAPSED_WIDTH);\n\n  const uid = useId();\n  const dotLayoutId = `${uid}-dot`;\n  const labelLayoutId = `${uid}-label`;\n\n  const reduceMotion = useReducedMotion() ?? false;\n  const containerRef = useRef<HTMLDivElement>(null);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n  // The very first measurement corrects the pill from its pre-measurement\n  // guess to its real content-fit width — that correction must apply\n  // instantly, or it plays as a visible open/close-style shrink on load.\n  // This is a ref (not state) read directly during render: it must still\n  // read `false` on the render that *applies* the corrected width, and only\n  // flip afterward, once that instant correction has already committed —\n  // updating it via the same setState call the width change came from would\n  // make both land in one render, so the correction itself would already\n  // see it as \"measured\" and animate instead of snapping.\n  const hasMeasuredWidthRef = useRef(false);\n  const lastWidthRef = useRef(collapsedWidth);\n  useEffect(() => {\n    if (lastWidthRef.current !== collapsedWidth) {\n      lastWidthRef.current = collapsedWidth;\n      hasMeasuredWidthRef.current = true;\n    }\n  }, [collapsedWidth]);\n\n  const hasValue = value.trim() !== \"\";\n\n  const close = useCallback(() => {\n    if (submitting) return;\n    setOpen(false);\n    setValue(\"\");\n  }, [submitting]);\n\n  const submit = useCallback(async () => {\n    if (submitting || !hasValue) return;\n\n    setSubmitting(true);\n    try {\n      await onSubmit?.(value.trim());\n      setOpen(false);\n      setValue(\"\");\n    } catch {\n      // Swallow — the caller is expected to surface its own error UI\n      // (e.g. a toast). The panel stays open so the user can retry.\n    } finally {\n      setSubmitting(false);\n    }\n  }, [hasValue, onSubmit, submitting, value]);\n\n  // Dismiss on outside click or Escape while open.\n  useEffect(() => {\n    if (!open) return;\n\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!containerRef.current?.contains(event.target as Node)) close();\n    };\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") close();\n    };\n\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      document.removeEventListener(\"pointerdown\", handlePointerDown);\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [open, close]);\n\n  // Autofocus the textarea once the panel opens.\n  useEffect(() => {\n    if (!open) return;\n    const frame = requestAnimationFrame(() => textareaRef.current?.focus());\n    return () => cancelAnimationFrame(frame);\n  }, [open]);\n\n  const boxTransition =\n    reduceMotion || !hasMeasuredWidthRef.current\n      ? INSTANT\n      : open\n        ? OPEN_TRANSITION\n        : CLOSE_TRANSITION;\n  const labelTransition = reduceMotion ? INSTANT : LABEL_TRANSITION;\n  const fadeTransition = reduceMotion ? INSTANT : CONTENT_FADE;\n\n  return (\n    <motion.div\n      animate={{\n        width: open ? EXPANDED_WIDTH : collapsedWidth,\n        height: open ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,\n        borderRadius: open ? EXPANDED_RADIUS : COLLAPSED_RADIUS,\n      }}\n      className={cn(\n        \"relative overflow-hidden border border-border bg-white text-sm shadow-[0_10px_30px_-18px_rgba(0,0,0,0.35)] dark:bg-zinc-950\",\n        className\n      )}\n      data-slot=\"feedback-form\"\n      initial={false}\n      ref={containerRef}\n      transition={boxTransition}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? (\n          <ExpandedPanel\n            dotLayoutId={dotLayoutId}\n            fadeTransition={fadeTransition}\n            hasValue={hasValue}\n            height={EXPANDED_HEIGHT}\n            labelLayoutId={labelLayoutId}\n            labelTransition={labelTransition}\n            onChange={setValue}\n            onSubmit={submit}\n            placeholder={placeholder}\n            reduceMotion={reduceMotion}\n            submitting={submitting}\n            textareaRef={textareaRef}\n            value={value}\n            width={EXPANDED_WIDTH}\n          />\n        ) : (\n          <CollapsedTrigger\n            dotLayoutId={dotLayoutId}\n            fadeTransition={fadeTransition}\n            height={COLLAPSED_HEIGHT}\n            label={label}\n            labelLayoutId={labelLayoutId}\n            labelTransition={labelTransition}\n            onMeasuredWidth={setCollapsedWidth}\n            onOpen={() => setOpen(true)}\n            reduceMotion={reduceMotion}\n            width={collapsedWidth}\n          />\n        )}\n      </AnimatePresence>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "title": "Feedback Form",
  "description": "Collapsed pill that morphs into an expanded feedback panel with a send button, loading state, and a textarea, driven by pure CSS transitions with asymmetric open/close easing."
}
