{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contact-form",
  "title": "Contact Form",
  "description": "A two-tile contact form with a topic picker, a character count, optional consent, and an info tile with the response time.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/contact-form/contact-form.tsx",
      "content": "// Contact form: a two-tile split. The form tile holds name, email, topic (Pills), message with a mono character\n// count, and an optional consent checkbox; the info tile holds email, phone, address and the response time.\n// Signature: the count turns destructive, with the words \"12 over\", only when over the limit.\n// Screen readers: real labels, aria-invalid + aria-describedby on errors, focus moves to the first invalid field,\n// and sending, sent and error states are announced through a live region. The form never sends anything itself:\n// onSubmit returns a promise the section awaits. On a narrow tile the topic Pills become a native select.\n// Reduced motion: the press feedback on the send button is dropped; nothing else moves.\nimport { useEffect, useId, useRef, useState, type FormEvent, type HTMLAttributes, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { Pills, type PillOption } from '@/registry/manniche/chart-kit/chart-kit'\n\nexport type ContactFormValues = { name: string; email: string; topic: string; message: string; consent: boolean }\n\nexport type ContactInfo = {\n  email?: string\n  phone?: string\n  address?: ReactNode\n  /** \"We reply within two working days.\" */\n  responseTime?: ReactNode\n}\n\nexport type ContactFormProps = Omit<HTMLAttributes<HTMLElement>, 'onSubmit' | 'title'> & {\n  /** The section heading (h2). */\n  heading: ReactNode\n  /** One line under the heading. */\n  intro?: ReactNode\n  /** Topics shown as Pills. */\n  topics: PillOption[]\n  /** Message limit in characters. Default 800. */\n  maxLength?: number\n  /** Consent line (may hold a privacy link). The checkbox only appears when this is passed. */\n  consent?: ReactNode\n  /** Content of the info tile. */\n  info: ContactInfo\n  /** Called with the values; the section waits for it. Reject to show the error state. */\n  onSubmit?: (values: ContactFormValues) => Promise<void> | void\n  labels?: {\n    name?: string; email?: string; topic?: string; message?: string; send?: string; sending?: string\n    sentTitle?: string; sentText?: string; another?: string; errorText?: string; retry?: string\n    nameError?: string; emailError?: string; messageError?: string; consentError?: string\n    over?: (n: number) => string; infoTitle?: string; emailLabel?: string; phoneLabel?: string; addressLabel?: string\n  }\n}\n\ntype Status = 'idle' | 'sending' | 'sent' | 'error'\ntype Errors = Partial<Record<'name' | 'email' | 'message' | 'consent', string>>\n\nconst TILE = 'rounded-[26px] bg-card text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]'\nconst FIELD =\n  'mt-1.5 block min-h-11 w-full rounded-[14px] border border-border bg-background px-3.5 text-base outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring aria-[invalid=true]:border-destructive'\n\nexport function ContactForm({ heading, intro, topics, maxLength = 800, consent, info, onSubmit, labels = {}, className, ...rest }: ContactFormProps) {\n  const L = {\n    name: 'Name', email: 'Email', topic: 'Topic', message: 'Message', send: 'Send message', sending: 'Sending',\n    sentTitle: 'Message sent', sentText: 'Thank you. We will be in touch.', another: 'Write another',\n    errorText: 'The message did not go through. Your text is still here, so you can try again.', retry: 'Try again',\n    nameError: 'Enter your name.', emailError: 'Enter an email address like name@example.com.',\n    messageError: 'Write a message, or shorten it to the limit.', consentError: 'Tick the box to agree before sending.',\n    over: (n: number) => `${n} over`, infoTitle: 'Other ways to reach us', emailLabel: 'Email', phoneLabel: 'Phone', addressLabel: 'Address',\n    ...labels,\n  }\n  const uid = useId()\n  const [values, setValues] = useState<ContactFormValues>({ name: '', email: '', topic: topics[0]?.id ?? '', message: '', consent: false })\n  const [errors, setErrors] = useState<Errors>({})\n  const [status, setStatus] = useState<Status>('idle')\n  const formRef = useRef<HTMLFormElement>(null)\n  const sentRef = useRef<HTMLHeadingElement>(null)\n  const reset = useRef(false)\n  // The form disappears when sent, so focus moves to the confirmation instead of being lost; after \"Write another\"\n  // it moves back to the first field.\n  useEffect(() => {\n    if (status === 'sent') sentRef.current?.focus()\n    if (status === 'idle' && reset.current) {\n      reset.current = false\n      formRef.current?.querySelector<HTMLElement>('[name=\"name\"]')?.focus()\n    }\n  }, [status])\n\n  const validate = (v: ContactFormValues): Errors => {\n    const e: Errors = {}\n    if (!v.name.trim()) e.name = L.nameError\n    if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v.email.trim())) e.email = L.emailError\n    if (!v.message.trim() || v.message.length > maxLength) e.message = L.messageError\n    if (consent && !v.consent) e.consent = L.consentError\n    return e\n  }\n  const set = <K extends keyof ContactFormValues>(k: K, val: ContactFormValues[K]) => setValues((v) => ({ ...v, [k]: val }))\n  const blur = (k: keyof Errors) => setErrors((e) => ({ ...e, [k]: validate(values)[k] }))\n\n  const submit = async (ev: FormEvent) => {\n    ev.preventDefault()\n    if (status === 'sending') return\n    const e = validate(values)\n    setErrors(e)\n    const first = (['name', 'email', 'message', 'consent'] as const).find((k) => e[k])\n    if (first) {\n      formRef.current?.querySelector<HTMLElement>(`[name=\"${first}\"]`)?.focus()\n      return\n    }\n    setStatus('sending')\n    try {\n      await onSubmit?.(values)\n      setStatus('sent')\n    } catch {\n      setStatus('error')\n    }\n  }\n\n  const left = maxLength - values.message.length\n  const over = left < 0\n  const err = (k: keyof Errors) => (errors[k] ? `${uid}-${k}-err` : undefined)\n  const errMsg = (k: keyof Errors) =>\n    errors[k] ? (\n      <p id={`${uid}-${k}-err`} className=\"mt-1.5 text-sm text-destructive\">\n        {errors[k]}\n      </p>\n    ) : null\n\n  return (\n    <section aria-labelledby={`${uid}-h`} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>\n      <header className=\"max-w-2xl\">\n        <h2 id={`${uid}-h`} className=\"font-[family-name:var(--font-display,inherit)] text-3xl font-semibold tracking-tight text-balance @min-[40rem]:text-4xl\">\n          {heading}\n        </h2>\n        {intro && <p className=\"mt-3 text-muted-foreground\">{intro}</p>}\n      </header>\n\n      <div className=\"mt-8 grid gap-3 @min-[52rem]:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]\">\n        <div className={cn(TILE, '@container/form p-6')}>\n          <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {status === 'sending' ? L.sending : status === 'sent' ? L.sentTitle : status === 'error' ? L.errorText : ''}\n          </div>\n          {status === 'sent' ? (\n            <div>\n              <h3 ref={sentRef} tabIndex={-1} className=\"text-xl font-semibold outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\">{L.sentTitle}</h3>\n              <p className=\"mt-2 text-muted-foreground\">{L.sentText}</p>\n              <p className=\"mt-4 font-mono text-xs tabular-nums text-muted-foreground\">{values.email}</p>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setValues({ name: '', email: '', topic: topics[0]?.id ?? '', message: '', consent: false })\n                  reset.current = true\n                  setStatus('idle')\n                }}\n                className=\"mt-6 inline-flex min-h-11 items-center rounded-full bg-muted px-4 text-sm font-medium outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\"\n              >\n                {L.another}\n              </button>\n            </div>\n          ) : (\n            <form ref={formRef} onSubmit={submit} noValidate className=\"grid gap-5\">\n              <div>\n                <label htmlFor={`${uid}-name`} className=\"text-sm font-medium\">{L.name}</label>\n                <input id={`${uid}-name`} name=\"name\" type=\"text\" autoComplete=\"name\" value={values.name}\n                  onChange={(e) => set('name', e.target.value)} onBlur={() => blur('name')}\n                  aria-invalid={!!errors.name} aria-describedby={err('name')} className={FIELD} />\n                {errMsg('name')}\n              </div>\n              <div>\n                <label htmlFor={`${uid}-email`} className=\"text-sm font-medium\">{L.email}</label>\n                <input id={`${uid}-email`} name=\"email\" type=\"email\" autoComplete=\"email\" inputMode=\"email\" value={values.email}\n                  onChange={(e) => set('email', e.target.value)} onBlur={() => blur('email')}\n                  aria-invalid={!!errors.email} aria-describedby={err('email')} className={FIELD} />\n                {errMsg('email')}\n              </div>\n              {topics.length > 1 && (\n                <div>\n                  <p id={`${uid}-topic`} className=\"mb-1.5 text-sm font-medium\">{L.topic}</p>\n                  {/* Pills cannot wrap, so a narrow tile gets the same choice as a native select. */}\n                  <div className=\"hidden @min-[26rem]/form:block\">\n                    <Pills label={L.topic} options={topics} value={values.topic} onChange={(t) => set('topic', t)} />\n                  </div>\n                  <select name=\"topic\" aria-labelledby={`${uid}-topic`} value={values.topic} onChange={(e) => set('topic', e.target.value)}\n                    className={cn(FIELD, 'mt-0 @min-[26rem]/form:hidden')}>\n                    {topics.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}\n                  </select>\n                </div>\n              )}\n              <div>\n                <label htmlFor={`${uid}-message`} className=\"text-sm font-medium\">{L.message}</label>\n                <textarea id={`${uid}-message`} name=\"message\" rows={6} value={values.message}\n                  onChange={(e) => set('message', e.target.value)} onBlur={() => blur('message')}\n                  aria-invalid={!!errors.message} aria-describedby={[err('message'), `${uid}-count`].filter(Boolean).join(' ')}\n                  className={cn(FIELD, 'min-h-32 resize-y py-2.5')} />\n                <p id={`${uid}-count`} className={cn('mt-1.5 text-right font-mono text-xs tabular-nums', over ? 'text-destructive' : 'text-muted-foreground')}>\n                  {over ? L.over(-left) : `${values.message.length} / ${maxLength}`}\n                </p>\n                {errMsg('message')}\n              </div>\n              {consent && (\n                <div>\n                  <label className=\"flex min-h-11 items-start gap-3 text-sm\">\n                    <input name=\"consent\" type=\"checkbox\" checked={values.consent}\n                      onChange={(e) => set('consent', e.target.checked)} onBlur={() => blur('consent')}\n                      aria-invalid={!!errors.consent} aria-describedby={err('consent')}\n                      className=\"mt-0.5 size-5 shrink-0 accent-[var(--primary)] outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\" />\n                    <span>{consent}</span>\n                  </label>\n                  {errMsg('consent')}\n                </div>\n              )}\n              {status === 'error' && <p className=\"text-sm text-destructive\">{L.errorText}</p>}\n              {/* aria-disabled only: a disabled button would drop keyboard focus to the body while sending. submit() ignores repeats. */}\n              <button type=\"submit\" aria-disabled={status === 'sending'}\n                className=\"inline-flex min-h-11 w-fit items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground outline-offset-2 transition-[opacity,transform] duration-200 ease-out-quint focus-visible:outline-2 focus-visible:outline-ring active:scale-[0.98] aria-disabled:opacity-60 motion-reduce:transition-none motion-reduce:active:scale-100\">\n                {status === 'sending' ? L.sending : status === 'error' ? L.retry : L.send}\n              </button>\n            </form>\n          )}\n        </div>\n\n        <aside aria-labelledby={`${uid}-info`} className={cn(TILE, 'h-fit p-6')}>\n          <h3 id={`${uid}-info`} className=\"text-lg font-semibold\">{L.infoTitle}</h3>\n          <dl className=\"mt-4 grid gap-4 text-sm\">\n            {info.email && (\n              <div><dt className=\"font-mono text-[11px] uppercase tracking-wide text-muted-foreground\">{L.emailLabel}</dt>\n                <dd className=\"break-words\"><a className=\"inline-flex min-h-11 items-center underline underline-offset-4 outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\" href={`mailto:${info.email}`}>{info.email}</a></dd></div>\n            )}\n            {info.phone && (\n              <div><dt className=\"font-mono text-[11px] uppercase tracking-wide text-muted-foreground\">{L.phoneLabel}</dt>\n                <dd className=\"tabular-nums\"><a className=\"inline-flex min-h-11 items-center underline underline-offset-4 outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring\" href={`tel:${info.phone.replace(/[^\\d+]/g, '')}`}>{info.phone}</a></dd></div>\n            )}\n            {info.address && (\n              <div><dt className=\"font-mono text-[11px] uppercase tracking-wide text-muted-foreground\">{L.addressLabel}</dt>\n                <dd className=\"mt-1\">{info.address}</dd></div>\n            )}\n          </dl>\n          {info.responseTime && <p className=\"mt-6 border-t border-border pt-4 font-mono text-xs text-muted-foreground\">{info.responseTime}</p>}\n        </aside>\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}