Async location search input backed by the Mapbase /v1/locations/autocomplete endpoint. Built on shadcn Command + Popover.
bunx shadcn@latest add https://mapbase.dev/r/autocomplete-location.jsonThe shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).
"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"
}
}