ecomcn
All blocks

Cart & checkout/block

Address form

A shipping or billing address whose fields follow the country: a state and ZIP code for the US, a town and postcode for the UK, family name first for Japan, a generic shape for the rest. Country and region are your shadcn Combobox, searchable, and every field carries its section-prefixed autocomplete token, so browser autofill fills the lot. validateAddress is a pure function in its own file, so your server runs the same rules. Error summary that links to each field, an address-lookup slot, save-as-default, a loading skeleton. Fields that come and go with the country grow and fold to their measured height (Motion; a fade under reduced motion).

See address form in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/address-form
Older shadcn CLI, or want the registry pinned?

Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json
Preview — live
100%

Usage

In your checkout
import { AddressForm } from "@/components/ecomcn/address-form"
import { emptyAddress, validateAddress, type AddressErrors } from "@/lib/address-format"

const [address, setAddress] = React.useState(emptyAddress("US"))
const [errors, setErrors] = React.useState<AddressErrors>({})

<form
  noValidate
  onSubmit={(event) => {
    event.preventDefault()
    const found = validateAddress(address)   // {} when it will do
    setErrors(found)
    if (Object.keys(found).length === 0) goToDelivery()
  }}
>
  <AddressForm
    value={address}
    onValueChange={setAddress}
    errors={errors}
    countries={["US", "CA", "GB"]}          // the countries you ship to, in your order
  />
  <Button type="submit">Continue</Button>
</form>
The same rules on your server
// No "use client" in this file: import it from a route handler or server action.
import { getAddressFormat, validateAddress, type Address } from "@/lib/address-format"

export async function saveAddress(address: Address) {
  const errors = validateAddress(address, getAddressFormat(address.country, { fields }))
  if (Object.keys(errors).length) return { errors }   // hand them back to the form
  // ...
}
Your address search in the lookup slot
import { AddressForm, useAddressForm } from "@/components/ecomcn/address-form"

function PlacesSearch() {
  const { setAddress } = useAddressForm()   // fills several fields in one go
  return <YourPlacesAutocomplete onPick={(place) => setAddress(toAddress(place))} />
}

<AddressForm
  value={address}
  onValueChange={setAddress}
  lookup={<PlacesSearch />}
  fields={{ organization: "optional", phone: "required" }}
  saveAsDefault={saveDefault}
  onSaveAsDefaultChange={setSaveDefault}
/>
A country of your own
// Merged over the built-in table — add a country, or relabel one.
const formats = {
  NL: {
    rows: [["givenName", "familyName"], ["line1"], ["line2"], ["postalCode", "city"], ["phone"]],
    required: ["givenName", "familyName", "line1", "postalCode", "city"],
    labels: { line1: "Street and number", postalCode: "Postcode" },
    postalCode: { pattern: /^\d{4}\s?[A-Z]{2}$/i, example: "1012 AB" },
  },
}

<AddressForm value={address} onValueChange={setAddress} formats={formats} />
validateAddress(address, getAddressFormat(address.country, { formats }))

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

AddressFormContextValue
value: Address
  /** The format for the chosen country, with your `formats` and `fields` applied. */
  format: AddressFormat
  /** Sets one field. A new country drops a region it doesn't have. */
  setField: (field: Part, next: string) => void
  /** Sets several fields at once — what an address lookup calls with its result. */
  setAddress: (patch: Partial<Address>) => void
  /** The errors on show: yours, less any field the shopper has edited since. */
  errors: AddressErrors
  section: "shipping" | "billing"
  /** The id of a field's input, for a label or a link of your own. */
  fieldId: (field: Part) => string
  /** Moves focus to a field and scrolls it into view. */
  focusField: (field: Part) => void
  countries: Option[]
  name?: string
  lookup?: React.ReactNode
  saveAsDefault: boolean
  /** Undefined when the form was given no `onSaveAsDefaultChange`. */
  setSaveAsDefault?: (checked: boolean) => void
  disabled: boolean
  loading: boolean
AddressFormProps
value: Address
  onValueChange: (address: Address) => void
  /** Prefixes every autocomplete token, so a billing form never autofills as shipping. */
  section?: "shipping" | "billing"
  /** Messages by field — from `validateAddress`, your schema or your server. */
  errors?: AddressErrors | null
  /** ISO codes to offer, in your order. Every country, by name, when left out. */
  countries?: string[]
  /** Your countries, or changes to ours — see `AddressFormatInput`. */
  formats?: Record<string, Partial<AddressFormatInput>>
  /** Show, hide or require the company, second line and phone in every country. */
  fields?: AddressFieldModes
  /** For country names. `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** "Shipping address" or "Billing address", by default. */
  legend?: React.ReactNode
  /** Keeps the legend for screen readers when your page already has a heading. */
  hideLegend?: boolean
  /** An address search — Google Places, Loqate, your own — placed above the fields. */
  lookup?: React.ReactNode
  saveAsDefault?: boolean
  /** Shows "Save as my default address"; leave it out to hide the checkbox. */
  onSaveAsDefaultChange?: (checked: boolean) => void
  /** Names every input `${name}.city` and so on, for a plain form post. */
  name?: string
  loading?: boolean
  children?: React.ReactNode
AddressFormCountryProps
/** "Country / region", by default. */
  label?: string
AddressFormFieldProps
field: AddressFieldName
AddressFormSaveDefaultProps
/** "Save as my default shipping address", by default. */
  label?: React.ReactNode

Source

components/ecomcn/address-form.tsx
"use client"

import * as React from "react"
import { CircleAlert } from "lucide-react"
import { AnimatePresence, MotionConfig, animate, motion, useReducedMotion } from "motion/react"

import { Checkbox } from "@/components/ui/checkbox"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
  COUNTRY_CODES,
  getAddressFormat,
  type Address,
  type AddressErrors,
  type AddressFieldModes,
  type AddressFieldName,
  type AddressFormat,
  type AddressFormatInput,
} from "@/lib/address-format"
import { cn } from "@/lib/utils"

