Typing in the map search pill now opens a suggestions dropdown built from the items that already match the active filters: - Spots: title matches; selecting one flies to it and opens its sheet. - Places: distinct city/region/country tokens split out of each item's location; selecting one sets the query to that token (e.g. spain -> Spain). buildSearchSuggestions lives in lib (pure, testable) and MapSearchSuggestions.tsx is presentational; MapPage owns the list and keyboard nav (ArrowUp/Down, Enter, Escape). Adds mapPage.searchSuggestPlaces (en/es only; other locales fall back to English).
95 lines
3.4 KiB
TypeScript
95 lines
3.4 KiB
TypeScript
import { MapPin, Store } from 'lucide-react';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import type { SearchSuggestion } from '@/lib/mapSearchSuggestions';
|
|
|
|
interface MapSearchSuggestionsProps {
|
|
suggestions: SearchSuggestion[];
|
|
activeIndex: number;
|
|
spotsLabel: string;
|
|
placesLabel: string;
|
|
emptyLabel: string;
|
|
onHover: (index: number) => void;
|
|
onSelect: (suggestion: SearchSuggestion) => void;
|
|
}
|
|
|
|
/**
|
|
* Dropdown panel for the map search bar. Purely presentational: the parent owns
|
|
* the suggestion list (see `buildSearchSuggestions` in
|
|
* `@/lib/mapSearchSuggestions`), the active index and selection.
|
|
*/
|
|
export function MapSearchSuggestions({
|
|
suggestions,
|
|
activeIndex,
|
|
spotsLabel,
|
|
placesLabel,
|
|
emptyLabel,
|
|
onHover,
|
|
onSelect,
|
|
}: MapSearchSuggestionsProps) {
|
|
return (
|
|
<div
|
|
role="listbox"
|
|
// Keep the input focused when a row is pressed, so `blur` cannot close the
|
|
// panel before the click lands.
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className="absolute left-0 right-0 top-full z-[1001] mt-2 max-h-80 overflow-y-auto rounded-2xl border border-white/15 bg-black/95 p-1.5 shadow-2xl backdrop-blur-md"
|
|
>
|
|
{suggestions.length === 0 ? (
|
|
<div className="px-3 py-2.5 text-sm text-gray-400">{emptyLabel}</div>
|
|
) : (
|
|
suggestions.map((s, index) => {
|
|
const header =
|
|
index === 0 || suggestions[index - 1].kind !== s.kind
|
|
? s.kind === 'spot'
|
|
? spotsLabel
|
|
: placesLabel
|
|
: null;
|
|
const active = index === activeIndex;
|
|
return (
|
|
<div key={`${s.kind}:${s.label}:${index}`}>
|
|
{header && (
|
|
<div className="px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
|
{header}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
ref={(el) => {
|
|
if (active && el) el.scrollIntoView({ block: 'nearest' });
|
|
}}
|
|
onMouseEnter={() => onHover(index)}
|
|
onClick={() => onSelect(s)}
|
|
className={cn(
|
|
'flex min-h-0 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors',
|
|
active ? 'bg-white/10 text-white' : 'text-gray-300 hover:bg-white/5',
|
|
)}
|
|
>
|
|
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-white/5">
|
|
{s.isShop ? (
|
|
<Store className="h-3.5 w-3.5" />
|
|
) : (
|
|
<MapPin className="h-3.5 w-3.5" />
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">{s.label}</span>
|
|
{s.kind === 'spot' && s.sublabel && (
|
|
<span className="block truncate text-xs text-gray-500">{s.sublabel}</span>
|
|
)}
|
|
</span>
|
|
{s.kind === 'place' && s.sublabel && (
|
|
<span className="flex-shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] text-gray-400">
|
|
{s.sublabel}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
}
|