All blocks
primitives
Autocomplete
Self-contained search against Mapbase registry layers (locations, postcodes, lau, zones). On select, returns the full engine hit in ref. Also supports Google and Geoapify sources.
Open playground →Live preview
Install
$
bunx shadcn@latest add https://mapbase.dev/r/autocomplete.jsonCopies source into your app and installs npm + registry dependencies.
npm dependencies
mapbasemotionlucide-react@tanstack/react-query
Registry dependencies
commandpopoverbutton
Source
components/mapbase/autocomplete.tsx
"use client"
import * as React from "react"
import { motion } from "motion/react"
import {
DEFAULT_AUTOCOMPLETE_INCLUDE,
type AutocompleteHit,
type AutocompleteOutcome,
type CountryCode,
type GeometryInclude,
type Layer,
type LocationKind,
type LocationSuggestion,
type PlaceSourceName,
type PlaceSuggestion,
MapbaseError,
} from "mapbase"
import { useAutocomplete } from "mapbase/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { Loader2, Search, X } from "lucide-react"
export type AutocompleteProps = {
apiKey?: string
baseUrl?: string
source?: PlaceSourceName
country?: CountryCode
layers?: Layer[]
limit?: number
debounceMs?: number
minChars?: number
lat?: number
lng?: number
include?: GeometryInclude[]
language?: string
types?: string
radius?: number
strictbounds?: boolean
region?: string
geoapifyType?: import("mapbase").GeoapifyAutocompleteParams["type"]
geoapifyLang?: string
geoapifyFilter?: string
geoapifyBias?: string
googleApiKey?: string
geoapifyApiKey?: string
value?: PlaceSuggestion | null
onChange?: (suggestion: PlaceSuggestion | null) => void
onSelect?: (suggestion: PlaceSuggestion) => void
onHoverChange?: (hit: AutocompleteHit | null) => void
onResponse?: (payload: {
outcome: AutocompleteOutcome | null
latencyMs: number | null
}) => void
placeholder?: string
triggerLabel?: string
disabled?: boolean
className?: string
detail?: "compact" | "full"
/** Inline search bar (map overlay) vs popover trigger (forms). */
variant?: "popover" | "inline"
/** Endpoint path badge shown in inline mode (e.g. /v1/autocomplete). */
requestPath?: string
/** Dropdown placement for inline mode (use top for bottom-docked search bars). */
resultsSide?: "top" | "bottom"
activeId?: string | null
blurb?: string
}
const LOCATION_KIND_LABELS = {
autonomous_city: "Autonomous city",
autonomous_region: "Autonomous region",
comarca: "Comarca",
continent: "Continent",
country: "Country",
district: "District",
external_region: "External region",
island: "Island",
municipality: "Municipality",
neighborhood: "Neighborhood",
parish: "Parish",
province: "Province",
town: "Town",
} as const satisfies Record<LocationKind, string>
const KIND_COLORS: Record<string, string> = {
autonomous_city: "#6366f1",
autonomous_region: "#818cf8",
comarca: "#a78bfa",
continent: "#1d4ed8",
country: "#2563eb",
district: "#0891b2",
external_region: "#7c3aed",
island: "#0d9488",
municipality: "#8b5cf6",
neighborhood: "#ec4899",
parish: "#d946ef",
province: "#4f46e5",
town: "#a855f7",
lau: "#65a30d",
postcode: "#0284c7",
zone: "#d97706",
region: "#ca8a04",
beach: "#14b8a6",
locality: "#8b5cf6",
administrative_area_level_1: "#4f46e5",
administrative_area_level_2: "#6366f1",
street_address: "#64748b",
route: "#64748b",
}
const KIND_COLOR_FALLBACK = "#94a3b8"
/** Keep address search from pairing with nearby password fields in the browser. */
const SEARCH_INPUT_PROPS = {
autoComplete: "off",
autoCorrect: "off",
autoCapitalize: "off",
spellCheck: false,
"data-1p-ignore": true,
"data-lpignore": "true",
"data-form-type": "other",
} as const
function kindBadgeStyle(kind: string | undefined): {
borderColor: string
backgroundColor: string
color: string
} {
const fill = (kind ? KIND_COLORS[kind] : undefined) ?? KIND_COLOR_FALLBACK
return {
borderColor: `${fill}4D`,
backgroundColor: `${fill}1A`,
color: fill,
}
}
function humanizeKind(kind: string | undefined): string {
if (!kind) return "—"
const labels = LOCATION_KIND_LABELS as Record<string, string>
const known = labels[kind]
if (known) return known
return kind
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
}
function useReducedMotion(): boolean {
const [reduced, setReduced] = React.useState(false)
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
const update = () => setReduced(mq.matches)
update()
mq.addEventListener("change", update)
return () => mq.removeEventListener("change", update)
}, [])
return reduced
}
function suggestionRow(value: PlaceSuggestion): LocationSuggestion | undefined {
return value.ref as LocationSuggestion | undefined
}
function hitToSuggestion(hit: AutocompleteHit): PlaceSuggestion {
return {
id: hit.id,
label: hit.label,
ref: hit.ref ?? hit,
}
}
// function hitContext(hit: AutocompleteHit): string {
// const row = hit.ref as LocationSuggestion | undefined
// return row?.label ?? row?.name ?? hit.country ?? ""
// }
function HitRow({
hit,
// detail,
}: {
hit: AutocompleteHit
detail: "compact" | "full"
}) {
// const context = hitContext(hit)
return (
<>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[14px] text-foreground">
{hit.label}
</span>
{/* {detail === "full" && context ? (
<span className="truncate text-[12px] text-muted-foreground">
{context}
</span>
) : null} */}
</span>
{hit.kind ? (
<span
className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={kindBadgeStyle(hit.kind)}
>
{humanizeKind(hit.kind)}
</span>
) : null}
</>
)
}
function KindBadge({ kind }: { kind?: string }) {
if (!kind) return null
return (
<span
className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={kindBadgeStyle(kind)}
>
{humanizeKind(kind)}
</span>
)
}
export function Autocomplete(props: AutocompleteProps) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 0, refetchOnWindowFocus: false },
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<AutocompleteInner {...props} />
</QueryClientProvider>
)
}
function AutocompleteInner({
apiKey,
baseUrl,
source,
country,
layers,
limit,
debounceMs,
minChars,
lat,
lng,
include = DEFAULT_AUTOCOMPLETE_INCLUDE,
language,
types,
radius,
strictbounds,
region,
geoapifyType,
geoapifyLang,
geoapifyFilter,
geoapifyBias,
googleApiKey,
geoapifyApiKey,
value,
onChange,
onSelect,
onHoverChange,
onResponse,
placeholder = "Search a city, district, neighborhood…",
triggerLabel = "Pick a location",
disabled,
className,
detail = "full",
variant = "inline",
requestPath = "/v1/autocomplete",
resultsSide = "bottom",
activeId = null,
blurb,
}: AutocompleteProps) {
const [open, setOpen] = React.useState(false)
const [focused, setFocused] = React.useState(false)
const reducedMotion = useReducedMotion()
const { ref: anchorRef, width: anchorWidth } =
useElementWidth<HTMLDivElement>()
const minQueryChars = minChars ?? 2
const resultsPopoverStyle =
anchorWidth > 0 ? ({ width: anchorWidth } as const) : undefined
const {
query,
setQuery,
hits,
loading,
error,
response,
request,
latencyMs,
} = useAutocomplete({
apiKey,
baseUrl,
source,
debounceMs,
minChars,
country,
layers,
limit,
lat,
lng,
include,
language,
types,
radius,
strictbounds,
region,
geoapifyType,
geoapifyLang,
geoapifyFilter,
geoapifyBias,
googleApiKey,
geoapifyApiKey,
})
const onResponseRef = React.useRef(onResponse)
onResponseRef.current = onResponse
const lastResponseKey = React.useRef("")
React.useEffect(() => {
const notify = onResponseRef.current
if (!notify) return
const payload =
!response && !request
? {
outcome: null as AutocompleteOutcome | null,
latencyMs: null as number | null,
}
: {
outcome: response
? {
hits,
response,
request: request ?? {
method: "GET" as const,
path: "",
params: {},
},
}
: null,
latencyMs,
}
const key = JSON.stringify({
latencyMs: payload.latencyMs,
hitIds: payload.outcome?.hits.map((h: AutocompleteHit) => h.id) ?? null,
path: payload.outcome?.request.path ?? null,
})
if (key === lastResponseKey.current) return
lastResponseKey.current = key
notify(payload)
}, [hits, response, request, latencyMs])
const handleSelect = (suggestion: PlaceSuggestion | null) => {
onHoverChange?.(null)
onChange?.(suggestion)
if (suggestion) onSelect?.(suggestion)
if (variant === "popover") setOpen(false)
setQuery("")
}
const showResults =
variant === "inline"
? focused &&
(query.length >= minQueryChars || hits.length > 0 || Boolean(error))
: open
const selectedRow = value ? suggestionRow(value) : undefined
const selectedBadge =
value && detail === "full" ? <KindBadge kind={selectedRow?.kind} /> : null
const resultsList = (
<>
{loading ? (
<div className="py-6 text-center text-sm text-muted-foreground">
Searching…
</div>
) : null}
{!loading && error ? (
<div className="flex flex-col gap-1 p-3 text-sm">
<span className="font-medium text-red-400">
{humanizeErrorCode(error.code)}
</span>
<span className="text-white/50">{error.message}</span>
</div>
) : null}
{!loading && !error && query.trim().length < minQueryChars ? (
<CommandEmpty>Type at least {minQueryChars} characters.</CommandEmpty>
) : null}
{!loading &&
!error &&
query.trim().length >= minQueryChars &&
hits.length === 0 ? (
<CommandEmpty>No results.</CommandEmpty>
) : null}
{!error && hits.length > 0 ? (
<CommandGroup>
{hits.map((hit: AutocompleteHit, i: number) => (
<motion.div
key={hit.id}
className="w-full"
initial={reducedMotion ? false : { opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: reducedMotion ? 0 : i * 0.03 }}
>
<CommandItem
value={hit.id}
onSelect={() => handleSelect(hitToSuggestion(hit))}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => onHoverChange?.(hit)}
onMouseLeave={() => onHoverChange?.(null)}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5",
activeId === hit.id && "bg-accent"
)}
>
<HitRow hit={hit} detail={detail} />
</CommandItem>
</motion.div>
))}
</CommandGroup>
) : null}
</>
)
if (variant === "popover") {
return (
<div ref={anchorRef} className={cn("w-full min-w-0", className)}>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="relative w-full justify-between gap-2 text-left font-normal"
>
{value ? (
<>
<span className="truncate font-medium">{value.label}</span>
{selectedBadge}
</>
) : (
triggerLabel
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
style={resultsPopoverStyle}
className="overflow-hidden p-0"
>
<Command
shouldFilter={false}
className="w-full border-0 bg-transparent"
>
<CommandInput
placeholder={placeholder}
value={query}
onValueChange={setQuery}
disabled={disabled}
{...SEARCH_INPUT_PROPS}
/>
<CommandList className="max-h-72">{resultsList}</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
return (
<div className={cn("relative w-full", className)}>
<div
ref={anchorRef}
className={cn(
"flex w-full items-center gap-3 rounded-xl border bg-card px-4 py-3 shadow-sm transition-[border-color,box-shadow]",
focused
? "border-primary/50 ring-2 ring-primary/20"
: "border-border",
)}
>
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 120)}
placeholder={value ? value.label : placeholder}
disabled={disabled}
className="w-full min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
aria-label="Search places"
aria-expanded={showResults}
aria-autocomplete="list"
role="combobox"
{...SEARCH_INPUT_PROPS}
/>
{selectedBadge}
{loading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : null}
{value ? (
<button
type="button"
onClick={() => handleSelect(null)}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Clear selection"
>
<X className="size-3.5" />
</button>
) : null}
{requestPath ? (
<kbd className="hidden shrink-0 rounded-md border border-border/80 bg-muted/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline">
{requestPath}
</kbd>
) : null}
</div>
{showResults ? (
<div
className={cn(
"absolute z-50 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
resultsSide === "top" ? "bottom-full mb-2" : "top-full mt-2",
)}
style={resultsPopoverStyle}
>
<Command shouldFilter={false} className="w-full bg-transparent">
<CommandList className="max-h-[min(38dvh,280px)] w-full sm:max-h-72">
{resultsList}
</CommandList>
</Command>
</div>
) : null}
{blurb ? (
<p className="mt-2 text-center text-xs text-muted-foreground">
{blurb}
</p>
) : null}
</div>
)
}
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null)
const [width, setWidth] = React.useState(0)
React.useLayoutEffect(() => {
const el = ref.current
if (!el) return
const update = () => setWidth(el.getBoundingClientRect().width)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [])
return { ref, width }
}
function humanizeErrorCode(code: MapbaseError["code"]): string {
switch (code) {
case "unauthorized":
return "Invalid API key"
case "rate_limited":
return "Rate limited"
case "invalid_request":
return "Invalid query"
case "not_implemented":
return "Not available"
case "network_error":
return "Network error"
default:
return "Something went wrong"
}
}