mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(planner): share the suggestion dropdown between the airport, location and address fields
AirportSelect, LocationSelect and AddressInput each carried their own copy of the open state, the keyboard highlight, the outside click that closes the list, the floating list with its loading line and the suggestion row. useSuggestionDropdown now holds the open state, the highlight, the outside click and the arrow, Enter and Escape handling. SuggestionDropdown.tsx holds the boxed input with its icon and clear button (airport and location), the floating list (all three), the row button and the place row content (location and address). What still differs between the fields stays with them or is a parameter: the icon, the placeholder, what typing does (LocationSelect also resets the highlight), the rows the keys walk (LocationSelect walks the trip's places below three characters), whether the loading line may show (not while LocationSelect offers the trip's places) and how a row lines up its content (centred for airports). Each field keeps its own query, search and pick.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -17,28 +18,18 @@ interface Props {
|
||||
// authoritative: every keystroke reaches the parent so a hand-written address
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const { locale } = useTranslation()
|
||||
const placeLang = usePlaceLanguage()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
|
||||
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)
|
||||
// Clearing the timer does nothing to a request that is already out, and
|
||||
// mapsApi.search takes no signal, so results are matched by ticket instead.
|
||||
const reqIdRef = useRef(0)
|
||||
|
||||
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) }, [])
|
||||
|
||||
// Search on typing only (not on focus or external value changes), so opening
|
||||
@@ -74,14 +65,6 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
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)) }
|
||||
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 === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }}>
|
||||
<input
|
||||
@@ -92,37 +75,23 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
// Opens its list on focus, so a dialog must not focus it by itself (#1302).
|
||||
data-no-autofocus
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKey}
|
||||
onKeyDown={(e) => handleKey(e, results, pick)}
|
||||
className={className}
|
||||
/>
|
||||
{open && (loading || results.length > 0) && (
|
||||
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', insetInline: 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 && (
|
||||
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{open && (
|
||||
<SuggestionList loading={loading} rowCount={results.length}>
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
<SuggestionRow
|
||||
key={`${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: 'start',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
active={i === highlight}
|
||||
onPick={() => pick(r)}
|
||||
onHover={() => setHighlight(i)}
|
||||
align="flex-start"
|
||||
>
|
||||
<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>
|
||||
<PlaceSuggestion name={r.name} address={r.address} />
|
||||
</SuggestionRow>
|
||||
))}
|
||||
</div>
|
||||
</SuggestionList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plane, X } from 'lucide-react'
|
||||
import { Plane } from 'lucide-react'
|
||||
import { airportsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
|
||||
export interface Airport {
|
||||
iata: string
|
||||
@@ -35,11 +37,9 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
|
||||
try { return countryName?.of(code) || code } catch { return code }
|
||||
}
|
||||
const [query, setQuery] = useState(value ? formatLabel(value) : '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
|
||||
const [results, setResults] = useState<Airport[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
@@ -47,14 +47,6 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
|
||||
setQuery(value ? formatLabel(value) : '')
|
||||
}, [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()
|
||||
@@ -81,7 +73,7 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
|
||||
}
|
||||
}, 220)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query, value])
|
||||
}, [query, value, setHighlight])
|
||||
|
||||
const pick = (a: Airport) => {
|
||||
onChange(a)
|
||||
@@ -96,63 +88,30 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
|
||||
setResults([])
|
||||
}
|
||||
|
||||
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)) }
|
||||
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 === '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)' }}>
|
||||
<Plane size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder ?? t('airport.searchPlaceholder')}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
|
||||
// Opens its list on focus, so a dialog must not focus it by itself (#1302).
|
||||
data-no-autofocus
|
||||
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>
|
||||
<SuggestionInputBox
|
||||
icon={<Plane size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
|
||||
query={query}
|
||||
placeholder={placeholder ?? t('airport.searchPlaceholder')}
|
||||
onType={(text) => { setQuery(text); setOpen(true); if (value) onChange(null) }}
|
||||
onOpen={() => setOpen(true)}
|
||||
onKeyDown={(e) => handleKey(e, results, pick)}
|
||||
onClear={value ? clear : undefined}
|
||||
/>
|
||||
|
||||
{open && (loading || results.length > 0) && (
|
||||
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', insetInline: 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 && (
|
||||
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{open && (
|
||||
<SuggestionList loading={loading} rowCount={results.length}>
|
||||
{results.map((a, i) => (
|
||||
<button
|
||||
key={a.iata}
|
||||
type="button"
|
||||
onClick={() => pick(a)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
className={`text-content ${i === highlight ? 'bg-surface-hover' : 'bg-transparent'}`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'start',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<SuggestionRow key={a.iata} active={i === highlight} onPick={() => pick(a)} onHover={() => setHighlight(i)} align="center">
|
||||
<span className="text-content-muted" style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, minWidth: 32 }}>{a.iata}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.city || a.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}{a.country ? ` · ${displayCountry(a.country)}` : ''}</div>
|
||||
</span>
|
||||
</button>
|
||||
</SuggestionRow>
|
||||
))}
|
||||
</div>
|
||||
</SuggestionList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown'
|
||||
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -31,25 +33,15 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
// 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 { open, setOpen, highlight, setHighlight, wrapRef, handleKey } = useSuggestionDropdown()
|
||||
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()
|
||||
@@ -77,7 +69,7 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
stale = true
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [query, value, locale, placeLang])
|
||||
}, [query, value, locale, placeLang, setHighlight])
|
||||
|
||||
const pick = (r: any) => {
|
||||
const lat = Number(r.lat)
|
||||
@@ -105,65 +97,32 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
|
||||
? (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) }}
|
||||
// Opens its list on focus, so a dialog must not focus it by itself (#1302).
|
||||
data-no-autofocus
|
||||
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>
|
||||
<SuggestionInputBox
|
||||
icon={<MapPin size={14} className="text-content-faint" style={{ flexShrink: 0 }} />}
|
||||
query={query}
|
||||
placeholder={placeholder ?? t('reservations.searchLocation')}
|
||||
onType={(text) => { setQuery(text); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
|
||||
onOpen={() => setOpen(true)}
|
||||
onKeyDown={(e) => handleKey(e, rows, pick)}
|
||||
onClear={value ? clear : undefined}
|
||||
/>
|
||||
|
||||
{open && ((loading && !showPicks) || rows.length > 0) && (
|
||||
<div className="bg-surface-card" style={{ position: 'absolute', top: 'calc(100% + 4px)', insetInline: 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>
|
||||
)}
|
||||
{open && (
|
||||
<SuggestionList loading={loading && !showPicks} rowCount={rows.length}>
|
||||
{rows.map((r, i) => (
|
||||
<button
|
||||
<SuggestionRow
|
||||
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: 'start',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
active={i === highlight}
|
||||
onPick={() => pick(r)}
|
||||
onHover={() => setHighlight(i)}
|
||||
align="flex-start"
|
||||
>
|
||||
<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>
|
||||
<PlaceSuggestion name={r.name} address={r.address} />
|
||||
</SuggestionRow>
|
||||
))}
|
||||
</div>
|
||||
</SuggestionList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
import { PlaceSuggestion, SuggestionInputBox, SuggestionList, SuggestionRow } from './SuggestionDropdown';
|
||||
|
||||
// FE-PLANNER-SUGGESTMARKUP-001 to FE-PLANNER-SUGGESTMARKUP-008
|
||||
|
||||
function inputBox(over: Partial<React.ComponentProps<typeof SuggestionInputBox>> = {}) {
|
||||
const props = {
|
||||
icon: <span data-testid="icon" />,
|
||||
query: 'Ber',
|
||||
placeholder: 'Search',
|
||||
onType: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onKeyDown: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<SuggestionInputBox {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('SuggestionInputBox', () => {
|
||||
it('FE-PLANNER-SUGGESTMARKUP-001: renders the icon and the query, and reports typing, focus and keys', () => {
|
||||
const props = inputBox();
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
const field = screen.getByPlaceholderText('Search');
|
||||
expect(field).toHaveValue('Ber');
|
||||
expect(field).toHaveAttribute('data-no-autofocus');
|
||||
fireEvent.change(field, { target: { value: 'Berl' } });
|
||||
expect(props.onType).toHaveBeenCalledWith('Berl');
|
||||
fireEvent.focus(field);
|
||||
expect(props.onOpen).toHaveBeenCalled();
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
expect(props.onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTMARKUP-002: shows the clear button only with an onClear', () => {
|
||||
inputBox();
|
||||
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTMARKUP-003: the clear button calls onClear', () => {
|
||||
const onClear = vi.fn();
|
||||
inputBox({ onClear });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SuggestionList', () => {
|
||||
it('FE-PLANNER-SUGGESTMARKUP-004: renders nothing while idle and empty', () => {
|
||||
const { container } = render(
|
||||
<SuggestionList loading={false} rowCount={0}>
|
||||
<span>row</span>
|
||||
</SuggestionList>
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTMARKUP-005: shows the loading line until rows arrive', () => {
|
||||
const { rerender } = render(
|
||||
<SuggestionList loading rowCount={0}>
|
||||
{null}
|
||||
</SuggestionList>
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
rerender(
|
||||
<SuggestionList loading rowCount={1}>
|
||||
<span>row</span>
|
||||
</SuggestionList>
|
||||
);
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('row')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SuggestionRow', () => {
|
||||
it('FE-PLANNER-SUGGESTMARKUP-006: reports pick and hover, and marks the active row', () => {
|
||||
const onPick = vi.fn();
|
||||
const onHover = vi.fn();
|
||||
render(
|
||||
<SuggestionRow active onPick={onPick} onHover={onHover} align="center">
|
||||
Berlin
|
||||
</SuggestionRow>
|
||||
);
|
||||
const row = screen.getByRole('button', { name: 'Berlin' });
|
||||
expect(row).toHaveClass('bg-surface-hover');
|
||||
expect(row.style.alignItems).toBe('center');
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(onHover).toHaveBeenCalled();
|
||||
fireEvent.click(row);
|
||||
expect(onPick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTMARKUP-007: an inactive row stays transparent', () => {
|
||||
render(
|
||||
<SuggestionRow active={false} onPick={vi.fn()} onHover={vi.fn()} align="flex-start">
|
||||
Paris
|
||||
</SuggestionRow>
|
||||
);
|
||||
const row = screen.getByRole('button', { name: 'Paris' });
|
||||
expect(row).toHaveClass('bg-transparent');
|
||||
expect(row.style.alignItems).toBe('flex-start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceSuggestion', () => {
|
||||
it('FE-PLANNER-SUGGESTMARKUP-008: shows the address under the name only when it says more', () => {
|
||||
const { rerender } = render(<PlaceSuggestion name="Louvre" address="Rue de Rivoli, Paris" />);
|
||||
expect(screen.getByText('Louvre')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument();
|
||||
rerender(<PlaceSuggestion name="Louvre" address="Louvre" />);
|
||||
expect(screen.getAllByText('Louvre')).toHaveLength(1);
|
||||
rerender(<PlaceSuggestion name={null} address="Rue de Rivoli, Paris" />);
|
||||
expect(screen.getAllByText('Rue de Rivoli, Paris')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { MapPin, X } from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
// The markup the planner's search fields share: the boxed input with its icon and
|
||||
// clear button (AirportSelect, LocationSelect), the floating list (all three) and
|
||||
// a map search answer as a row (LocationSelect, AddressInput).
|
||||
|
||||
interface SuggestionInputBoxProps {
|
||||
icon: React.ReactNode;
|
||||
query: string;
|
||||
placeholder: string;
|
||||
onType: (text: string) => void;
|
||||
onOpen: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
/** Shown as the clear button while a value is picked. */
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function SuggestionInputBox({
|
||||
icon,
|
||||
query,
|
||||
placeholder,
|
||||
onType,
|
||||
onOpen,
|
||||
onKeyDown,
|
||||
onClear,
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className="bg-surface-tertiary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onType(e.target.value)}
|
||||
// Opens its list on focus, so a dialog must not focus it by itself (#1302).
|
||||
data-no-autofocus
|
||||
onFocus={onOpen}
|
||||
onKeyDown={onKeyDown}
|
||||
className="bg-transparent text-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
}}
|
||||
/>
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SuggestionListProps {
|
||||
/** Shows the loading line while there are no rows yet. */
|
||||
loading: boolean;
|
||||
rowCount: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** The floating list under the field. Rendered only while it is loading or has rows; the caller checks `open`. */
|
||||
export function SuggestionList({ loading, rowCount, children }: SuggestionListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!loading && rowCount === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="bg-surface-card"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
insetInline: 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 && rowCount === 0 && (
|
||||
<div className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SuggestionRowProps {
|
||||
active: boolean;
|
||||
onPick: () => void;
|
||||
onHover: () => void;
|
||||
/** How the row lines up its icon and text. */
|
||||
align: 'center' | 'flex-start';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SuggestionRow({ active, onPick, onHover, align, children }: SuggestionRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
onMouseEnter={onHover}
|
||||
className={`text-content ${active ? 'bg-surface-hover' : 'bg-transparent'}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: align,
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'start',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaceSuggestionProps {
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
}
|
||||
|
||||
/** A place as a row's content: a pin, its name, and its address when that says more. */
|
||||
export function PlaceSuggestion({ name, address }: PlaceSuggestionProps) {
|
||||
return (
|
||||
<>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{name || address}
|
||||
</div>
|
||||
{address && name && name !== address && (
|
||||
<div
|
||||
className="text-content-faint"
|
||||
style={{
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{address}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useSuggestionDropdown } from './useSuggestionDropdown';
|
||||
|
||||
// FE-PLANNER-SUGGESTDD-001 to FE-PLANNER-SUGGESTDD-006
|
||||
|
||||
const ROWS = ['alpha', 'beta', 'gamma'];
|
||||
|
||||
function Harness({ rows = ROWS, onPick }: { rows?: string[]; onPick: (row: string) => void }) {
|
||||
const { open, setOpen, highlight, wrapRef, handleKey } = useSuggestionDropdown();
|
||||
return (
|
||||
<div>
|
||||
<div ref={wrapRef}>
|
||||
<input aria-label="field" onFocus={() => setOpen(true)} onKeyDown={(e) => handleKey(e, rows, onPick)} />
|
||||
<span data-testid="state">{`${open ? 'open' : 'closed'}:${highlight}`}</span>
|
||||
</div>
|
||||
<button type="button">outside</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function state() {
|
||||
return screen.getByTestId('state').textContent;
|
||||
}
|
||||
|
||||
describe('useSuggestionDropdown', () => {
|
||||
it('FE-PLANNER-SUGGESTDD-001: starts closed with nothing highlighted', () => {
|
||||
render(<Harness onPick={vi.fn()} />);
|
||||
expect(state()).toBe('closed:-1');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTDD-002: arrow keys move the highlight and stop at both ends', () => {
|
||||
render(<Harness onPick={vi.fn()} />);
|
||||
const field = screen.getByLabelText('field');
|
||||
fireEvent.focus(field);
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
expect(state()).toBe('open:2');
|
||||
fireEvent.keyDown(field, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowUp' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowUp' });
|
||||
expect(state()).toBe('open:0');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTDD-003: Enter picks the highlighted row, and does nothing without one', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<Harness onPick={onPick} />);
|
||||
const field = screen.getByLabelText('field');
|
||||
fireEvent.focus(field);
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(onPick).toHaveBeenCalledWith('beta');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTDD-004: Escape closes the list', () => {
|
||||
render(<Harness onPick={vi.fn()} />);
|
||||
const field = screen.getByLabelText('field');
|
||||
fireEvent.focus(field);
|
||||
fireEvent.keyDown(field, { key: 'Escape' });
|
||||
expect(state()).toBe('closed:-1');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTDD-005: keys are ignored while closed or without rows', () => {
|
||||
const onPick = vi.fn();
|
||||
const { unmount } = render(<Harness onPick={onPick} />);
|
||||
fireEvent.keyDown(screen.getByLabelText('field'), { key: 'ArrowDown' });
|
||||
expect(state()).toBe('closed:-1');
|
||||
unmount();
|
||||
|
||||
render(<Harness rows={[]} onPick={onPick} />);
|
||||
const field = screen.getByLabelText('field');
|
||||
fireEvent.focus(field);
|
||||
fireEvent.keyDown(field, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
expect(state()).toBe('open:-1');
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SUGGESTDD-006: a mousedown outside closes the list, one inside keeps it open', () => {
|
||||
render(<Harness onPick={vi.fn()} />);
|
||||
const field = screen.getByLabelText('field');
|
||||
fireEvent.focus(field);
|
||||
fireEvent.mouseDown(field);
|
||||
expect(state()).toBe('open:-1');
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: 'outside' }));
|
||||
expect(state()).toBe('closed:-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* The open state, the keyboard highlight and the outside click that closes the
|
||||
* list, shared by the planner's search fields (AirportSelect, LocationSelect and
|
||||
* AddressInput). Each field keeps its own query, search and pick.
|
||||
*/
|
||||
export function useSuggestionDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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]);
|
||||
|
||||
/** Arrow keys move the highlight over `rows`, Enter picks the highlighted row, Escape closes. */
|
||||
const handleKey = <T>(e: React.KeyboardEvent<HTMLInputElement>, rows: T[], pick: (row: T) => void) => {
|
||||
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 && highlight < rows.length) {
|
||||
e.preventDefault();
|
||||
pick(rows[highlight]);
|
||||
} else if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
return { open, setOpen, highlight, setHighlight, wrapRef, handleKey };
|
||||
}
|
||||
Reference in New Issue
Block a user