Skip to main content

Map Search Nav

The top-of-page search on /map lets the user either explore accessible places by filters or jump straight to a specific place by name. Both flows share one entry point — the filter pill in the nav.

Entry Point — SearchNav

File: src/components/search/map-search/search-nav.tsx

Mounted in src/components/layout/nav.tsx only when pathname.startsWith('/map'). Wrapped in LocalExploreSearchProvider so the explore form has its own scratch state separate from the URL-bound SearchProvider.

SearchNav renders three things at different breakpoints:

BreakpointTriggerWhat opens
Mobile"Search" buttonFull-screen overlay with chooser as the first screen
DesktopFilter pill (categories | access | location + dark icon)Floating top panel; opens directly into chooser

There is one trigger on each breakpoint. The pill body and the dark green icon are wrapped in a single <button> so that clicking anywhere on the pill opens the chooser.

The Chooser-First Flow

When the panel opens, the user first sees a chooser with two cards:

  • Explore — switches the panel to <ExploreSearch /> (filter form for category, access, location).
  • Specific place — switches the panel to <NameSearch /> (Google Places autocomplete).

A back arrow lets the user return to the chooser without dismissing the panel. The state machine is local to SearchNav:

type SearchType = 'chooser' | 'explore' | 'name';
const [desktopOpen, setDesktopOpen] = useState(false);
const [desktopType, setDesktopType] = useState<SearchType>('chooser');
const [mobileOpen, setMobileOpen] = useState(false);
const [mobileType, setMobileType] = useState<SearchType>('chooser');

Both desktop and mobile share the same chooser → search transition, but with different layouts:

  • Desktop: the chooser cards sit side-by-side in a flex w-full row (no max-width cap so they fill the panel). The chosen search renders inside the same motion.div overlay.
  • Mobile: the chooser cards stack vertically. The chosen search fills the full-screen overlay below the header.

useClickOutside (from src/lib/hooks/use-click-outside.ts) closes the desktop panel when clicked outside. The hook defers its listener with requestAnimationFrame so the click that opened the panel doesn't immediately close it.

Specific-Place Search — NameSearch

File: src/components/search/name-search.tsx

A controlled input that drives Google Places autocomplete via useAutocompletePlaces, then resolves the selected place to a DB row via ensurePlaceExists (creating a places row if Google returned a place we haven't seen) and routes the user to /map/place/[dbPlaceId].

Auto-focus

The chooser passes autoFocus so the input gets focus as soon as the user picks Specific place:

<NameSearch onClose={closeDesktop} autoFocus />

Internally:

const [open, setOpen] = useState(autoFocus);

When open is true, the input mounts with React's native autoFocus attribute. The component is also used by the landing page's searchWrapper.tsx without autoFocus, so the prop defaults to false.

Keyboard Navigation

The input handles three keys:

KeyBehavior
Move highlight down, clamped to last result
Move highlight up, clamped to first result
Select the highlighted result; falls back to first result if missing

State:

const [highlightedIndex, setHighlightedIndex] = useState(0);

useEffect(() => {
setHighlightedIndex(0);
}, [getAutocomplete.predictionResults]);

The reset effect keeps the highlight at index 0 whenever a fresh set of results arrives (typing a new query).

Double-Highlight Fix

NameSearch uses cmdk's <Command> for the dropdown. cmdk has its own internal "selected" state driven by hover, which it surfaces via data-[selected=true]:bg-accent on <CommandItem> (defined in src/components/ui/command.tsx).

If we tracked our own arrow-key highlight separately, hover and arrow-keys could disagree — two items would visually highlight at once. To prevent this, <Command> is controlled:

<Command
value={results[highlightedIndex]?.place_id ?? ''}
onValueChange={(v) => {
const i = results.findIndex((r) => r.place_id === v);
if (i >= 0) setHighlightedIndex(i);
}}
shouldFilter={false}
>

cmdk's selection now mirrors our highlightedIndex exactly. Hover updates value via onValueChange (so the arrow-key state follows the mouse); arrow keys update highlightedIndex (so cmdk's selection follows the keyboard). Only one item is ever highlighted.

shouldFilter={false} is set because filtering happens server-side (Google Places autocomplete) — cmdk should display whatever rows we hand it, in order.

Adjacent Operations

  • Filter persistenceExploreSearch writes filter selections through SearchProvider (src/components/providers/search-provider.tsx), which syncs to URL search params. Reloading /map?categories=…&acsAttrs=…&location=… restores the same filter state.
  • Place creation on selectionNameSearch.handlePlaceSelect() calls ensurePlaceExists(placeId) (src/components/search/place-operations.tsx), which upserts a places row from Google Place data so the app has a stable internal id to navigate to.
  • Map recentre — when a place is selected, map.setCenter(...) recentres the Google Map, and mapPlacesCtx.setReloadPlaces(true) triggers a refetch of the places-in-view query.
  • Loading overlayuseLoadingOverlay() shows a full-screen "Going to {name}…" overlay while ensurePlaceExists is in flight, hiding it on success or error.