{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checkout-address",
  "type": "registry:component",
  "title": "Address form",
  "description": "Shipping address with blur validation, an address combobox that fills city, state and ZIP, and a save checkbox.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "checkout",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/button.json",
    "https://storekit-eight.vercel.app/r/input.json"
  ],
  "files": [
    {
      "path": "checkout/checkout-address.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { Check, ChevronDown, MapPin } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { Button } from \"@/components/storekit/ui/button\"\nimport { Input } from \"@/components/storekit/ui/input\"\n\n/* Shipping address form. Checks a field's format when you leave it (empty\n   fields are only flagged on submit, so tabbing through never paints the\n   form red), moves focus to the first problem on submit, and has an address\n   combobox whose suggestion fills city, state and ZIP. Swap `suggest` for\n   your places API. */\n\nexport type Address = {\n  email: string\n  firstName: string\n  lastName: string\n  address1: string\n  address2: string\n  city: string\n  state: string\n  zip: string\n  phone: string\n  save: boolean\n}\n\ntype Suggestion = { line: string; city: string; state: string; zip: string }\n\nconst SAMPLE_SUGGESTIONS: Suggestion[] = [\n  { line: \"12 Elmwood Avenue\", city: \"Buffalo\", state: \"NY\", zip: \"14201\" },\n  { line: \"120 Delaware Avenue\", city: \"Buffalo\", state: \"NY\", zip: \"14202\" },\n  { line: \"1275 Market Street\", city: \"San Francisco\", state: \"CA\", zip: \"94103\" },\n  { line: \"450 Brannan Street\", city: \"San Francisco\", state: \"CA\", zip: \"94107\" },\n  { line: \"88 Kendall Street\", city: \"Cambridge\", state: \"MA\", zip: \"02142\" },\n]\n\nconst defaultSuggest = (q: string) => {\n  const s = q.trim().toLowerCase()\n  if (s.length < 2) return []\n  return SAMPLE_SUGGESTIONS.filter((x) => x.line.toLowerCase().startsWith(s)).slice(0, 3)\n}\n\nconst STATES = [\"AL\",\"AK\",\"AZ\",\"AR\",\"CA\",\"CO\",\"CT\",\"DE\",\"FL\",\"GA\",\"HI\",\"ID\",\"IL\",\"IN\",\"IA\",\"KS\",\"KY\",\"LA\",\"ME\",\"MD\",\"MA\",\"MI\",\"MN\",\"MS\",\"MO\",\"MT\",\"NE\",\"NV\",\"NH\",\"NJ\",\"NM\",\"NY\",\"NC\",\"ND\",\"OH\",\"OK\",\"OR\",\"PA\",\"RI\",\"SC\",\"SD\",\"TN\",\"TX\",\"UT\",\"VT\",\"VA\",\"WA\",\"WV\",\"WI\",\"WY\"]\n\ntype Field = Exclude<keyof Address, \"save\" | \"address2\">\n\nfunction validate(f: Field, a: Address): string | undefined {\n  const v = a[f].trim()\n  switch (f) {\n    case \"email\":\n      return !v ? \"Enter an email.\" : /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v) ? undefined : \"Enter a valid email.\"\n    case \"firstName\":\n      return v ? undefined : \"Enter a first name.\"\n    case \"lastName\":\n      return v ? undefined : \"Enter a last name.\"\n    case \"address1\":\n      return v ? undefined : \"Enter an address.\"\n    case \"city\":\n      return v ? undefined : \"Enter a city.\"\n    case \"state\":\n      return v ? undefined : \"Choose a state.\"\n    case \"zip\":\n      return /^\\d{5}(-\\d{4})?$/.test(v) ? undefined : \"Enter a 5-digit ZIP.\"\n    case \"phone\":\n      return v.replace(/\\D/g, \"\").length >= 10 ? undefined : \"Enter a 10-digit phone.\"\n  }\n}\n\nconst FIELDS: Field[] = [\"email\", \"firstName\", \"lastName\", \"address1\", \"city\", \"state\", \"zip\", \"phone\"]\nconst EMPTY: Address = { email: \"\", firstName: \"\", lastName: \"\", address1: \"\", address2: \"\", city: \"\", state: \"\", zip: \"\", phone: \"\", save: true }\n\nexport function CheckoutAddress({\n  defaultValue,\n  suggest = defaultSuggest,\n  submitLabel = \"Continue to shipping\",\n  onSubmit,\n  className,\n}: {\n  defaultValue?: Partial<Address>\n  suggest?: (query: string) => Suggestion[]\n  submitLabel?: string\n  onSubmit?: (address: Address) => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const [a, setA] = React.useState<Address>({ ...EMPTY, ...defaultValue })\n  const [errors, setErrors] = React.useState<Partial<Record<Field, string>>>({})\n  const [listOpen, setListOpen] = React.useState(false)\n  const [active, setActive] = React.useState(0)\n  const [done, setDone] = React.useState(false)\n  const [submitted, setSubmitted] = React.useState(false)\n  const formRef = React.useRef<HTMLFormElement>(null)\n  const listId = React.useId()\n  const stateId = React.useId()\n  const results = suggest(a.address1)\n  const showList = listOpen && results.length > 0\n\n  const set = <K extends keyof Address>(k: K, v: Address[K]) => {\n    setA((p) => ({ ...p, [k]: v }))\n    if (k in errors) setErrors((e) => ({ ...e, [k]: undefined }))\n  }\n  const blur = (f: Field) => {\n    if (!submitted && !a[f].trim()) return\n    setErrors((e) => ({ ...e, [f]: validate(f, a) }))\n  }\n\n  const pick = (s: Suggestion) => {\n    setA((p) => ({ ...p, address1: s.line, city: s.city, state: s.state, zip: s.zip }))\n    setErrors((e) => ({ ...e, address1: undefined, city: undefined, state: undefined, zip: undefined }))\n    setListOpen(false)\n  }\n\n  const onComboKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (!showList) return\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault()\n      setActive((i) => (i + 1) % results.length)\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault()\n      setActive((i) => (i - 1 + results.length) % results.length)\n    } else if (e.key === \"Enter\") {\n      e.preventDefault()\n      pick(results[active])\n    } else if (e.key === \"Escape\") {\n      setListOpen(false)\n    }\n  }\n\n  const submit = (e: React.FormEvent) => {\n    e.preventDefault()\n    const next: Partial<Record<Field, string>> = {}\n    for (const f of FIELDS) next[f] = validate(f, a)\n    setErrors(next)\n    setSubmitted(true)\n    const firstBad = FIELDS.find((f) => next[f])\n    if (firstBad) {\n      formRef.current?.querySelector<HTMLElement>(`[name=\"${firstBad}\"]`)?.focus()\n      return\n    }\n    setDone(true)\n    onSubmit?.(a)\n    window.setTimeout(() => setDone(false), 1200)\n  }\n\n  const field = (f: Field) => ({\n    name: f,\n    value: a[f],\n    error: errors[f],\n    onChange: (e: React.ChangeEvent<HTMLInputElement>) => set(f, e.target.value),\n    onBlur: () => blur(f),\n  })\n\n  return (\n    <form ref={formRef} noValidate onSubmit={submit} data-sk-section=\"checkout\" data-sk-variant=\"checkout-address\" className={cn(\"grid w-full grid-cols-1 gap-4\", className)}>\n      <p className=\"font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Contact</p>\n      <Input label=\"Email\" type=\"email\" autoComplete=\"email\" placeholder=\"you@example.com\" {...field(\"email\")} />\n\n      <p className=\"mt-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">Shipping address</p>\n      <div className=\"grid gap-4 sm:grid-cols-2\">\n        <Input label=\"First name\" autoComplete=\"given-name\" {...field(\"firstName\")} />\n        <Input label=\"Last name\" autoComplete=\"family-name\" {...field(\"lastName\")} />\n      </div>\n\n      <div className=\"relative\">\n        <Input\n          label=\"Address\"\n          placeholder=\"Start typing, try 12\"\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded={showList}\n          aria-controls={listId}\n          aria-activedescendant={showList ? `${listId}-${active}` : undefined}\n          name=\"address1\"\n          autoComplete=\"address-line1\"\n          value={a.address1}\n          error={errors.address1}\n          onChange={(e) => {\n            set(\"address1\", e.target.value)\n            setListOpen(true)\n            setActive(0)\n          }}\n          onKeyDown={onComboKey}\n          onFocus={() => setListOpen(true)}\n          onBlur={() => {\n            // Let a click on an option land before closing.\n            window.setTimeout(() => setListOpen(false), 120)\n            blur(\"address1\")\n          }}\n        />\n        <AnimatePresence>\n          {showList ? (\n            <motion.ul\n              id={listId}\n              role=\"listbox\"\n              aria-label=\"Address suggestions\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, y: -4 }}\n              animate={{ opacity: 1, scale: 1, y: 0 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}\n              style={{ transformOrigin: \"top\" }}\n              className=\"absolute inset-x-0 top-[calc(100%+4px)] z-20 overflow-hidden rounded-sk-card border border-sk-line bg-sk-card p-1 shadow-[0_24px_60px_-24px_rgb(var(--sk-ink)/0.25)]\"\n            >\n              {results.map((s, i) => (\n                <li\n                  key={s.line}\n                  id={`${listId}-${i}`}\n                  role=\"option\"\n                  aria-selected={i === active}\n                  onPointerDown={(e) => e.preventDefault()}\n                  onClick={() => pick(s)}\n                  onMouseEnter={() => setActive(i)}\n                  className={cn(\"flex min-h-11 cursor-pointer items-center gap-3 rounded-sk-btn px-3 text-sm\", i === active && \"bg-sk-ink/[0.05]\")}\n                >\n                  <MapPin size={14} className=\"shrink-0 text-sk-muted\" />\n                  <span className=\"min-w-0 truncate\">\n                    <span className=\"font-medium text-sk-ink\">{s.line}</span>\n                    <span className=\"text-sk-muted\">, {s.city}, {s.state} {s.zip}</span>\n                  </span>\n                </li>\n              ))}\n            </motion.ul>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      <Input label=\"Apartment, suite (optional)\" autoComplete=\"address-line2\" value={a.address2} onChange={(e) => set(\"address2\", e.target.value)} />\n\n      <div className=\"grid grid-cols-2 gap-4 sm:grid-cols-[1.4fr_1fr_1fr]\">\n        <div className=\"col-span-2 sm:col-span-1\">\n          <Input label=\"City\" autoComplete=\"address-level2\" {...field(\"city\")} />\n        </div>\n        <label htmlFor={stateId} className=\"block\">\n          <span className=\"mb-1.5 block text-[13px] font-medium text-sk-ink\">State</span>\n          <span className=\"relative block\">\n            <select\n              id={stateId}\n              name=\"state\"\n              autoComplete=\"address-level1\"\n              aria-describedby={errors.state ? `${stateId}-err` : undefined}\n              value={a.state}\n              aria-invalid={!!errors.state || undefined}\n              onChange={(e) => set(\"state\", e.target.value)}\n              onBlur={() => blur(\"state\")}\n              className={cn(\n                \"h-11 w-full appearance-none rounded-sk-btn border bg-sk-card pl-3.5 pr-9 text-sm text-sk-ink outline-none transition-[border-color,box-shadow] duration-200\",\n                \"focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15\",\n                errors.state ? \"border-sk-sale\" : \"border-sk-line\",\n                !a.state && \"text-sk-muted\"\n              )}\n            >\n              <option value=\"\">Select</option>\n              {STATES.map((s) => (\n                <option key={s} value={s}>{s}</option>\n              ))}\n            </select>\n            <ChevronDown size={14} className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sk-muted\" />\n          </span>\n          {errors.state ? <span id={`${stateId}-err`} role=\"alert\" className=\"mt-1.5 block text-xs text-sk-sale\">{errors.state}</span> : null}\n        </label>\n        <Input label=\"ZIP\" inputMode=\"numeric\" autoComplete=\"postal-code\" maxLength={10} {...field(\"zip\")} />\n      </div>\n\n      <Input label=\"Phone\" type=\"tel\" autoComplete=\"tel\" hint=\"For delivery updates only.\" {...field(\"phone\")} />\n\n      <label className=\"flex min-h-11 cursor-pointer items-center gap-3 text-sm\">\n        <span className=\"relative grid h-5 w-5 shrink-0 place-items-center\">\n          <input\n            type=\"checkbox\"\n            checked={a.save}\n            onChange={(e) => set(\"save\", e.target.checked)}\n            className={cn(\n              \"peer h-5 w-5 cursor-pointer appearance-none rounded-[min(var(--sk-radius-btn),6px)] border border-sk-line bg-sk-card transition-colors duration-200 checked:border-sk-accent checked:bg-sk-accent\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n            )}\n          />\n          <Check size={13} strokeWidth={3} className=\"pointer-events-none absolute text-sk-accent-ink opacity-0 transition-opacity duration-150 peer-checked:opacity-100\" />\n        </span>\n        Save this address for next time\n      </label>\n\n      <Button type=\"submit\" size=\"lg\" className=\"mt-2 w-full sm:w-auto sm:justify-self-start\">\n        {done ? (\n          <>\n            <Check size={16} />\n            Saved\n          </>\n        ) : (\n          submitLabel\n        )}\n      </Button>\n    </form>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/checkout/checkout-address.tsx"
    }
  ],
  "docs": "Pass suggest(query) to wire a real places API. Try typing 12 in the demo.",
  "meta": {
    "section": "checkout",
    "variant": "address",
    "niche": [
      "any"
    ],
    "commerce": [
      "checkout",
      "address",
      "autocomplete",
      "validation"
    ],
    "previewHeight": 820
  }
}