{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-subscribe",
  "title": "Expandable Subscribe",
  "description": "A compact button that expands into an email form, with async submission, loading, confirmation, and error recovery.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input-group",
    "https://ui.ericts.com/r/check-mark.json"
  ],
  "files": [
    {
      "path": "registry/base/ui/expandable-subscribe.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp, LoaderCircle } from \"lucide-react\";\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupInput,\n} from \"@/components/ui/input-group\";\n\nimport { CheckMark } from \"./check-mark\";\n\nimport \"./expandable-subscribe.css\";\n\nexport type ExpandableSubscribeProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"onSubmit\" | \"onError\" | \"defaultValue\"\n> & {\n  /** Resolve after saving the email; reject to show the retry state. */\n  onSubscribe: (email: string, signal: AbortSignal) => void | Promise<void>;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (email: string) => void;\n  /** Called when editing is cancelled by outside press, focus leaving, or Escape. */\n  onCancel?: () => void;\n  label?: string;\n  placeholder?: string;\n  inputLabel?: string;\n  submitLabel?: string;\n  loadingLabel?: string;\n  successLabel?: string;\n  errorLabel?: string;\n  disabled?: boolean;\n  /** Expanded width in pixels, capped by the available container width. */\n  expandedWidth?: number;\n  transition?: Transition;\n};\n\n/** A compact email form. Success remains visible until the component is remounted. */\nexport function ExpandableSubscribe({\n  onSubscribe,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onCancel,\n  label = \"Email me\",\n  placeholder = \"Your email\",\n  inputLabel = \"Email address\",\n  submitLabel = \"Subscribe\",\n  loadingLabel = \"Subscribing…\",\n  successLabel = \"You're on the list!\",\n  errorLabel = \"Something went wrong. Please try again.\",\n  disabled = false,\n  expandedWidth = 320,\n  transition = { type: \"spring\", duration: 0.3, bounce: 0 },\n  className,\n  style,\n  ref,\n  onBlur,\n  ...props\n}: ExpandableSubscribeProps) {\n  const [state, setState] = React.useState<\n    \"closed\" | \"editing\" | \"loading\" | \"success\"\n  >(\"closed\");\n  const [localValue, setLocalValue] = React.useState(defaultValue);\n  const [failed, setFailed] = React.useState(false);\n  const email = value ?? localValue;\n  const reduceMotion = useReducedMotion();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const requestRef = React.useRef<AbortController | null>(null);\n  const cancelledRef = React.useRef(false);\n  const restoreTriggerFocusRef = React.useRef(false);\n  const errorId = React.useId();\n\n  React.useEffect(() => () => requestRef.current?.abort(), []);\n\n  React.useEffect(() => {\n    if (state === \"editing\") {\n      cancelledRef.current = false;\n      inputRef.current?.focus();\n    } else if (state === \"closed\" && restoreTriggerFocusRef.current) {\n      restoreTriggerFocusRef.current = false;\n      triggerRef.current?.focus();\n    }\n  }, [state]);\n\n  const cancel = React.useCallback(\n    (restoreFocus = false) => {\n      if (state !== \"editing\" || cancelledRef.current) return;\n      // An outside pointer press can also cause blur in the same event cycle.\n      cancelledRef.current = true;\n      restoreTriggerFocusRef.current = restoreFocus;\n      setLocalValue(\"\");\n      setFailed(false);\n      setState(\"closed\");\n      onValueChange?.(\"\");\n      onCancel?.();\n    },\n    [state, onValueChange, onCancel],\n  );\n\n  React.useEffect(() => {\n    if (state !== \"editing\") return;\n    const document = rootRef.current?.ownerDocument;\n    const handleOutsidePress = (event: PointerEvent) => {\n      const root = rootRef.current;\n      if (root && !event.composedPath().includes(root)) cancel();\n    };\n    document?.addEventListener(\"pointerdown\", handleOutsidePress, true);\n    return () =>\n      document?.removeEventListener(\"pointerdown\", handleOutsidePress, true);\n  }, [state, cancel]);\n\n  async function submit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    if (disabled || state !== \"editing\" || requestRef.current) return;\n    const controller = new AbortController();\n    requestRef.current = controller;\n    setFailed(false);\n    setState(\"loading\");\n    rootRef.current?.focus();\n    try {\n      await onSubscribe(email.trim(), controller.signal);\n      if (!controller.signal.aborted) setState(\"success\");\n    } catch {\n      if (!controller.signal.aborted) {\n        setFailed(true);\n        setState(\"editing\");\n      }\n    } finally {\n      if (requestRef.current === controller) requestRef.current = null;\n    }\n  }\n\n  const editing = state === \"editing\";\n  const expanded = state !== \"closed\";\n  const status = state === \"loading\" || state === \"success\";\n  const fade = { duration: reduceMotion ? 0 : 0.15 };\n\n  return (\n    <div\n      className={cn(\"relative max-w-full outline-none\", className)}\n      style={style}\n      {...props}\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      tabIndex={-1}\n      data-slot=\"expandable-subscribe\"\n      data-state={state}\n      aria-busy={state === \"loading\"}\n      onBlur={(event) => {\n        onBlur?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget)) cancel();\n      }}\n    >\n      <motion.div\n        layout={reduceMotion ? false : true}\n        transition={transition}\n        className=\"relative h-12 max-w-full overflow-hidden rounded-xl border border-input bg-secondary text-secondary-foreground\"\n        style={{ width: expanded ? expandedWidth : \"max-content\" }}\n      >\n        <motion.div\n          layout=\"position\"\n          className=\"h-full\"\n          animate={{ opacity: expanded ? 0 : 1 }}\n          transition={fade}\n          inert={expanded}\n        >\n          <Button\n            ref={triggerRef}\n            type=\"button\"\n            variant=\"secondary\"\n            disabled={disabled}\n            aria-expanded={expanded}\n            onClick={() => setState(\"editing\")}\n            className=\"h-full rounded-xl px-5\"\n          >\n            {label}\n          </Button>\n        </motion.div>\n\n        <motion.form\n          onSubmit={submit}\n          className=\"absolute inset-0\"\n          animate={{ opacity: editing ? 1 : 0 }}\n          transition={fade}\n          inert={!editing}\n          aria-hidden={!editing}\n          onKeyDown={(event) => {\n            if (event.key === \"Escape\") {\n              event.preventDefault();\n              cancel(true);\n            }\n          }}\n        >\n          <InputGroup className=\"h-full rounded-xl border-0\">\n            <InputGroupInput\n              ref={inputRef}\n              type=\"email\"\n              required\n              autoComplete=\"email\"\n              name=\"email\"\n              aria-label={inputLabel}\n              placeholder={placeholder}\n              value={email}\n              disabled={disabled || !editing}\n              aria-describedby={failed ? errorId : undefined}\n              onChange={(event) => {\n                setLocalValue(event.target.value);\n                onValueChange?.(event.target.value);\n                setFailed(false);\n              }}\n            />\n            <InputGroupAddon align=\"inline-end\">\n              <InputGroupButton\n                type=\"submit\"\n                variant=\"secondary\"\n                size=\"icon-sm\"\n                aria-label={submitLabel}\n                disabled={disabled || !editing}\n              >\n                <ArrowUp aria-hidden />\n              </InputGroupButton>\n            </InputGroupAddon>\n          </InputGroup>\n        </motion.form>\n\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 origin-right bg-muted\"\n          initial={false}\n          animate={{\n            transform: status ? \"scaleX(1)\" : \"scaleX(0)\",\n            opacity: status ? 1 : 0,\n          }}\n          transition={reduceMotion ? { duration: 0 } : transition}\n        />\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-sm\"\n          animate={{ opacity: status ? 1 : 0 }}\n          transition={fade}\n        >\n          {state === \"loading\" ? (\n            <LoaderCircle className=\"size-4 animate-spin motion-reduce:animate-none\" />\n          ) : state === \"success\" ? (\n            <div\n              data-slot=\"expandable-subscribe-confirmation\"\n              className=\"expandable-subscribe-confirmation flex min-w-0 items-center gap-2.5 px-4\"\n            >\n              <CheckMark variant=\"circle\" size=\"md\" strokeWidth={1.5} />\n              <span\n                data-slot=\"expandable-subscribe-success-label\"\n                className=\"expandable-subscribe-success-label truncate\"\n              >\n                {successLabel}\n              </span>\n            </div>\n          ) : null}\n        </motion.div>\n      </motion.div>\n      <span role=\"status\" className=\"sr-only\">\n        {state === \"loading\"\n          ? loadingLabel\n          : state === \"success\"\n            ? successLabel\n            : \"\"}\n      </span>\n      {failed && (\n        <p\n          id={errorId}\n          role=\"alert\"\n          className=\"absolute top-full mt-2 w-full text-xs text-destructive\"\n        >\n          {errorLabel}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/expandable-subscribe.tsx"
    },
    {
      "path": "registry/base/ui/expandable-subscribe.css",
      "content": "@layer components {\n  .expandable-subscribe-confirmation {\n    --subscribe-circle-duration: 400ms;\n    --subscribe-check-duration: 260ms;\n  }\n\n  .expandable-subscribe-confirmation .check-mark {\n    --check-mark-shape-duration: var(--subscribe-circle-duration);\n    --check-mark-check-duration: var(--subscribe-check-duration);\n  }\n\n  /* Mounted together with CheckMark: the fade and check share delay/duration,\n     so they finish together without a timer or a React render between them. */\n  .expandable-subscribe-success-label {\n    opacity: 0;\n    animation: subscribe-confirmation-fade var(--subscribe-check-duration)\n      cubic-bezier(0.23, 1, 0.32, 1) var(--subscribe-circle-duration) forwards;\n  }\n\n  @keyframes subscribe-confirmation-fade {\n    to {\n      opacity: 1;\n    }\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .expandable-subscribe-success-label {\n    animation: none !important;\n    opacity: 1;\n  }\n}\n",
      "type": "registry:ui",
      "target": "@ui/expandable-subscribe.css"
    }
  ],
  "meta": {
    "tags": [
      "email",
      "subscribe",
      "form",
      "loading",
      "success"
    ],
    "effects": [
      "expand",
      "state-transition"
    ]
  },
  "categories": [
    "form"
  ],
  "type": "registry:ui"
}