{
  "$schema": "https://abackui.com/schema/registry-item.json",
  "name": "contact-form",
  "type": "registry:block",
  "title": "Contact form",
  "description": "A contact form with field-level errors, focus on the first error, a character count, and submit states.",
  "dependencies": [
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://abackui.com/r/alert.json",
    "https://abackui.com/r/button.json",
    "https://abackui.com/r/field.json",
    "https://abackui.com/r/input.json",
    "https://abackui.com/r/select.json",
    "https://abackui.com/r/spinner.json",
    "https://abackui.com/r/textarea.json"
  ],
  "categories": [
    "Forms"
  ],
  "docs": "https://abackui.com/docs/blocks/contact-form",
  "meta": {
    "status": "beta",
    "usedIn": [
      "aback-launch"
    ]
  },
  "files": [
    {
      "path": "components/blocks/contact-form.tsx",
      "content": "\"use client\";\n\nimport { type FormEvent, useId, useRef, useState } from \"react\";\nimport { IconCircleCheck } from \"@tabler/icons-react\";\n\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Field } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport { Select } from \"@/components/ui/select\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nexport interface ContactValues {\n  name: string;\n  email: string;\n  topic: string;\n  message: string;\n}\n\nexport interface ContactResult {\n  ok: boolean;\n  /** Shown after submission: when you reply, or what went wrong. */\n  message: string;\n}\n\nexport interface ContactFormBlockProps {\n  /** Send the message. Validate again on the server; this form only checks the shape. */\n  onSubmit: (values: ContactValues) => Promise<ContactResult>;\n  topics?: { value: string; label: string }[];\n  title?: string;\n  description?: string;\n  maxLength?: number;\n}\n\ntype Errors = Partial<Record<keyof ContactValues, string>>;\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nfunction validate(values: ContactValues, maxLength: number): Errors {\n  const errors: Errors = {};\n  if (!values.name.trim()) errors.name = \"Enter your name.\";\n  if (!values.email.trim()) errors.email = \"Enter your email address.\";\n  else if (!EMAIL.test(values.email.trim())) errors.email = \"Enter an email address like you@company.com.\";\n  if (!values.topic) errors.topic = \"Choose what your message is about.\";\n  if (values.message.trim().length < 10) errors.message = \"Write at least 10 characters.\";\n  else if (values.message.length > maxLength) errors.message = `Keep it under ${maxLength} characters.`;\n  return errors;\n}\n\nconst FIELD_ORDER: (keyof ContactValues)[] = [\"name\", \"email\", \"topic\", \"message\"];\n\n/**\n * A contact form with field-level errors, a character count, and real\n * submission states. On an invalid submit it focuses the first field with an\n * error; on success it replaces the form with your confirmation.\n */\nexport function ContactFormBlock({\n  onSubmit,\n  topics = [\n    { value: \"support\", label: \"Product support\" },\n    { value: \"sales\", label: \"Sales and pricing\" },\n    { value: \"press\", label: \"Press\" },\n    { value: \"other\", label: \"Something else\" },\n  ],\n  title = \"Contact us\",\n  description = \"We reply within two working days.\",\n  maxLength = 2000,\n}: ContactFormBlockProps) {\n  const headingId = useId();\n  const [values, setValues] = useState<ContactValues>({ name: \"\", email: \"\", topic: \"\", message: \"\" });\n  const [errors, setErrors] = useState<Errors>({});\n  const [submitting, setSubmitting] = useState(false);\n  const [result, setResult] = useState<ContactResult | null>(null);\n  const form = useRef<HTMLFormElement>(null);\n\n  function update<K extends keyof ContactValues>(key: K, value: ContactValues[K]) {\n    setValues((current) => ({ ...current, [key]: value }));\n    if (errors[key]) setErrors((current) => ({ ...current, [key]: undefined }));\n  }\n\n  async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    const found = validate(values, maxLength);\n    setErrors(found);\n    const first = FIELD_ORDER.find((key) => found[key]);\n    if (first) {\n      form.current?.querySelector<HTMLElement>(`[name=\"${first}\"]`)?.focus();\n      return;\n    }\n    setSubmitting(true);\n    setResult(null);\n    try {\n      setResult(await onSubmit({ ...values, name: values.name.trim(), email: values.email.trim() }));\n    } catch {\n      setResult({ ok: false, message: \"Your message could not be sent. Try again in a moment.\" });\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  const describedBy = (key: keyof ContactValues, extra?: string) =>\n    [errors[key] ? `contact-${key}-error` : null, extra].filter(Boolean).join(\" \") || undefined;\n\n  if (result?.ok) {\n    return (\n      <section aria-labelledby={headingId} className=\"mx-auto w-full max-w-xl px-4 py-12 md:px-6\">\n        <div role=\"status\" className=\"flex flex-col items-center gap-3 border border-border bg-card p-8 text-center\">\n          <span aria-hidden=\"true\" className=\"flex size-12 items-center justify-center bg-success-soft text-success\">\n            <IconCircleCheck className=\"size-6\" />\n          </span>\n          <h2 id={headingId} className=\"text-xl font-semibold text-foreground\">\n            Message sent\n          </h2>\n          <p className=\"text-sm text-muted-foreground\">{result.message}</p>\n          <Button variant=\"outline\" size=\"sm\" onClick={() => {\n            setValues({ name: \"\", email: \"\", topic: \"\", message: \"\" });\n            setResult(null);\n          }}>\n            Send another message\n          </Button>\n        </div>\n      </section>\n    );\n  }\n\n  return (\n    <section aria-labelledby={headingId} className=\"mx-auto w-full max-w-xl px-4 py-12 md:px-6\">\n      <div className=\"mb-6 flex flex-col gap-1.5\">\n        <h2 id={headingId} className=\"text-2xl font-bold tracking-tight text-foreground\">\n          {title}\n        </h2>\n        <p className=\"text-sm text-muted-foreground\">{description}</p>\n      </div>\n\n      <form ref={form} onSubmit={handleSubmit} noValidate className=\"flex flex-col gap-5\">\n        <div className=\"grid gap-5 sm:grid-cols-2\">\n          <Field htmlFor=\"contact-name\" label=\"Name\" required error={errors.name}>\n            <Input\n              id=\"contact-name\"\n              name=\"name\"\n              autoComplete=\"name\"\n              value={values.name}\n              onChange={(event) => update(\"name\", event.target.value)}\n              aria-invalid={errors.name ? true : undefined}\n              aria-describedby={describedBy(\"name\")}\n              required\n            />\n          </Field>\n          <Field htmlFor=\"contact-email\" label=\"Email\" required error={errors.email}>\n            <Input\n              id=\"contact-email\"\n              name=\"email\"\n              type=\"email\"\n              autoComplete=\"email\"\n              inputMode=\"email\"\n              value={values.email}\n              onChange={(event) => update(\"email\", event.target.value)}\n              aria-invalid={errors.email ? true : undefined}\n              aria-describedby={describedBy(\"email\")}\n              required\n            />\n          </Field>\n        </div>\n\n        <Field htmlFor=\"contact-topic\" label=\"Topic\" required error={errors.topic}>\n          <Select\n            id=\"contact-topic\"\n            name=\"topic\"\n            value={values.topic}\n            onChange={(event) => update(\"topic\", event.target.value)}\n            aria-invalid={errors.topic ? true : undefined}\n            aria-describedby={describedBy(\"topic\")}\n            required\n          >\n            <option value=\"\" disabled>\n              Choose a topic\n            </option>\n            {topics.map((topic) => (\n              <option key={topic.value} value={topic.value}>\n                {topic.label}\n              </option>\n            ))}\n          </Select>\n        </Field>\n\n        <Field\n          htmlFor=\"contact-message\"\n          label=\"Message\"\n          required\n          error={errors.message}\n          description={`${values.message.length} of ${maxLength} characters`}\n        >\n          <Textarea\n            id=\"contact-message\"\n            name=\"message\"\n            rows={6}\n            value={values.message}\n            onChange={(event) => update(\"message\", event.target.value)}\n            aria-invalid={errors.message ? true : undefined}\n            aria-describedby={describedBy(\"message\", \"contact-message-description\")}\n            required\n          />\n        </Field>\n\n        {result && !result.ok ? (\n          <Alert variant=\"destructive\" role=\"alert\">\n            <AlertDescription>{result.message}</AlertDescription>\n          </Alert>\n        ) : null}\n\n        <Button type=\"submit\" disabled={submitting} className=\"self-start\">\n          {submitting ? <Spinner aria-hidden=\"true\" size=\"sm\" className=\"text-current\" /> : null}\n          {submitting ? \"Sending\" : \"Send message\"}\n        </Button>\n      </form>\n    </section>\n  );\n}\n",
      "type": "registry:block",
      "target": "components/blocks/contact-form.tsx"
    }
  ]
}