/*
 * A shipping or billing address whose fields follow the country: a US
 * address asks for a state and a ZIP code, a UK one for a town and a
 * postcode, a Japanese one starts with the family name.
 *
 *   const [address, setAddress] = React.useState(emptyAddress("US"))
 *   <AddressForm value={address} onValueChange={setAddress} errors={errors} />
 *
 *   <AddressForm value={address} onValueChange={setAddress}>          or composed
 *     <AddressFormErrorSummary />
 *     <AddressFormCountry />
 *     <AddressFormFields />
 *   </AddressForm>
 *
 * Country and region are your shadcn Combobox — type "ger" for Germany —
 * and every field carries its `autocomplete` token, so browser autofill
 * fills the lot, country and state included. Validation is yours to run:
 * `validateAddress` from `@/lib/address-format` is a pure function, so the
 * server checks with the same rules; pass what it returns as `errors`.
 * Fields that come and go with the country grow and fold to their measured
 * height, and the lists grow out of their field (Motion; a fade under
 * reduced motion).
 */

export type { Address, AddressErrors, AddressFieldModes, AddressFieldName, AddressFormat } from "@/lib/address-format"

/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
const FADE = { duration: 0.15 } as const

const subscribeNothing = () => () => {}

/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */
function useFormatLocale(locale?: string) {
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
  return locale ?? (hydrated ? undefined : "en-US")
}

/** Each field's autofill token, after the section ("shipping" / "billing"). */
const AUTOCOMPLETE: Record<AddressFieldName, string> = {
  givenName: "given-name",
  familyName: "family-name",
  organization: "organization",
  line1: "address-line1",
  line2: "address-line2",
  city: "address-level2",
  region: "address-level1",
  postalCode: "postal-code",
  phone: "tel",
}

/** Side by side once the form is wide enough — the form's width, not the screen's. */
const COLUMNS: Record<number, string> = {
  1: "",
  2: "@sm/address-form:grid-cols-2",
  3: "@sm/address-form:grid-cols-2 @md/address-form:grid-cols-3",
}

/** Grows to its measured height and folds back; a plain fade under reduced motion. */
function fold(reduce: boolean | null) {
  return reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: FADE }
    : {
        initial: { height: 0, opacity: 0, overflow: "hidden" },
        // Visible once open, so focus rings aren't clipped.
        animate: { height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } },
        exit: { height: 0, opacity: 0, overflow: "hidden" },
        transition: MORPH,
      }
}

/** "State" -> "state", but "ZIP code" keeps its capitals. */
const lower = (label: string) => (/^[A-Z][a-z]/.test(label) ? label[0].toLowerCase() + label.slice(1) : label)

type Option = { value: string; label: string }

/** Lower case, accents off: "cote" finds "Côte d’Ivoire". */
const plain = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase()

/**
 * Best match first: the code itself ("de"), then names that start with what
 * was typed ("ger" -> Germany), then a word inside the name ("kingdom"),
 * then anywhere — so "ger" doesn't open on Algeria.
 */
