mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
3a67a8e279
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.
160 lines
6.7 KiB
TypeScript
160 lines
6.7 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { MapPin, X } from 'lucide-react'
|
|
import { mapsApi } from '../../api/client'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useLocationBias } from '../../hooks/useLocationBias'
|
|
|
|
export interface LocationPoint {
|
|
name: string
|
|
lat: number
|
|
lng: number
|
|
address?: string | null
|
|
}
|
|
|
|
interface Props {
|
|
value: LocationPoint | null
|
|
onChange: (loc: LocationPoint | null) => void
|
|
placeholder?: string
|
|
style?: React.CSSProperties
|
|
/**
|
|
* 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) {
|
|
const { t, locale } = useTranslation()
|
|
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
|
const { point: locationBias } = useLocationBias()
|
|
const [query, setQuery] = useState(value?.name || '')
|
|
const [open, setOpen] = useState(false)
|
|
const [results, setResults] = useState<any[]>([])
|
|
const [highlight, setHighlight] = useState(-1)
|
|
const [loading, setLoading] = useState(false)
|
|
const wrapRef = useRef<HTMLDivElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
useEffect(() => {
|
|
setQuery(value?.name || '')
|
|
}, [value])
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
if (open) document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
const trimmed = query.trim()
|
|
if (trimmed.length < 3 || (value && trimmed === value.name)) {
|
|
setResults([])
|
|
return
|
|
}
|
|
debounceRef.current = setTimeout(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await mapsApi.search(trimmed, locale, locationBias)
|
|
setResults(data.places || [])
|
|
setHighlight(-1)
|
|
} catch {
|
|
setResults([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, 320)
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
|
}, [query, value, locale])
|
|
|
|
const pick = (r: any) => {
|
|
const lat = Number(r.lat)
|
|
const lng = Number(r.lng)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
|
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
|
|
onChange(loc)
|
|
setQuery(loc.name)
|
|
setOpen(false)
|
|
setResults([])
|
|
}
|
|
|
|
const clear = () => {
|
|
onChange(null)
|
|
setQuery('')
|
|
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 || 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' && rows[highlight]) { e.preventDefault(); pick(rows[highlight]) }
|
|
else if (e.key === 'Escape') setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
|
<div className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
|
|
<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
placeholder={placeholder ?? t('reservations.searchLocation')}
|
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
|
|
onFocus={() => setOpen(true)}
|
|
onKeyDown={onKey}
|
|
className="bg-transparent text-content"
|
|
style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
|
/>
|
|
{value && (
|
|
<button type="button" onClick={clear} className="bg-transparent text-content-faint" style={{ border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }} aria-label="Clear">
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{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 && !showPicks && rows.length === 0 && (
|
|
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
|
|
)}
|
|
{rows.map((r, i) => (
|
|
<button
|
|
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)}
|
|
className={`text-content ${i === highlight ? 'bg-surface-hover' : 'bg-transparent'}`}
|
|
style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
|
|
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
<MapPin size={12} className="text-content-faint" style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<span style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
|
|
{r.address && r.name && r.name !== r.address && (
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
|
|
)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|