fix(planner): trip places in every manual location field, never at 0,0

The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
This commit is contained in:
Maurice
2026-09-23 13:41:31 +02:00
parent cc5957dc6a
commit 3a67a8e279
8 changed files with 234 additions and 24 deletions
@@ -16,7 +16,12 @@ interface Props {
onChange: (loc: LocationPoint | null) => void
placeholder?: string
style?: React.CSSProperties
places?: { name: string; lat: number; lng: number }[]
/**
* The trip's own places, offered while the field is empty or holds fewer than three
* characters, the way the transit search offers them (#2468). Typing narrows them by
* name; from the third character on the map search answers instead.
*/
places?: LocationPoint[]
}
export default function LocationSelect({ value, onChange, placeholder, style, places }: Props) {
@@ -47,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 3 || (value && trimmed === value.name)) {
setResults(trimmed.length < 3 && places ? places : [])
setResults([])
return
}
debounceRef.current = setTimeout(async () => {
@@ -82,11 +87,20 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
setResults([])
}
// The trip's places stand in for the search below three characters. Derived rather
// than written into `results`, so a search answer that lands after the field was
// shortened cannot replace them, and a place already picked does not reopen them.
const typed = query.trim()
const showPicks = !value && typed.length < 3 && !!places?.length
const rows = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
if (!open || rows.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, rows.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Enter' && rows[highlight]) { e.preventDefault(); pick(rows[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
@@ -98,7 +112,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
type="text"
value={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
className="bg-transparent text-content"
@@ -111,14 +125,14 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
)}
</div>
{open && (loading || results.length > 0) && (
{open && ((loading && !showPicks) || rows.length > 0) && (
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && (
{loading && !showPicks && rows.length === 0 && (
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
)}
{results.map((r, i) => (
{rows.map((r, i) => (
<button
key={`${r.osm_id || r.google_place_id || i}`}
key={showPicks ? `pick:${r.name}:${r.lat}:${r.lng}` : `${r.osm_id || r.google_place_id || i}`}
type="button"
onClick={() => pick(r)}
onMouseEnter={() => setHighlight(i)}