function rank(options: Option[], query: string) {
  const q = plain(query.trim())
  if (!q) return options
  const scored: { option: Option; score: number; index: number }[] = []
  options.forEach((option, index) => {
    const label = plain(option.label)
    const at = label.indexOf(q)
    let score = -1
    if (option.value.toLowerCase() === q) score = 0
    else if (at === 0) score = 1
    else if (at > 0 && /[\s(\-’']/.test(label[at - 1])) score = 2
    else if (at > 0) score = 3
    if (score !== -1) scored.push({ option, score, index })
  })
  return scored.sort((a, b) => a.score - b.score || a.index - b.index).map((s) => s.option)
}

/* ─── context ──────────────────────────────────────────────────────────── */

type Part = AddressFieldName | "country"

export interface AddressFormContextValue {
  value: Address
  /** The format for the chosen country, with your `formats` and `fields` applied. */
  format: AddressFormat
  /** Sets one field. A new country drops a region it doesn't have. */
  setField: (field: Part, next: string) => void
  /** Sets several fields at once — what an address lookup calls with its result. */
  setAddress: (patch: Partial<Address>) => void
  /** The errors on show: yours, less any field the shopper has edited since. */
  errors: AddressErrors
  section: "shipping" | "billing"
  /** The id of a field's input, for a label or a link of your own. */
  fieldId: (field: Part) => string
  /** Moves focus to a field and scrolls it into view. */
  focusField: (field: Part) => void
  countries: Option[]
  name?: string
  lookup?: React.ReactNode
  saveAsDefault: boolean
  /** Undefined when the form was given no `onSaveAsDefaultChange`. */
  setSaveAsDefault?: (checked: boolean) => void
  disabled: boolean
  loading: boolean
}

const AddressFormContext = React.createContext<AddressFormContextValue | null>(null)

export function useAddressForm() {
  const context = React.useContext(AddressFormContext)
  if (!context) {
    throw new Error("useAddressForm must be used within <AddressForm>.")
  }
  return context
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface AddressFormProps
  extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue" | "name" | "children"> {
  value: Address
  onValueChange: (address: Address) => void
  /** Prefixes every autocomplete token, so a billing form never autofills as shipping. */
  section?: "shipping" | "billing"
  /** Messages by field — from `validateAddress`, your schema or your server. */
  errors?: AddressErrors | null
  /** ISO codes to offer, in your order. Every country, by name, when left out. */
  countries?: string[]
  /** Your countries, or changes to ours — see `AddressFormatInput`. */
  formats?: Record<string, Partial<AddressFormatInput>>
  /** Show, hide or require the company, second line and phone in every country. */
  fields?: AddressFieldModes
  /** For country names. `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** "Shipping address" or "Billing address", by default. */
  legend?: React.ReactNode
  /** Keeps the legend for screen readers when your page already has a heading. */
  hideLegend?: boolean
  /** An address search — Google Places, Loqate, your own — placed above the fields. */
  lookup?: React.ReactNode
  saveAsDefault?: boolean
  /** Shows "Save as my default address"; leave it out to hide the checkbox. */
  onSaveAsDefaultChange?: (checked: boolean) => void
  /** Names every input `${name}.city` and so on, for a plain form post. */
  name?: string
  loading?: boolean
  children?: React.ReactNode
}

export function AddressForm({
  value,
  onValueChange,
  section = "shipping",
  errors,
  countries,
  formats,
  fields,
  locale: localeProp,
  legend,
  hideLegend = false,
  lookup,
  saveAsDefault = false,
  onSaveAsDefaultChange,
  name,
  loading = false,
  disabled = false,
  className,
  children,
  ...props
}: AddressFormProps) {
  const locale = useFormatLocale(localeProp)
  const reduce = useReducedMotion()
  const uid = React.useId()

  const formatOptions = React.useMemo(() => ({ formats, fields }), [formats, fields])
  const format = React.useMemo(() => getAddressFormat(value.country ?? "", formatOptions), [value.country, formatOptions])

  // Autofill fires a change per field in one tick, before React re-renders;
  // building each from the last one sent keeps all of them.
  const latest = React.useRef(value)
  React.useLayoutEffect(() => {
    latest.current = value
  }, [value])
  const emit = React.useCallback(
    (next: Address) => {
      latest.current = next
      onValueChange(next)
    },
    [onValueChange]
  )

  // A field's error stays until the shopper edits it, or new errors arrive.
  const errorsKey = JSON.stringify(errors ?? {})
  const [edited, setEdited] = React.useState({ errorsKey, fields: new Set<Part>() })
  if (edited.errorsKey !== errorsKey) setEdited({ errorsKey, fields: new Set() })
  const visibleErrors = React.useMemo(() => {
    const shown: AddressErrors = {}
    for (const [field, message] of Object.entries(errors ?? {}) as [Part, string | undefined][]) {
      if (message && !edited.fields.has(field)) shown[field] = message
    }
    return shown
  }, [errors, edited])
  const markEdited = React.useCallback(
    (parts: Part[]) =>
      setEdited((prev) => (parts.every((p) => prev.fields.has(p)) ? prev : { ...prev, fields: new Set([...prev.fields, ...parts]) })),
    []
  )

  const setAddress = React.useCallback(
    (patch: Partial<Address>) => {
      const current = latest.current
      const next = { ...current, ...patch }
      if (patch.country !== undefined && patch.country !== current.country && patch.region === undefined) {
        // A region only carries over between countries that both type it in.
        const from = getAddressFormat(current.country ?? "", formatOptions).regions
        const to = getAddressFormat(patch.country, formatOptions).regions
        const keeps = to ? to.some((r) => r.code === current.region) : !from
        if (!keeps) next.region = ""
      }
      markEdited(Object.keys(patch) as Part[])
      emit(next)
    },
    [emit, formatOptions, markEdited]
  )
  const setField = React.useCallback((field: Part, next: string) => setAddress({ [field]: next }), [setAddress])

  const fieldId = React.useCallback((field: Part) => `${uid}-${field}`, [uid])
  const focusField = React.useCallback(
    (field: Part) => {
      const input = document.getElementById(fieldId(field))
      if (!input) return
      input.focus({ preventScroll: true })
      input.scrollIntoView({ block: "center", behavior: reduce ? "auto" : "smooth" })
    },
    [fieldId, reduce]
  )

  const countryOptions = useCountryOptions(countries, value.country, locale)

  const context = React.useMemo<AddressFormContextValue>(
    () => ({
      value,
      format,
      setField,
      setAddress,
      errors: visibleErrors,
      section,
      fieldId,
      focusField,
      countries: countryOptions,
      name,
      lookup,
      saveAsDefault,
      setSaveAsDefault: onSaveAsDefaultChange,
      disabled,
      loading,
    }),
    [value, format, setField, setAddress, visibleErrors, section, fieldId, focusField, countryOptions, name, lookup, saveAsDefault, onSaveAsDefaultChange, disabled, loading]
  )

  return (
    <AddressFormContext.Provider value={context}>
      <MotionConfig reducedMotion="user">
        <FieldSet
          data-slot="address-form"
          data-section={section}
          disabled={disabled}
          aria-busy={loading || undefined}
          className={cn("@container/address-form min-w-0 gap-5", className)}
          {...props}
        >
          <FieldLegend className={cn(hideLegend && "sr-only")}>
            {legend ?? (section === "billing" ? "Billing address" : "Shipping address")}
          </FieldLegend>
          {loading ? (
            <AddressFormSkeleton />
          ) : (
            (children ?? (
              <>
                <AddressFormErrorSummary />
                <AddressFormCountry />
                <AddressFormLookup />
                <AddressFormFields />
                <AddressFormSaveDefault />
              </>
            ))
          )}
        </FieldSet>
      </MotionConfig>
    </AddressFormContext.Provider>
  )
}

/** Country names in the visitor's language: every country by name, or yours in your order. */
function useCountryOptions(codes: string[] | undefined, current: string | undefined, locale: string | undefined) {
  const key = codes?.join(",")
  return React.useMemo(() => {
    let names: Intl.DisplayNames | null = null
    try {
      names = new Intl.DisplayNames(locale ? [locale] : undefined, { type: "region" })
    } catch {
      // Very old engines: show the codes.
    }
    const list = key ? key.split(",") : [...COUNTRY_CODES]
    if (current && !list.includes(current.toUpperCase())) list.push(current.toUpperCase())
    const options = list.map((code) => ({ value: code.toUpperCase(), label: names?.of(code.toUpperCase()) ?? code }))
    if (!key) options.sort((a, b) => a.label.localeCompare(b.label, locale))
    return options
  }, [key, current, locale])
}

/* ─── a combobox that grows out of its field ───────────────────────────── */

const OPEN_CLIP = "inset(-10% -10% -10% -10%)"

interface AddressComboboxProps {
  id: string
  options: Option[]
  value: string
  onValueChange: (value: string) => void
  autoComplete: string
  name?: string
  invalid: boolean
  describedBy?: string
  required: boolean
  disabled: boolean
  empty: string
}

/**
 * Your shadcn Combobox, searchable, with its list growing out of the field
 * it belongs to and folding back into it. Base UI is told to wait for the
 * fold before it unmounts the list.
 */
function AddressCombobox({
  id,
  options,
  value,
  onValueChange,
  autoComplete,
  name,
  invalid,
  describedBy,
  required,
  disabled,
  empty,
}: AddressComboboxProps) {
  const reduce = useReducedMotion()
  const popupId = React.useId()
  const actionsRef = React.useRef<{ unmount: () => void; close: () => void } | null>(null)
  const phase = React.useRef<"closed" | "opening" | "open" | "closing">("closed")
  const running = React.useRef<{ stop: () => void } | null>(null)
  const frame = React.useRef(0)

  const selected = React.useMemo(() => options.find((o) => o.value === value) ?? null, [options, value])
  const [query, setQuery] = React.useState("")
  const filtered = React.useMemo(() => rank(options, query), [options, query])
  const findPopup = () => document.querySelector<HTMLElement>(`[data-address-popup="${popupId}"]`)

  /** `fromHere` reverses a fold already under way rather than restarting it. */
  const play = (popup: HTMLElement, open: boolean, fromHere = false) => {
    running.current?.stop()
    if (reduce) {
      const controls = animate(popup, { opacity: open ? 1 : 0 }, FADE)
      running.current = controls
      return controls
    }
    // From the field's edge: down from below it, up from above it.
    const shut = popup.getAttribute("data-side") === "top" ? "inset(100% -10% -10% -10%)" : "inset(-10% -10% 100% -10%)"
    const controls = animate(
      popup,
      { clipPath: open && !fromHere ? [shut, OPEN_CLIP] : open ? OPEN_CLIP : shut },
      MORPH
    )
    running.current = controls
    return controls
  }

  // The list mounts a frame or two after the open request, in a portal.
  const playOpen = (tries = 12) => {
    cancelAnimationFrame(frame.current)
    const popup = findPopup()
    if (!popup) {
      if (tries > 0) frame.current = requestAnimationFrame(() => playOpen(tries - 1))
      return
    }
    const reversing = phase.current === "closing"
    phase.current = "opening"
    play(popup, true, reversing).then(() => {
      if (phase.current === "opening") phase.current = "open"
    })
  }

  React.useEffect(() => () => cancelAnimationFrame(frame.current), [])

  return (
    <Combobox
      items={options}
      filteredItems={filtered}
      onInputValueChange={(input: string) => setQuery(input)}
      value={selected}
      onValueChange={(next: Option | null) => onValueChange(next?.value ?? "")}
      isItemEqualToValue={(a: Option, b: Option) => a.value === b.value}
      autoHighlight
      // The browser fills a hidden input with this token; Base UI matches
      // "US" or "United States" to an option and selects it.
      autoComplete={autoComplete}
      name={name}
      disabled={disabled}
      actionsRef={actionsRef}
      onOpenChange={(open, details) => {
        if (open) {
          playOpen()
          return
        }
        cancelAnimationFrame(frame.current)
        const popup = findPopup()
        if (!popup) return
        details.preventUnmountOnClose()
        phase.current = "closing"
        play(popup, false).then(() => {
          if (phase.current !== "closing") return
          phase.current = "closed"
          actionsRef.current?.unmount()
        })
      }}
    >
      <ComboboxInput
        id={id}
        className="w-full"
        disabled={disabled}
        aria-invalid={invalid || undefined}
        aria-describedby={describedBy}
        aria-required={required || undefined}
      />
      <ComboboxContent
        data-address-popup={popupId}
        className={cn(
          // Motion runs the open and close; the primitive's keyframes would fight it.
          "animate-none!",
          reduce
            ? "opacity-0"
            : "[clip-path:inset(-10%_-10%_100%_-10%)] data-[side=top]:[clip-path:inset(100%_-10%_-10%_-10%)]"
        )}
      >
        <ComboboxEmpty>{empty}</ComboboxEmpty>
        <ComboboxList className="[scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
          {(option: Option) => (
            <ComboboxItem key={option.value} value={option}>
              {option.label}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

/* ─── parts ────────────────────────────────────────────────────────────── */

function FieldLabelText({ label, required }: { label: string; required: boolean }) {
  return (
    <>
      {label}
      {!required && <span className="font-normal text-muted-foreground">(optional)</span>}
    </>
  )
}

export interface AddressFormCountryProps extends React.HTMLAttributes<HTMLDivElement> {
  /** "Country / region", by default. */
  label?: string
}

/** The country, first: it decides every field below it. */
export function AddressFormCountry({ label = "Country / region", className, ...props }: AddressFormCountryProps) {
  const { value, countries, setField, errors, fieldId, section, name, disabled } = useAddressForm()
  const id = fieldId("country")
  const error = errors.country
  return (
    <Field
      data-slot="address-form-country"
      data-invalid={error ? true : undefined}
      data-disabled={disabled || undefined}
      className={className}
      {...props}
    >
      <FieldLabel htmlFor={id}>{label}</FieldLabel>
      <AddressCombobox
        id={id}
        options={countries}
        value={value.country ?? ""}
        onValueChange={(next) => setField("country", next)}
        autoComplete={`${section} country`}
        name={name ? `${name}.country` : undefined}
        invalid={!!error}
        describedBy={error ? `${id}-error` : undefined}
        required
        disabled={disabled}
        empty="No country matches"
      />
      <FieldError id={`${id}-error`} role={undefined}>
        {error}
      </FieldError>
    </Field>
  )
}

export interface AddressFormFieldProps extends React.HTMLAttributes<HTMLDivElement> {
  field: AddressFieldName
}

/** One field, labelled and wired: its autofill token, its error, its keyboard. */
export function AddressFormField({ field, className, ...props }: AddressFormFieldProps) {
  const { value, format, setField, errors, fieldId, section, name, disabled } = useAddressForm()
  const id = fieldId(field)
  const error = errors[field]
  const required = format.required.includes(field)
  const label = format.labels[field]
  const describedBy = error ? `${id}-error` : undefined
  const autoComplete = `${section} ${AUTOCOMPLETE[field]}`
  const inputName = name ? `${name}.${field}` : undefined

  const control =
    field === "region" && format.regions ? (
      <AddressCombobox
        id={id}
        options={format.regions.map((r) => ({ value: r.code, label: r.name }))}
        value={value.region ?? ""}
        onValueChange={(next) => setField("region", next)}
        autoComplete={autoComplete}
        name={inputName}
        invalid={!!error}
        describedBy={describedBy}
        required={required}
        disabled={disabled}
        empty={`No ${lower(label)} matches`}
      />
    ) : (
      <Input
        id={id}
        name={inputName}
        value={value[field] ?? ""}
        onChange={(event) => setField(field, event.target.value)}
        autoComplete={autoComplete}
        aria-invalid={error ? true : undefined}
        aria-describedby={describedBy}
        aria-required={required || undefined}
        disabled={disabled}
        {...(field === "phone" && { type: "tel", inputMode: "tel" as const })}
        {...(field === "postalCode" && {
          inputMode: format.postalCode?.numeric ? ("numeric" as const) : undefined,
          autoCapitalize: format.postalCode?.numeric ? undefined : "characters",
          spellCheck: false,
        })}
        {...((field === "givenName" || field === "familyName" || field === "city") && { autoCapitalize: "words" })}
      />
    )

  return (
    <Field
      data-slot="address-form-field"
      data-field={field}
      data-invalid={error ? true : undefined}
      data-disabled={disabled || undefined}
      className={cn("min-w-0", className)}
      {...props}
    >
      <FieldLabel htmlFor={id}>
        <FieldLabelText label={label} required={required} />
      </FieldLabel>
      {control}
      <FieldError id={`${id}-error`} role={undefined}>
        {error}
      </FieldError>
    </Field>
  )
}

/**
 * The country's fields, row by row. A row the new country doesn't have
 * folds away where it stood and a new one grows in where it belongs; the
 * rows that stay move only as far as those heights carry them. No layout
 * slide on top: two motions on one row is how fields end up overlapping.
 */
export function AddressFormFields({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { format } = useAddressForm()
  const reduce = useReducedMotion()
  const motionProps = fold(reduce)
  return (
    <div data-slot="address-form-fields" className={cn("flex flex-col", className)} {...props}>
      <AnimatePresence initial={false}>
        {format.rows.map((row) => (
          <motion.div key={row.join("+")} {...motionProps}>
            <div className={cn("grid gap-4 pb-5", COLUMNS[row.length] ?? COLUMNS[3])}>
              {row.map((field) => (
                <AddressFormField key={field} field={field} />
              ))}
            </div>
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  )
}

/** Your address search, above the fields. Calls `useAddressForm().setAddress` with its result. */
export function AddressFormLookup({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { lookup } = useAddressForm()
  const content = children ?? lookup
  if (!content) return null
  return (
    <div data-slot="address-form-lookup" className={className} {...props}>
      {content}
    </div>
  )
}

export interface AddressFormSaveDefaultProps extends React.HTMLAttributes<HTMLLabelElement> {
  /** "Save as my default shipping address", by default. */
  label?: React.ReactNode
}

/** Shown only when the form was given `onSaveAsDefaultChange`. */
export function AddressFormSaveDefault({ label, className, ...props }: AddressFormSaveDefaultProps) {
  const { saveAsDefault, setSaveAsDefault, section, disabled } = useAddressForm()
  if (!setSaveAsDefault) return null
  return (
    <label
      data-slot="address-form-save-default"
      className={cn("flex w-fit cursor-pointer items-center gap-3 text-sm", disabled && "cursor-not-allowed opacity-50", className)}
      {...props}
    >
      <Checkbox checked={saveAsDefault} disabled={disabled} onCheckedChange={(checked: boolean) => setSaveAsDefault(checked === true)} />
      {label ?? `Save as my default ${section} address`}
    </label>
  )
}

/**
 * Every error at once, at the top, each a link to its field — for the
 * shopper who pressed continue at the bottom. It takes focus when it
 * appears, and folds away as the fields are fixed.
 */
export function AddressFormErrorSummary({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { errors, format, focusField, fieldId } = useAddressForm()
  const reduce = useReducedMotion()
  const order: Part[] = ["country", ...format.rows.flat()]
  const entries = order.flatMap((field) => (errors[field] ? [{ field, message: errors[field] as string }] : []))
  return (
    <AnimatePresence initial={false}>
      {entries.length > 0 && (
        <motion.div key="summary" {...fold(reduce)}>
          <ErrorSummaryBody
            entries={entries}
            focusField={focusField}
            fieldId={fieldId}
            className={className}
            {...props}
          />
        </motion.div>
      )}
    </AnimatePresence>
  )
}

/** Takes its entries as props, so it keeps them while the summary folds away. */
function ErrorSummaryBody({
  entries,
  focusField,
  fieldId,
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & {
  entries: { field: Part; message: string }[]
  focusField: (field: Part) => void
  fieldId: (field: Part) => string
}) {
  const ref = React.useRef<HTMLDivElement>(null)
  React.useEffect(() => {
    ref.current?.focus({ preventScroll: true })
    ref.current?.scrollIntoView({ block: "nearest" })
  }, [])
  return (
    <div
      ref={ref}
      role="alert"
      tabIndex={-1}
      data-slot="address-form-error-summary"
      className={cn("mb-1 border-l-2 border-destructive py-1 pl-4 text-sm outline-none", className)}
      {...props}
    >
      <p className="flex items-center gap-2 font-medium text-destructive">
        <CircleAlert className="size-4 shrink-0" aria-hidden />
        {entries.length === 1 ? "Check this field" : `Check these ${entries.length} fields`}
      </p>
      <ul className="mt-2 flex flex-col gap-1">
        {entries.map(({ field, message }) => (
          <li key={field}>
            <a
              href={`#${fieldId(field)}`}
              onClick={(event) => {
                event.preventDefault()
                focusField(field)
              }}
              className="underline underline-offset-4 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              {message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  )
}

/** The form's shape, while you load a saved address. */
function AddressFormSkeleton() {
  const { format } = useAddressForm()
  const rows: string[][] = [["country"], ...format.rows]
  return (
    <div data-slot="address-form-skeleton" className="flex flex-col gap-5">
      <span className="sr-only" role="status">
        Loading the address form
      </span>
      {rows.map((row) => (
        <div key={row.join("+")} aria-hidden className={cn("grid gap-4", COLUMNS[row.length] ?? COLUMNS[3])}>
          {row.map((field) => (
            <div key={field} className="flex flex-col gap-3">
              <Skeleton className="h-3.5 w-24 rounded-none" />
              <Skeleton className="h-9 w-full rounded-none" />
            </div>
          ))}
        </div>
      ))}
    </div>
  )
}
lib/address-format.ts
/*
 * What an address looks like in each country, and the rules that check one.
 *
 *   const format = getAddressFormat("GB")
 *   const errors = validateAddress(address, format)   // {} when it's fine
 *
 * No "use client" and no React: import it on the server and run the same
 * checks there that the form runs in the browser. `address-form` draws its
 * fields from these formats; pass `formats` to either to add a country or
 * change one.
 */

export type AddressFieldName =
  | "givenName"
  | "familyName"
  | "organization"
  | "line1"
  | "line2"
  | "city"
  | "region"
  | "postalCode"
  | "phone"

export interface Address {
  /** ISO 3166-1 alpha-2: "US", "IN". */
  country: string
  givenName: string
  familyName: string
  organization?: string
  line1: string
  line2?: string
  city: string
  /**
   * With a region list, the ISO 3166-2 subdivision code without the country
   * prefix — "CA" for California, "MH" for Maharashtra, "13" for Tokyo.
   * Otherwise whatever the shopper typed.
   */
  region?: string
  postalCode?: string
  phone?: string
}

/** An error message for each field that needs fixing; empty when nothing does. */
export type AddressErrors = Partial<Record<AddressFieldName | "country", string>>

export interface AddressRegion {
  code: string
  name: string
}

export interface AddressFormat {
  /** Top to bottom; fields in a row sit side by side once the form is wide enough. */
  rows: AddressFieldName[][]
  /** Fields the shopper must fill. Every other field in `rows` is optional. */
  required: AddressFieldName[]
  labels: Record<AddressFieldName, string>
  /** When set, the region is chosen from this list rather than typed. */
  regions?: AddressRegion[]
  postalCode?: {
    pattern: RegExp
    /** Shown in the error: "Enter a valid ZIP code, like 94103". */
    example: string
    /** Digits only, so phones show the number pad. */
    numeric?: boolean
  }
}

/** A country's format as you write it: labels you leave out keep their defaults. */
export type AddressFormatInput = Omit<AddressFormat, "labels"> & {
  labels?: Partial<Record<AddressFieldName, string>>
}

/** Whether a field is shown, and whether it must be filled — over any country's format. */
export type AddressFieldMode = "hidden" | "optional" | "required"
export type AddressFieldModes = Partial<Record<"organization" | "line2" | "phone", AddressFieldMode>>

export const DEFAULT_ADDRESS_LABELS: Record<AddressFieldName, string> = {
  givenName: "First name",
  familyName: "Last name",
  organization: "Company",
  line1: "Address",
  line2: "Apartment, suite, etc.",
  city: "City",
  region: "Region",
  postalCode: "Postal code",
  phone: "Phone",
}

/* ─── regions ──────────────────────────────────────────────────────────── */

const list = (entries: string): AddressRegion[] =>
  entries
    .trim()
    .split("\n")
    .map((line) => {
      const [code, name] = line.trim().split("|")
      return { code, name }
    })

const US_STATES = list(`
  AL|Alabama
  AK|Alaska
  AS|American Samoa
  AZ|Arizona
  AR|Arkansas
  CA|California
  CO|Colorado
  CT|Connecticut
  DE|Delaware
  DC|District of Columbia
  FL|Florida
  GA|Georgia
  GU|Guam
  HI|Hawaii
  ID|Idaho
  IL|Illinois
  IN|Indiana
  IA|Iowa
  KS|Kansas
  KY|Kentucky
  LA|Louisiana
  ME|Maine
  MD|Maryland
  MA|Massachusetts
  MI|Michigan
  MN|Minnesota
  MS|Mississippi
  MO|Missouri
  MT|Montana
  NE|Nebraska
  NV|Nevada
  NH|New Hampshire
  NJ|New Jersey
  NM|New Mexico
  NY|New York
  NC|North Carolina
  ND|North Dakota
  MP|Northern Mariana Islands
  OH|Ohio
  OK|Oklahoma
  OR|Oregon
  PA|Pennsylvania
  PR|Puerto Rico
  RI|Rhode Island
  SC|South Carolina
  SD|South Dakota
  TN|Tennessee
  TX|Texas
  UT|Utah
  VT|Vermont
  VI|U.S. Virgin Islands
  VA|Virginia
  WA|Washington
  WV|West Virginia
  WI|Wisconsin
  WY|Wyoming
`)

const CA_PROVINCES = list(`
  AB|Alberta
  BC|British Columbia
  MB|Manitoba
  NB|New Brunswick
  NL|Newfoundland and Labrador
  NT|Northwest Territories
  NS|Nova Scotia
  NU|Nunavut
  ON|Ontario
  PE|Prince Edward Island
  QC|Quebec
  SK|Saskatchewan
  YT|Yukon
`)

const AU_STATES = list(`
  ACT|Australian Capital Territory
  NSW|New South Wales
  NT|Northern Territory
  QLD|Queensland
  SA|South Australia
  TAS|Tasmania
  VIC|Victoria
  WA|Western Australia
`)

const IN_STATES = list(`
  AN|Andaman and Nicobar Islands
  AP|Andhra Pradesh
  AR|Arunachal Pradesh
  AS|Assam
  BR|Bihar
  CH|Chandigarh
  CG|Chhattisgarh
  DH|Dadra and Nagar Haveli and Daman and Diu
  DL|Delhi
  GA|Goa
  GJ|Gujarat
  HR|Haryana
  HP|Himachal Pradesh
  JK|Jammu and Kashmir
  JH|Jharkhand
  KA|Karnataka
  KL|Kerala
  LA|Ladakh
  LD|Lakshadweep
  MP|Madhya Pradesh
  MH|Maharashtra
  MN|Manipur
  ML|Meghalaya
  MZ|Mizoram
  NL|Nagaland
  OD|Odisha
  PY|Puducherry
  PB|Punjab
  RJ|Rajasthan
  SK|Sikkim
  TN|Tamil Nadu
  TS|Telangana
  TR|Tripura
  UP|Uttar Pradesh
  UK|Uttarakhand
  WB|West Bengal
`)

// North to south, the order Japanese forms list them in.
const JP_PREFECTURES = `Hokkaido Aomori Iwate Miyagi Akita Yamagata Fukushima Ibaraki Tochigi
  Gunma Saitama Chiba Tokyo Kanagawa Niigata Toyama Ishikawa Fukui Yamanashi Nagano Gifu
  Shizuoka Aichi Mie Shiga Kyoto Osaka Hyogo Nara Wakayama Tottori Shimane Okayama
  Hiroshima Yamaguchi Tokushima Kagawa Ehime Kochi Fukuoka Saga Nagasaki Kumamoto Oita
  Miyazaki Kagoshima Okinawa`
  .split(/\s+/)
  .map((name, i) => ({ code: String(i + 1).padStart(2, "0"), name }))

/* ─── formats ──────────────────────────────────────────────────────────── */

const NAMES: AddressFieldName[] = ["givenName", "familyName"]
const STREET: AddressFieldName[][] = [["line1"], ["line2"]]

/** Any country without its own entry: every field, nothing assumed. */
export const GENERIC_ADDRESS_FORMAT: AddressFormatInput = {
  rows: [NAMES, ...STREET, ["city"], ["region", "postalCode"], ["phone"]],
  required: ["givenName", "familyName", "line1", "city"],
  labels: { region: "State / province / region" },
}

export const ADDRESS_FORMATS: Record<string, AddressFormatInput> = {
  US: {
    rows: [NAMES, ...STREET, ["city", "region", "postalCode"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "region", "postalCode"],
    labels: { region: "State", postalCode: "ZIP code" },
    regions: US_STATES,
    postalCode: { pattern: /^\d{5}(?:[-\s]?\d{4})?$/, example: "94103", numeric: true },
  },
  CA: {
    rows: [NAMES, ...STREET, ["city", "region", "postalCode"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "region", "postalCode"],
    labels: { region: "Province" },
    regions: CA_PROVINCES,
    postalCode: { pattern: /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z][\s-]?\d[ABCEGHJ-NPRSTV-Z]\d$/i, example: "K1A 0B1" },
  },
  GB: {
    rows: [NAMES, ...STREET, ["city", "postalCode"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "postalCode"],
    labels: { city: "Town or city", postalCode: "Postcode" },
    postalCode: { pattern: /^(?:GIR\s?0AA|[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2})$/i, example: "SW1A 1AA" },
  },
  AU: {
    rows: [NAMES, ...STREET, ["city", "region", "postalCode"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "region", "postalCode"],
    labels: { city: "Suburb", region: "State", postalCode: "Postcode" },
    regions: AU_STATES,
    postalCode: { pattern: /^\d{4}$/, example: "2000", numeric: true },
  },
  IN: {
    rows: [NAMES, ...STREET, ["city", "region", "postalCode"], ["phone"]],
    // Couriers in India call before delivering.
    required: ["givenName", "familyName", "line1", "city", "region", "postalCode", "phone"],
    labels: { line2: "Apartment, area, landmark", region: "State", postalCode: "PIN code" },
    regions: IN_STATES,
    postalCode: { pattern: /^[1-9]\d{2}\s?\d{3}$/, example: "110001", numeric: true },
  },
  DE: {
    rows: [NAMES, ...STREET, ["postalCode", "city"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "postalCode"],
    labels: { line1: "Street and house number", postalCode: "Postcode" },
    postalCode: { pattern: /^\d{5}$/, example: "10115", numeric: true },
  },
  FR: {
    rows: [NAMES, ...STREET, ["postalCode", "city"], ["phone"]],
    required: ["givenName", "familyName", "line1", "city", "postalCode"],
    labels: { postalCode: "Postcode" },
    postalCode: { pattern: /^\d{2}\s?\d{3}$/, example: "75001", numeric: true },
  },
  JP: {
    // Family name first, then the address from the largest unit down.
    rows: [["familyName", "givenName"], ["postalCode", "region"], ["city"], ...STREET, ["phone"]],
    required: ["familyName", "givenName", "postalCode", "region", "city", "line1"],
    labels: { region: "Prefecture", city: "City, ward, town", line1: "Street address", line2: "Building, room" },
    regions: JP_PREFECTURES,
    postalCode: { pattern: /^\d{3}-?\d{4}$/, example: "100-0001", numeric: true },
  },
}

/** Every ISO 3166-1 alpha-2 code. Pass `countries` to the form to offer only those you ship to. */
export const COUNTRY_CODES: readonly string[] = `AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
  BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM
  CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD
  GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS
  IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
  MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ
  OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK
  SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US
  UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW`.split(/\s+/)

/* ─── resolving a format ───────────────────────────────────────────────── */

export interface AddressFormatOptions {
  /** Your countries, or changes to ours — merged over the built-in entry. */
  formats?: Record<string, Partial<AddressFormatInput>>
  /** Show, hide or require the company, second address line and phone everywhere. */
  fields?: AddressFieldModes
}

/** Where a field goes back in when a mode turns it on. */
function insertRow(rows: AddressFieldName[][], field: AddressFieldName): AddressFieldName[][] {
  if (field === "phone") return [...rows, ["phone"]]
  const anchor = field === "line2" ? "line1" : rows[0]?.find((f) => f === "givenName" || f === "familyName")
  const at = rows.findIndex((row) => anchor !== undefined && row.includes(anchor))
  return at === -1 ? [[field], ...rows] : [...rows.slice(0, at + 1), [field], ...rows.slice(at + 1)]
}

/** The format for a country — built-in, generic, or yours — with field modes applied. */
export function getAddressFormat(country: string, options: AddressFormatOptions = {}): AddressFormat {
  const code = country.toUpperCase()
  const base = ADDRESS_FORMATS[code] ?? GENERIC_ADDRESS_FORMAT
  const own = options.formats?.[code]
  const input: AddressFormatInput = { ...base, ...own, labels: { ...base.labels, ...own?.labels } }

  let rows = input.rows.map((row) => [...row])
  let required = [...input.required]
  for (const [field, mode] of Object.entries(options.fields ?? {}) as [AddressFieldName, AddressFieldMode][]) {
    const shown = rows.some((row) => row.includes(field))
    if (mode === "hidden") {
      rows = rows.map((row) => row.filter((f) => f !== field)).filter((row) => row.length > 0)
      required = required.filter((f) => f !== field)
      continue
    }
    if (!shown) rows = insertRow(rows, field)
    required = mode === "required" ? [...new Set([...required, field])] : required.filter((f) => f !== field)
  }

  return {
    rows,
    required,
    labels: { ...DEFAULT_ADDRESS_LABELS, ...input.labels },
    regions: input.regions,
    postalCode: input.postalCode,
  }
}

/** An empty address in a country. */
export function emptyAddress(country = "US"): Address {
  return { country, givenName: "", familyName: "", line1: "", city: "" }
}

/* ─── validation ───────────────────────────────────────────────────────── */

/** "State" -> "state", but "ZIP code" and "PIN code" keep their capitals. */
const lower = (label: string) => (/^[A-Z][a-z]/.test(label) ? label[0].toLowerCase() + label.slice(1) : label)
const article = (word: string) => (/^[aeiou]/i.test(word) ? "an" : "a")
const named = (label: string) => `${article(lower(label))} ${lower(label)}`

function missing(field: AddressFieldName, format: AddressFormat) {
  const label = format.labels[field]
  if (field === "givenName" || field === "familyName") return `Enter your ${lower(label)}`
  if (field === "phone") return "Enter a phone number"
  if (field === "region" && format.regions) return `Choose ${named(label)}`
  return `Enter ${named(label)}`
}

/**
 * The same rules the form shows, as a pure function: required fields, the
 * postcode's shape, a region from the list, a plausible phone number. Returns
 * an error message per field, or `{}` when the address will do. Run it again
 * on your server — the browser's copy can be skipped.
 */
export function validateAddress(address: Address, format: AddressFormat = getAddressFormat(address.country)): AddressErrors {
  const errors: AddressErrors = {}
  if (!address.country?.trim()) errors.country = "Choose a country"

  const shown = new Set(format.rows.flat())
  for (const field of shown) {
    const value = (address[field] ?? "").trim()
    if (!value) {
      if (format.required.includes(field)) errors[field] = missing(field, format)
      continue
    }
    if (field === "postalCode" && format.postalCode && !format.postalCode.pattern.test(value)) {
      errors.postalCode = `Enter a valid ${lower(format.labels.postalCode)}, like ${format.postalCode.example}`
    }
    if (field === "region" && format.regions && !format.regions.some((r) => r.code === value)) {
      errors.region = `Choose ${named(format.labels.region)} from the list`
    }
    if (field === "phone" && !(/^\+?[\d\s().-]+$/.test(value) && value.replace(/\D/g, "").length >= 6)) {
      errors.phone = "Enter a valid phone number"
    }
  }
  return errors
}

/* ─── display ──────────────────────────────────────────────────────────── */

/**
 * The address as lines for a label or a "Ship to" summary, in the country's
 * order: "San Francisco, California 94103", "10115 Berlin". Region codes
 * become names; the country itself is left to you.
 */
export function formatAddressLines(address: Address, format: AddressFormat = getAddressFormat(address.country)): string[] {
  const text = (field: AddressFieldName) => {
    const value = (address[field] ?? "").trim()
    if (field !== "region" || !format.regions) return value
    return format.regions.find((r) => r.code === value)?.name ?? value
  }
  return format.rows
    .filter((row) => !row.includes("phone"))
    .map((row) => {
      const parts = row.map((field) => ({ field, value: text(field) })).filter((p) => p.value)
      return parts
        .map((p, i) => {
          if (i === 0) return p.value
          const prev = parts[i - 1]
          const tight = p.field === "postalCode" || prev.field === "postalCode" || prev.field === "givenName" || prev.field === "familyName"
          return (tight ? " " : ", ") + p.value
        })
        .join("")
    })
    .filter(Boolean)
}