{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-popover",
  "type": "registry:ui",
  "title": "Morph Popover",
  "description": "A button that grows into its own form and folds back into a tick.",
  "categories": [
    "dialogs"
  ],
  "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/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/morph-popover.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { Loader, X } from \"@/lib/icons\";\n\ntype Ctx = {\n  open: boolean;\n  titleId: string;\n  /** Close and put focus back on the button. */\n  close: () => void;\n  /** Close with the success tick on the button, e.g. after a form was sent. */\n  complete: () => void;\n  draft: string;\n  setDraft: (v: string) => void;\n};\nconst MorphContext = createContext<Ctx | null>(null);\n\n/** Close, complete and the kept draft, for content you build yourself. */\nexport function useMorphPopover() {\n  const ctx = use(MorphContext);\n  if (!ctx) throw new Error(\"useMorphPopover must be used inside <MorphPopover>\");\n  return ctx;\n}\n\nconst FOCUSABLE = 'textarea, input, select, button:not([data-morph-close]), [href], [tabindex]:not([tabindex=\"-1\"])';\n\nexport type MorphPopoverProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> & {\n  /** The button's content while closed. */\n  label: React.ReactNode;\n  /** Shown on the button, with a drawn tick, for a moment after complete(). */\n  successLabel?: string;\n  /** Heading of the panel, and its accessible name. */\n  title: React.ReactNode;\n  /** Which way the panel grows from the button. */\n  side?: \"top\" | \"bottom\";\n  /** Which edge of the button stays put: \"end\" grows leftwards, \"start\" rightwards. */\n  align?: \"start\" | \"end\";\n  /** Panel width in px. Capped to the room between the anchored edge and the viewport, less 16px. */\n  width?: number;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Milliseconds the success label stays before the button returns to rest. */\n  successDuration?: number;\n  children: React.ReactNode;\n};\n\nexport function MorphPopover({\n  label,\n  successLabel = \"Sent\",\n  title,\n  side = \"bottom\",\n  align = \"end\",\n  width = 320,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  successDuration = 1800,\n  className,\n  children,\n  ...rest\n}: MorphPopoverProps) {\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [done, setDone] = useState(false);\n  const [draft, setDraft] = useState(\"\");\n  const [panelW, setPanelW] = useState(width);\n  const [panelH, setPanelH] = useState<number | null>(null);\n  // Escape closes on the same frame; everything else morphs.\n  const [instant, setInstant] = useState(false);\n  const titleId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const restoreFocus = useRef(false);\n  const doneTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(doneTimer.current), []);\n\n  const show = () => {\n    window.clearTimeout(doneTimer.current);\n    setDone(false);\n    setInstant(false);\n    // Never wider than the room between the anchored edge and the far side of the viewport.\n    const box = rootRef.current?.getBoundingClientRect();\n    const room = box ? (align === \"end\" ? box.right - 16 : window.innerWidth - box.left - 16) : window.innerWidth - 32;\n    setPanelW(Math.max(box?.width ?? 0, Math.min(width, room)));\n    setOpen(true);\n  };\n\n  const close = useCallback(\n    (opts: { restore?: boolean; instant?: boolean } = {}) => {\n      restoreFocus.current = opts.restore ?? true;\n      setInstant(!!opts.instant);\n      setOpen(false);\n    },\n    [setOpen],\n  );\n\n  const complete = useCallback(() => {\n    setDraft(\"\");\n    setDone(true);\n    close();\n    window.clearTimeout(doneTimer.current);\n    doneTimer.current = window.setTimeout(() => setDone(false), successDuration);\n  }, [close, successDuration]);\n\n  // Focus follows the surface: into the panel's first field on open, back to the button on close.\n  useEffect(() => {\n    if (open) {\n      panelRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true });\n    } else if (restoreFocus.current) {\n      restoreFocus.current = false;\n      buttonRef.current?.focus({ preventScroll: true });\n    }\n  }, [open]);\n\n  // Outside presses close without stealing focus from wherever the press landed.\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) close({ restore: false });\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [open, close]);\n\n  // The panel's natural height, so the surface can spring to an exact number.\n  useEffect(() => {\n    const el = panelRef.current;\n    if (!open || !el) return;\n    const ro = new ResizeObserver(([entry]) => setPanelH(entry.borderBoxSize[0].blockSize + 2));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [open]);\n\n  const sizeTransition = reduce || instant ? { duration: 0 } : open ? spring.soft : spring.snappy;\n  const fade = (delay: number, d = 0.2) => ({ duration: reduce ? 0.12 : d, delay: reduce || instant ? 0 : delay, ease: ease.out });\n  const blur = reduce ? {} : { filter: \"blur(4px)\" };\n  const corner = cn(side === \"bottom\" ? \"top-0\" : \"bottom-0\", align === \"end\" ? \"right-0\" : \"left-0\");\n  // The button is the closed surface's full outer box, overlapping the 1px border so its label centers exactly.\n  const buttonCorner = cn(side === \"bottom\" ? \"-top-px\" : \"-bottom-px\", align === \"end\" ? \"-right-px\" : \"-left-px\");\n  const buttonText = done ? successLabel : label;\n\n  const ctx: Ctx = { open, titleId, close: () => close(), complete, draft, setDraft };\n\n  return (\n    <MorphContext value={ctx}>\n      <div ref={rootRef} data-state={open ? \"open\" : \"closed\"} className={cn(\"relative inline-grid\", className)} {...rest}>\n        {/* Holds the closed button's footprint in the layout, sized for both labels, so nothing around it moves. */}\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 inline-grid h-8 items-center px-3 text-[12.5px] font-medium\">\n          <span className=\"col-start-1 row-start-1 flex items-center gap-2 whitespace-nowrap\">{label}</span>\n          <span className=\"col-start-1 row-start-1 flex items-center gap-2 whitespace-nowrap\">\n            <span className=\"size-4\" />\n            {successLabel}\n          </span>\n        </span>\n\n        <motion.div\n          initial={false}\n          animate={{\n            width: open ? panelW : \"100%\",\n            height: open ? (panelH ?? \"100%\") : \"100%\",\n            borderRadius: open ? 14 : 8,\n          }}\n          transition={sizeTransition}\n          className={cn(\n            \"absolute z-(--z-popover) overflow-hidden border border-line-2 bg-raised text-fg\",\n            // Press and focus live on the surface, so the whole button squashes and the ring isn't clipped.\n            \"transition-[box-shadow,scale,border-color] duration-200 has-[>button:active]:scale-[0.97] has-[>button:active]:duration-75\",\n            \"has-[>button:focus-visible]:outline-solid has-[>button:focus-visible]:outline-1 has-[>button:focus-visible]:outline-offset-2 has-[>button:focus-visible]:outline-fg-3\",\n            open ? \"shadow-pop\" : \"shadow-[var(--shadow)] has-[>button:hover]:border-fg-4\",\n            corner,\n          )}\n        >\n          <AnimatePresence initial={false}>\n            {!open ? (\n              <motion.button\n                key=\"button\"\n                ref={buttonRef}\n                type=\"button\"\n                aria-haspopup=\"dialog\"\n                aria-expanded={false}\n                onClick={show}\n                initial={{ opacity: 0, ...blur }}\n                animate={{ opacity: 1, filter: \"blur(0px)\", transition: fade(0.12, 0.18) }}\n                exit={{ opacity: 0, ...blur, transition: fade(0, 0.1) }}\n                className={cn(\n                  \"group/morph absolute inline-grid h-8 items-center justify-items-center whitespace-nowrap px-3 text-[12.5px] font-medium tracking-[-0.005em] outline-none\",\n                  \"transition-[background-color] duration-150 hover:bg-hover\",\n                  buttonCorner,\n                )}\n              >\n                {/* Both labels reserve the cell (same as the spacer) so the live one stays centered; the swap pops, the tick draws. */}\n                <span aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-2\">\n                  {label}\n                </span>\n                <span aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-2\">\n                  <span className=\"size-4\" />\n                  {successLabel}\n                </span>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={done ? \"done\" : \"rest\"}\n                    className=\"col-start-1 row-start-1 flex items-center gap-2\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n                    transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n                  >\n                    {done && <Tick reduce={!!reduce} />}\n                    {buttonText}\n                  </motion.span>\n                </AnimatePresence>\n              </motion.button>\n            ) : (\n              <motion.div\n                key=\"panel\"\n                ref={panelRef}\n                role=\"dialog\"\n                aria-labelledby={titleId}\n                style={{ width: panelW - 2 }}\n                initial={{ opacity: 0, ...blur }}\n                animate={{ opacity: 1, filter: \"blur(0px)\", transition: fade(0.07) }}\n                exit={{ opacity: 0, ...blur, transition: fade(0, 0.1) }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Escape\") {\n                    e.stopPropagation();\n                    close({ instant: true });\n                  }\n                }}\n                onBlur={(e) => {\n                  // Tabbing out of the panel closes it, like any non-modal popover.\n                  const next = e.relatedTarget as Node | null;\n                  if (next && !rootRef.current?.contains(next)) close({ restore: false });\n                }}\n                className={cn(\"absolute flex flex-col gap-3 p-3\", corner)}\n              >\n                <div className=\"flex items-center justify-between gap-3\">\n                  <h2 id={titleId} className=\"min-w-0 truncate pl-0.5 text-[13.5px] font-medium tracking-[-0.01em] text-fg\">\n                    {title}\n                  </h2>\n                  <button\n                    type=\"button\"\n                    data-morph-close\n                    aria-label=\"Close\"\n                    onClick={() => close()}\n                    className={cn(\n                      \"relative -my-1 -mr-1 grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                      \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                    )}\n                  >\n                    <X size={14} />\n                  </button>\n                </div>\n                {children}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </motion.div>\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {done ? successLabel : \"\"}\n        </span>\n      </div>\n    </MorphContext>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-success\">\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.34, ease: ease.out, delay: 0.16 }}\n      />\n    </svg>\n  );\n}\n\nexport type MorphPopoverFormProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\"> & {\n  /** Receives the trimmed text. Resolve to finish with the tick; reject to keep the panel open with an error. */\n  onSubmit: (text: string) => void | Promise<void>;\n  placeholder?: string;\n  submitLabel?: string;\n  /** Accessible label of the text field. */\n  fieldLabel?: string;\n  errorMessage?: string;\n  maxLength?: number;\n};\n\n/** A short text form for the panel: ⌘↵ to send, busy in place, the draft survives closing. */\nexport function MorphPopoverForm({\n  onSubmit,\n  placeholder = \"What could be better?\",\n  submitLabel = \"Send\",\n  fieldLabel = \"Message\",\n  errorMessage = \"Couldn’t send. Try again.\",\n  maxLength = 1000,\n  className,\n  ...rest\n}: MorphPopoverFormProps) {\n  const { draft, setDraft, complete } = useMorphPopover();\n  const [busy, setBusy] = useState(false);\n  const [failed, setFailed] = useState(false);\n  const errorId = useId();\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => void (alive.current = false);\n  }, []);\n  const empty = !draft.trim();\n\n  const send = async () => {\n    if (busy || empty) return;\n    setBusy(true);\n    setFailed(false);\n    try {\n      await onSubmit(draft.trim());\n      if (alive.current) complete();\n    } catch {\n      if (alive.current) setFailed(true);\n    } finally {\n      if (alive.current) setBusy(false);\n    }\n  };\n\n  const near = draft.length > maxLength * 0.9;\n\n  return (\n    <form\n      className={cn(\"flex flex-col gap-2.5\", className)}\n      onSubmit={(e) => {\n        e.preventDefault();\n        send();\n      }}\n      {...rest}\n    >\n      <label className=\"sr-only\" htmlFor={errorId + \"field\"}>\n        {fieldLabel}\n      </label>\n      <textarea\n        id={errorId + \"field\"}\n        value={draft}\n        maxLength={maxLength}\n        placeholder={placeholder}\n        readOnly={busy}\n        aria-invalid={failed || undefined}\n        aria-describedby={failed ? errorId : undefined}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          if (failed) setFailed(false);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n            e.preventDefault();\n            send();\n          }\n        }}\n        rows={4}\n        className={cn(\n          \"min-h-24 w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-2 text-base leading-5 text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\",\n          \"transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-3 focus:ring-fg/10\",\n          \"aria-invalid:border-danger/60 read-only:text-fg-2\",\n        )}\n      />\n      <div className=\"flex min-h-8 items-center justify-between gap-3\">\n        <p id={errorId} role={failed ? \"alert\" : undefined} className={cn(\"min-w-0 truncate pl-0.5 text-[12px]\", failed ? \"text-danger\" : \"text-fg-4\")}>\n          {failed ? (\n            errorMessage\n          ) : near ? (\n            <span className=\"tabular\">\n              {draft.length}/{maxLength}\n            </span>\n          ) : (\n            <span className=\"hidden pointer-fine:inline\">\n              <kbd className=\"font-sans\">⌘↵</kbd> to send\n            </span>\n          )}\n        </p>\n        <button\n          type=\"submit\"\n          aria-disabled={empty || undefined}\n          aria-busy={busy || undefined}\n          className={cn(\n            \"relative inline-grid h-8 shrink-0 place-items-center rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame outline-none\",\n            \"transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"aria-disabled:pointer-events-none aria-disabled:opacity-40 aria-busy:pointer-events-none\",\n          )}\n        >\n          <span className={cn(\"col-start-1 row-start-1 transition-opacity duration-150\", busy && \"opacity-0\")}>{submitLabel}</span>\n          {busy && <Loader size={14} className=\"col-start-1 row-start-1 animate-spin\" />}\n        </button>\n      </div>\n    </form>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/morph-popover"
}
