{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "newsletter-form",
  "type": "registry:ui",
  "title": "Newsletter Form",
  "description": "Inline email signup whose button grows over the field into the confirmation.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@base-ui/react",
    "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"
  ],
  "files": [
    {
      "path": "components/ui/newsletter-form.tsx",
      "content": "\"use client\";\nimport { Button } from \"@base-ui/react/button\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Form } from \"@base-ui/react/form\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Loader } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\ntype Status = \"idle\" | \"submitting\" | \"success\";\n\nconst EMAIL = /^[^\\s@]+@[^\\s@.]+(\\.[^\\s@.]+)*\\.[^\\s@.]{2,}$/;\n\n// Mailbox providers people actually mistype. A suggestion never blocks the\n// submit; it only offers the likely address.\nconst DOMAINS = [\n  \"gmail.com\", \"googlemail.com\", \"yahoo.com\", \"hotmail.com\", \"outlook.com\", \"live.com\",\n  \"icloud.com\", \"me.com\", \"proton.me\", \"protonmail.com\", \"aol.com\", \"hey.com\", \"fastmail.com\",\n];\nconst TLD_TYPOS: Record<string, string> = { con: \"com\", cmo: \"com\", ocm: \"com\", comm: \"com\", cm: \"com\", vom: \"com\", xom: \"com\", cpm: \"com\", nte: \"net\", ogr: \"org\" };\n\nfunction distance(a: string, b: string) {\n  const row = Array.from({ length: b.length + 1 }, (_, i) => i);\n  for (let i = 1; i <= a.length; i++) {\n    let prev = row[0];\n    row[0] = i;\n    for (let j = 1; j <= b.length; j++) {\n      const next = row[j];\n      row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));\n      prev = next;\n    }\n  }\n  return row[b.length];\n}\n\n/**\n * The address the person probably meant, or null. \"ryan@gmial.com\" gives\n * \"ryan@gmail.com\"; \"ryan@acme.con\" gives \"ryan@acme.com\"; a company domain it\n * doesn't know is left alone.\n */\nexport function suggestEmail(email: string): string | null {\n  const at = email.trim().lastIndexOf(\"@\");\n  if (at < 1) return null;\n  const local = email.trim().slice(0, at);\n  const domain = email.trim().slice(at + 1).toLowerCase();\n  if (!domain || DOMAINS.includes(domain)) return null;\n  let best: string | null = null;\n  let bestScore = 3;\n  for (const d of DOMAINS) {\n    const score = distance(domain, d);\n    if (score < bestScore) [best, bestScore] = [d, score];\n  }\n  if (best && domain.length > 4) return `${local}@${best}`;\n  const dot = domain.lastIndexOf(\".\");\n  const tld = dot > 0 ? domain.slice(dot + 1) : \"\";\n  if (TLD_TYPOS[tld]) return `${local}@${domain.slice(0, dot + 1)}${TLD_TYPOS[tld]}`;\n  return null;\n}\n\nconst wait = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\nexport type NewsletterFormProps = Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"children\"> & {\n  /** Called with a valid, trimmed address. Throw (or reject) with a message to show it under the field. */\n  onSubscribe: (email: string) => Promise<void> | void;\n  /** Called once the success state is showing. */\n  onSuccess?: (email: string) => void;\n  /** Accessible name of the field. Rendered visually hidden; the placeholder is only an example. */\n  label?: string;\n  placeholder?: string;\n  submitLabel?: string;\n  /** One quiet line under the field: cadence, privacy. Replaced by errors and the success line. */\n  hint?: React.ReactNode;\n  successTitle?: string;\n  successMessage?: React.ReactNode;\n  /** Label of the link that brings the field back with the address selected. */\n  resetLabel?: string;\n  /** Offer \"Did you mean …@gmail.com?\" when the domain looks mistyped. */\n  suggest?: boolean;\n  size?: \"sm\" | \"md\";\n  disabled?: boolean;\n  /** Name of the field in the submitted form values. */\n  name?: string;\n};\n\n/**\n * An email field with its button inside. Submitting dims the label and, if the\n * request is slow enough to notice, swaps it for a spinner at the same width.\n * On success the button grows over the whole field and becomes the confirmation.\n */\nexport function NewsletterForm({\n  onSubscribe,\n  onSuccess,\n  label = \"Email address\",\n  placeholder = \"you@company.com\",\n  submitLabel = \"Subscribe\",\n  hint,\n  successTitle = \"You’re in\",\n  successMessage = \"Check your inbox to confirm.\",\n  resetLabel = \"Use another email\",\n  suggest = true,\n  size = \"md\",\n  disabled = false,\n  name = \"email\",\n  className,\n  ...rest\n}: NewsletterFormProps) {\n  const reduce = useReducedMotion();\n  const [email, setEmail] = useState(\"\");\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  const [suggestion, setSuggestion] = useState<string | null>(null);\n  // Where the button sat when it was pressed, so the confirmation grows out of it.\n  const [origin, setOrigin] = useState<{ top: number; right: number; bottom: number; left: number } | null>(null);\n  const [slow, setSlow] = useState(false);\n\n  const box = useRef<HTMLDivElement>(null);\n  const button = useRef<HTMLButtonElement>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const reset = useRef<HTMLButtonElement>(null);\n  const alive = useRef(true);\n  const refocus = useRef<\"input\" | \"reset\" | null>(null);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  // Focus follows the state change once the new state is on screen.\n  useEffect(() => {\n    if (refocus.current === \"reset\" && status === \"success\") reset.current?.focus();\n    if (refocus.current === \"input\" && status === \"idle\") {\n      input.current?.focus();\n      input.current?.select();\n    }\n    refocus.current = null;\n  }, [status]);\n\n  // The spinner only appears if the request outlasts 150ms, so a fast answer never flashes it.\n  useEffect(() => {\n    if (status !== \"submitting\") return;\n    const t = window.setTimeout(() => setSlow(true), 150);\n    return () => {\n      window.clearTimeout(t);\n      setSlow(false);\n    };\n  }, [status]);\n\n  const submit = async (value: string) => {\n    if (status !== \"idle\") return;\n    const b = box.current?.getBoundingClientRect();\n    const k = button.current?.getBoundingClientRect();\n    if (b && k) setOrigin({ top: k.top - b.top, right: b.right - k.right, bottom: b.bottom - k.bottom, left: k.left - b.left });\n    setErrors({});\n    setSuggestion(null);\n    setStatus(\"submitting\");\n    const started = performance.now();\n    // Once a spinner has shown, it stays for at least 300ms so it reads as a state, not a glitch.\n    const settle = async () => {\n      const elapsed = performance.now() - started;\n      if (elapsed > 150 && elapsed < 450) await wait(450 - elapsed);\n    };\n    try {\n      await onSubscribe(value);\n      await settle();\n      if (!alive.current) return;\n      refocus.current = \"reset\";\n      setStatus(\"success\");\n      onSuccess?.(value);\n    } catch (error) {\n      await settle();\n      if (!alive.current) return;\n      setStatus(\"idle\");\n      // A new object each time, so the same message twice still re-announces and refocuses.\n      setErrors({ [name]: error instanceof Error && error.message ? error.message : \"Couldn’t subscribe you. Try again.\" });\n    }\n  };\n\n  const busy = status === \"submitting\";\n  const done = status === \"success\";\n  const sm = size === \"sm\";\n  const radius = sm ? 7 : 8;\n  const inner = sm ? 5 : 6;\n\n  const clip = (o: typeof origin, r: number) => (o ? `inset(${o.top}px ${o.right}px ${o.bottom}px ${o.left}px round ${r}px)` : `inset(0px 0px 0px 0px round ${radius}px)`);\n\n  return (\n    <div\n      data-slot=\"newsletter-form\"\n      data-state={status}\n      data-size={size}\n      className={cn(\"w-full min-w-0\", className)}\n      {...rest}\n    >\n      <Form\n        errors={errors}\n        onFormSubmit={(values) => submit(String(values[name] ?? \"\").trim())}\n        aria-busy={busy || undefined}\n      >\n        <Field.Root\n          name={name}\n          disabled={disabled}\n          validate={(v) => {\n            const s = String(v ?? \"\").trim();\n            if (!s) return \"Enter your email address\";\n            if (!EMAIL.test(s)) return \"Enter a valid email, like name@company.com\";\n            return null;\n          }}\n          className=\"group/nl flex flex-col gap-2\"\n        >\n          <Field.Label className=\"sr-only\">{label}</Field.Label>\n\n          <div\n            ref={box}\n            className={cn(\n              \"relative flex w-full min-w-0 items-center border bg-raised shadow-[var(--shadow)]\",\n              \"border-line-2 transition-[border-color,box-shadow] duration-150 ease-out\",\n              \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3\",\n              \"group-data-invalid/nl:border-danger/70 group-data-invalid/nl:hover:border-danger group-data-invalid/nl:focus-within:border-danger group-data-invalid/nl:hover:focus-within:border-danger group-data-invalid/nl:focus-within:ring-danger/15\",\n              \"group-data-disabled/nl:opacity-50 group-data-disabled/nl:shadow-none group-data-disabled/nl:hover:border-line-2\",\n              sm ? \"h-9 rounded-[7px] pl-2.5 pr-[3px]\" : \"h-10 rounded-lg pl-3 pr-[3px]\",\n            )}\n            // Pressing the padding puts the caret in the field, as a single native control would.\n            onMouseDown={(e) => {\n              if ((e.target as HTMLElement).closest(\"input, button\") || disabled || done) return;\n              e.preventDefault();\n              input.current?.focus();\n            }}\n          >\n            <div inert={done} className=\"flex h-full min-w-0 flex-1 items-center gap-2\">\n              <Field.Control\n                ref={input}\n                type=\"text\"\n                inputMode=\"email\"\n                autoComplete=\"email\"\n                autoCapitalize=\"none\"\n                autoCorrect=\"off\"\n                spellCheck={false}\n                enterKeyHint=\"send\"\n                placeholder={placeholder}\n                value={email}\n                onValueChange={(v) => {\n                  setEmail(v);\n                  if (suggestion) setSuggestion(null);\n                }}\n                onBlur={() => suggest && !done && setSuggestion(suggestEmail(email))}\n                readOnly={busy}\n                className={cn(\n                  \"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\",\n                  \"transition-colors duration-150 read-only:text-fg-2 disabled:cursor-not-allowed\",\n                  \"autofill:shadow-[inset_0_0_0_1000px_var(--raised)] autofill:[-webkit-text-fill-color:var(--fg)]\",\n                )}\n              />\n\n              <Button\n                ref={button}\n                type=\"submit\"\n                disabled={disabled}\n                aria-disabled={busy || undefined}\n                data-busy={busy || undefined}\n                className={cn(\n                  \"relative inline-flex shrink-0 select-none items-center justify-center bg-fg font-medium tracking-[-0.005em] text-frame\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                  \"data-busy:pointer-events-none data-disabled:pointer-events-none\",\n                  // 28–32px to the eye, 44px to a thumb.\n                  \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n                  sm ? \"h-7 rounded-[5px] px-2.5 text-[12px]\" : \"h-8 rounded-md px-3 text-[12.5px]\",\n                )}\n              >\n                {/* Label and spinner share one cell, so the button never changes width. */}\n                <span className=\"grid place-items-center\">\n                  <span\n                    className={cn(\n                      \"col-start-1 row-start-1 transition-[opacity,filter] duration-150 ease-out\",\n                      busy && \"opacity-60\",\n                      slow && \"opacity-0 blur-[2px] motion-reduce:blur-none\",\n                    )}\n                  >\n                    {submitLabel}\n                  </span>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      \"col-start-1 row-start-1 grid place-items-center transition-[opacity,scale] duration-200 ease-out-expo\",\n                      slow ? \"scale-100 opacity-100\" : \"scale-75 opacity-0\",\n                    )}\n                  >\n                    <Loader size={sm ? 13 : 14} className={cn(slow && \"animate-spin motion-reduce:animate-spin-slow\")} />\n                  </span>\n                </span>\n                {busy && <span className=\"sr-only\">Subscribing</span>}\n              </Button>\n            </div>\n\n            <AnimatePresence initial={false}>\n              {done && (\n                <motion.div\n                  key=\"done\"\n                  className=\"absolute -inset-px flex min-w-0 items-center gap-2 bg-fg text-frame\"\n                  style={{ borderRadius: radius, paddingInline: sm ? 10 : 12 }}\n                  initial={reduce ? { opacity: 0 } : { clipPath: clip(origin, inner) }}\n                  animate={reduce ? { opacity: 1 } : { clipPath: clip(null, radius), opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.15, ease: ease.in } }}\n                  transition={reduce ? { duration: 0.15 } : { duration: 0.46, ease: ease.out }}\n                >\n                  <Tick reduce={!!reduce} />\n                  <motion.span\n                    className=\"shrink-0 text-[13px] font-medium tracking-[-0.01em]\"\n                    initial={reduce ? false : { opacity: 0, x: -4, filter: \"blur(2px)\" }}\n                    animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.14 }}\n                  >\n                    {successTitle}\n                  </motion.span>\n                  <motion.span\n                    className=\"ml-auto min-w-0 truncate text-[12.5px] opacity-60\"\n                    initial={reduce ? false : { opacity: 0 }}\n                    animate={{ opacity: 0.6 }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.22 }}\n                  >\n                    {email.trim()}\n                  </motion.span>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n\n          {/* Hint, suggestion, error and confirmation share one line, so nothing below the form moves. */}\n          <div className=\"grid min-h-[18px] text-[12px] leading-[18px]\">\n            {hint != null && (\n              <Field.Description\n                className={cn(\n                  \"col-start-1 row-start-1 text-fg-3 transition-[opacity,translate] duration-150 ease-out text-pretty\",\n                  \"group-data-invalid/nl:pointer-events-none group-data-invalid/nl:opacity-0\",\n                  (suggestion || done) && \"pointer-events-none opacity-0\",\n                )}\n              >\n                {hint}\n              </Field.Description>\n            )}\n\n            <div\n              inert={!suggestion || done}\n              className={cn(\n                \"col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-fg-2 transition-[opacity,translate] duration-200 ease-out-expo\",\n                suggestion && !done ? \"opacity-100\" : \"pointer-events-none -translate-y-0.5 opacity-0 duration-100\",\n                \"group-data-invalid/nl:hidden\",\n              )}\n            >\n              <span className=\"shrink-0\">Did you mean</span>\n              <span className=\"flex min-w-0 items-center\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  if (!suggestion) return;\n                  setEmail(suggestion);\n                  setSuggestion(null);\n                  input.current?.focus();\n                }}\n                className={cn(\n                  \"min-w-0 truncate rounded-[3px] font-medium text-fg underline decoration-fg-4 underline-offset-[3px]\",\n                  \"outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                )}\n              >\n                {suggestion}\n              </button>\n              <span className=\"shrink-0\">?</span>\n              </span>\n            </div>\n\n            <Field.Error\n              className={cn(\n                \"col-start-1 row-start-1 flex items-start gap-1.5 text-danger\",\n                \"transition-[opacity,translate] duration-200 ease-out-expo\",\n                \"data-starting-style:-translate-y-1 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n                \"motion-reduce:translate-y-0\",\n              )}\n              // Keep Base UI's message (validation or server) and set an icon beside it.\n              render={({ children, ...props }) => (\n                <div {...props}>\n                  <Alert size={14} className=\"mt-0.5 shrink-0\" />\n                  <span className=\"min-w-0 text-pretty\">{children}</span>\n                </div>\n              )}\n            />\n\n            <div\n              inert={!done}\n              className={cn(\n                \"col-start-1 row-start-1 flex flex-wrap items-center gap-x-2 text-fg-3 transition-[opacity,translate] duration-300 ease-out-expo\",\n                done ? \"opacity-100 delay-200\" : \"pointer-events-none translate-y-1 opacity-0 duration-100 motion-reduce:translate-y-0\",\n              )}\n            >\n              <span>{successMessage}</span>\n              <button\n                ref={reset}\n                type=\"button\"\n                onClick={() => {\n                  refocus.current = \"input\";\n                  setStatus(\"idle\");\n                }}\n                className={cn(\n                  \"rounded-[3px] font-medium text-fg-2 underline decoration-fg-4 underline-offset-[3px]\",\n                  \"outline-none transition-[color,text-decoration-color] duration-150 hover:text-fg hover:decoration-fg-2\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                )}\n              >\n                {resetLabel}\n              </button>\n            </div>\n          </div>\n        </Field.Root>\n      </Form>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {done ? `${successTitle}. ${typeof successMessage === \"string\" ? successMessage : \"\"}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/** A ring that closes, then a tick that draws inside it. */\nfunction Tick({ reduce }: { reduce: boolean }) {\n  const draw = (delay: number, duration: number) =>\n    reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration, ease: ease.out, delay } };\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"shrink-0\">\n      <motion.circle cx=\"8\" cy=\"8\" r=\"6.25\" opacity={0.35} {...draw(0.1, 0.4)} />\n      <motion.path d=\"m5.25 8.25 1.9 1.9 3.6-4.15\" {...draw(0.3, 0.3)} />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/newsletter-form"
}
