All blocks

Address Picker

Search → select → preview-on-map → confirm flow. Composes autocomplete-location with the mapcn <Map> primitive and a Mapbase boundary fetch.

Install

$bunx shadcn@latest add https://mapbase.dev/r/address-picker.json

The shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).

npm dependencies

mapbasemaplibre-gl

Registry dependencies

cardbuttonhttps://www.mapcn.dev/r/map.jsonhttps://mapbase.dev/r/autocomplete-location.json

Source

components/mapbase/address-picker.tsx
"use client"

import * as React from "react"
import { Mapbase, type LocationSuggestion, type Boundary } from "mapbase"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Map,
  MapControls,
  MapMarker,
  MarkerContent,
  type MapViewport,
} from "@/components/ui/map"
import { AutocompleteLocation } from "@/components/mapbase/autocomplete-location"
import { cn } from "@/lib/utils"

export type AddressPickerSelection = {
  location: LocationSuggestion
  /**
   * Boundary GeoJSON if the engine returned one. `null` while loading
   * and after a `not_implemented` response (boundaries are part of the
   * roadmap; the picker still works without them).
   */
  boundary: Boundary | null
}

export type AddressPickerProps = {
  apiKey: string
  baseUrl?: string
  /**
   * Initial map viewport. Defaults to roughly the Iberian peninsula —
   * the only region currently covered by the engine.
   */
  initialViewport?: Partial<MapViewport>
  /** Fired when the user clicks "Use this location". */
  onConfirm?: (selection: AddressPickerSelection) => void
  /** Fired on every selection change (auto-pan, etc). */
  onSelect?: (selection: AddressPickerSelection | null) => void
  className?: string
}

/**
 * Search-→-select-→-preview-→-confirm address flow built from
 * three primitives: shadcn Card, mapcn Map, and the Mapbase
 * AutocompleteLocation component. Drops into a checkout / onboarding
 * flow as a single block.
 */
export function AddressPicker({
  apiKey,
  baseUrl,
  initialViewport,
  onConfirm,
  onSelect,
  className,
}: AddressPickerProps) {
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  const [location, setLocation] = React.useState<LocationSuggestion | null>(null)
  const [boundary, setBoundary] = React.useState<Boundary | null>(null)
  const [boundaryLoading, setBoundaryLoading] = React.useState(false)
  const [viewport, setViewport] = React.useState<MapViewport>({
    center: initialViewport?.center ?? [-4.5, 40.0],
    zoom: initialViewport?.zoom ?? 4.5,
    bearing: initialViewport?.bearing ?? 0,
    pitch: initialViewport?.pitch ?? 0,
  })

  // Fetch the matching boundary (if any) and re-frame the map on every
  // new selection. The boundaries route may legitimately return
  // `not_implemented`; treat that the same as "no boundary available".
  React.useEffect(() => {
    if (!location) {
      setBoundary(null)
      setBoundaryLoading(false)
      return
    }

    const controller = new AbortController()
    setBoundaryLoading(true)

    void (async () => {
      const result = await client.boundaries.byId(location.id, {
        signal: controller.signal,
      })
      if (controller.signal.aborted) return

      const nextBoundary = result.error ? null : result.data
      setBoundary(nextBoundary)
      setBoundaryLoading(false)

      const [lng, lat] = location.centroid
      setViewport((prev) => ({
        ...prev,
        center: [lng, lat],
        zoom: zoomForKind(location.kind),
      }))

      onSelect?.({ location, boundary: nextBoundary })
    })()

    return () => controller.abort()
  }, [client, location, onSelect])

  const handleConfirm = () => {
    if (!location) return
    onConfirm?.({ location, boundary })
  }

  return (
    <Card className={cn("overflow-hidden", className)}>
      <CardHeader>
        <CardTitle>Pick an address</CardTitle>
        <CardDescription>
          Search a city, district, or neighborhood. We&apos;ll show it on the
          map so you can confirm.
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <AutocompleteLocation
          apiKey={apiKey}
          baseUrl={baseUrl}
          value={location}
          onChange={setLocation}
        />
        <div className="relative h-[320px] w-full overflow-hidden rounded-md border">
          <Map
            viewport={viewport}
            onViewportChange={setViewport}
            className="h-full w-full"
          >
            <MapControls position="bottom-right" showZoom />
            {location ? (
              <MapMarker
                longitude={location.centroid[0]}
                latitude={location.centroid[1]}
              >
                <MarkerContent>
                  <div className="size-3 rounded-full bg-primary ring-2 ring-background shadow-md" />
                </MarkerContent>
              </MapMarker>
            ) : null}
          </Map>
          {boundaryLoading ? (
            <div className="absolute right-2 top-2 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur">
              Loading boundary…
            </div>
          ) : null}
        </div>
        {location ? (
          <div className="rounded-md border bg-muted/40 p-3 text-sm">
            <div className="font-medium">{location.name}</div>
            <div className="text-muted-foreground">{location.breadcrumb}</div>
            <div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
              <span>id</span>
              <span className="font-mono">{location.id}</span>
              <span>kind</span>
              <span>{location.kind}</span>
              <span>centroid</span>
              <span className="font-mono">
                {location.centroid[0].toFixed(4)},{" "}
                {location.centroid[1].toFixed(4)}
              </span>
              <span>boundary</span>
              <span>{boundary ? "geometry available" : "—"}</span>
            </div>
          </div>
        ) : null}
      </CardContent>
      <CardFooter className="justify-end">
        <Button onClick={handleConfirm} disabled={!location}>
          Use this location
        </Button>
      </CardFooter>
    </Card>
  )
}

function zoomForKind(kind: LocationSuggestion["kind"]): number {
  switch (kind) {
    case "country":
      return 5
    case "province":
      return 8
    case "town":
      return 11
    case "district":
      return 13
    case "neighborhood":
      return 14
    default:
      return 12
  }
}