All components

Autocomplete Location

Async location search input backed by the Mapbase /v1/locations/autocomplete endpoint. Built on shadcn Command + Popover.

Install

$bunx shadcn@latest add https://mapbase.dev/r/autocomplete-location.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

mapbase

Registry dependencies

commandpopoverinputbutton

Source

components/mapbase/autocomplete-location.tsx
"use client"

import * as React from "react"
import {
  Mapbase,
  type LocationSuggestion,
  type CountryCode,
  type LocationKind,
  MapbaseError,
} from "mapbase"

import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandLoading,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"

export type AutocompleteLocationProps = {
  /**
   * Public Mapbase API key (`mb_live_*`). Mint one at
   * https://mapbase.dev/api-keys.
   */
  apiKey: string
  /** Optional: pin the engine URL. Defaults to https://api.mapbase.dev. */
  baseUrl?: string
  /** Restrict suggestions to one country (ISO-3166 alpha-2). */
  country?: CountryCode
  /** Restrict suggestions to a subset of admin levels. */
  kinds?: LocationKind[]
  /** Bias results toward a coordinate. */
  near?: { lng: number; lat: number }
  /** Max suggestions per request. Engine clamps at 10. */
  limit?: number
  /** Debounce window for input → request. Defaults to 200 ms. */
  debounceMs?: number
  /** Min characters before issuing a request. Defaults to 2. */
  minChars?: number
  /** Initial selection (controlled). */
  value?: LocationSuggestion | null
  /** Fired when the user picks (or clears) a suggestion. */
  onChange?: (suggestion: LocationSuggestion | null) => void
  /** Placeholder for the empty input. */
  placeholder?: string
  /** Trigger label when nothing is selected. */
  triggerLabel?: string
  /** Disable the entire control. */
  disabled?: boolean
  className?: string
}

/**
 * Async location search input backed by the Mapbase
 * `/v1/locations/autocomplete` endpoint.
 *
 * Built on shadcn Command + Popover. Owns its open state and query
 * state; selection is controlled via `value` / `onChange`. Aborts
 * stale requests on every keystroke and surfaces `MapbaseError`s
 * inline so callers don't have to wire toast plumbing for the common
 * case (rate-limit, invalid key, network).
 */
export function AutocompleteLocation({
  apiKey,
  baseUrl,
  country,
  kinds,
  near,
  limit = 8,
  debounceMs = 200,
  minChars = 2,
  value,
  onChange,
  placeholder = "Search a city, district, neighborhood…",
  triggerLabel = "Pick a location",
  disabled,
  className,
}: AutocompleteLocationProps) {
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [suggestions, setSuggestions] = React.useState<LocationSuggestion[]>([])
  const [loading, setLoading] = React.useState(false)
  const [error, setError] = React.useState<MapbaseError | null>(null)

  // Re-create the client only when `apiKey` / `baseUrl` change. Caller
  // typically passes a stable key, so this is effectively once.
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  // Stable serialisation of the bias params so `useEffect` doesn't
  // re-fire on every render of `near={{lng, lat}}` etc.
  const kindsKey = React.useMemo(
    () => (kinds && kinds.length > 0 ? kinds.join(",") : ""),
    [kinds],
  )
  const nearKey = near ? `${near.lng},${near.lat}` : ""

  React.useEffect(() => {
    setError(null)
    if (query.trim().length < minChars) {
      setSuggestions([])
      setLoading(false)
      return
    }

    const controller = new AbortController()
    const timer = window.setTimeout(async () => {
      setLoading(true)
      const result = await client.locations.autocomplete(
        {
          q: query,
          ...(country ? { country } : {}),
          ...(kinds && kinds.length > 0 ? { kinds } : {}),
          ...(near ? { near } : {}),
          limit,
        },
        { signal: controller.signal },
      )
      // The fetch hasn't been aborted (a newer keystroke would have
      // killed it). Safe to commit.
      if (controller.signal.aborted) return

      if (result.error) {
        // Network aborts surface as `network_error`; the AbortController
        // path is the user's own; ignore.
        if (
          result.error.code === "network_error" &&
          controller.signal.aborted
        ) {
          return
        }
        setError(result.error)
        setSuggestions([])
      } else {
        setSuggestions(result.data?.data ?? [])
      }
      setLoading(false)
    }, debounceMs)

    return () => {
      controller.abort()
      window.clearTimeout(timer)
    }
  }, [client, query, minChars, debounceMs, country, kindsKey, nearKey, limit])

  const handleSelect = (suggestion: LocationSuggestion | null) => {
    onChange?.(suggestion)
    setOpen(false)
    setQuery("")
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="outline"
          role="combobox"
          aria-expanded={open}
          disabled={disabled}
          className={cn(
            "w-full justify-between text-left font-normal",
            !value && "text-muted-foreground",
            className,
          )}
        >
          {value ? (
            <span className="truncate">
              <span className="font-medium">{value.name}</span>
              {value.breadcrumb && value.breadcrumb !== value.name ? (
                <span className="text-muted-foreground">
                  {" "}— {value.breadcrumb}
                </span>
              ) : null}
            </span>
          ) : (
            triggerLabel
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent
        className="w-[--radix-popover-trigger-width] p-0"
        align="start"
      >
        <Command shouldFilter={false}>
          <CommandInput
            placeholder={placeholder}
            value={query}
            onValueChange={setQuery}
          />
          <CommandList>
            {loading ? (
              <CommandLoading>Searching…</CommandLoading>
            ) : null}

            {!loading && error ? (
              <div className="flex flex-col gap-1 p-3 text-sm">
                <span className="font-medium text-destructive">
                  {humanizeErrorCode(error.code)}
                </span>
                <span className="text-muted-foreground">{error.message}</span>
              </div>
            ) : null}

            {!loading && !error && query.trim().length < minChars ? (
              <CommandEmpty>
                Type at least {minChars} characters.
              </CommandEmpty>
            ) : null}

            {!loading && !error && query.trim().length >= minChars ? (
              <CommandEmpty>No results.</CommandEmpty>
            ) : null}

            {!error && suggestions.length > 0 ? (
              <CommandGroup>
                {suggestions.map((s) => (
                  <CommandItem
                    key={s.id}
                    value={s.id}
                    onSelect={() => handleSelect(s)}
                    className="flex flex-col items-start gap-0.5"
                  >
                    <span className="font-medium">{s.name}</span>
                    {s.breadcrumb && s.breadcrumb !== s.name ? (
                      <span className="text-xs text-muted-foreground">
                        {s.breadcrumb}
                      </span>
                    ) : null}
                  </CommandItem>
                ))}
              </CommandGroup>
            ) : null}

            {value ? (
              <CommandGroup>
                <CommandItem
                  onSelect={() => handleSelect(null)}
                  className="text-muted-foreground"
                >
                  Clear selection
                </CommandItem>
              </CommandGroup>
            ) : null}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

function humanizeErrorCode(code: MapbaseError["code"]): string {
  switch (code) {
    case "unauthorized":
      return "Invalid API key"
    case "rate_limited":
      return "Rate limited — try again shortly"
    case "validation_failed":
      return "Invalid query"
    case "not_implemented":
      return "Endpoint not yet available"
    case "network_error":
      return "Network error"
    default:
      return "Something went wrong"
  }
}