Async location search input backed by the Mapbase /v1/locations/autocomplete endpoint. Built on shadcn Command + Popover.
Set NEXT_PUBLIC_MAPBASE_DEMO_API_KEY in apps/web/.env to load this preview. Use a demo key with allowed_domains scoped to this site.
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"
import { MapPinIcon } from "lucide-react"
export type AutocompleteLocationProps = {
/**
* Public Mapbase API key (`mb_live_*`). Mint one at
* https://dashboard.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
/** Layout/sizing classes for the control root (`max-w-*`, margins, etc.). */
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)
const rootRef = React.useRef<HTMLDivElement>(null)
const [popoverWidth, setPopoverWidth] = React.useState<number>()
// Measure the layout root (includes consumer `className` e.g. `max-w-*`),
// not the viewport-wide parent — so the popover matches the trigger strip.
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const sync = () => setPopoverWidth(root.getBoundingClientRect().width)
sync()
const ro = new ResizeObserver(sync)
ro.observe(root)
return () => ro.disconnect()
}, [className])
// 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 (
<div ref={rootRef} className={cn("w-full min-w-0", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"relative w-full justify-between text-left font-normal",
!value && "pl-8 text-muted-foreground"
)}
>
<MapPinIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2" />
{value ? (
<span className="truncate">
<span className="font-medium">{value.name}</span>
</span>
) : (
triggerLabel
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="box-border max-w-none p-0"
style={popoverWidth != null ? { width: popoverWidth } : undefined}
>
<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>
<div className="flex w-full items-end justify-between gap-1">
<div className="text-xs text-muted-foreground">
{s.kind}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{value ? (
<CommandGroup>
<CommandItem
onSelect={() => handleSelect(null)}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
function humanizeErrorCode(code: MapbaseError["code"]): string {
switch (code) {
case "unauthorized":
return "Invalid API key"
case "rate_limited":
return "Rate limited — try again shortly"
case "invalid_request":
return "Invalid query"
case "not_implemented":
return "Endpoint not yet available"
case "network_error":
return "Network error"
default:
return "Something went wrong"
}
}