diff --git a/client/src/components/Planner/AddressInput.tsx b/client/src/components/Planner/AddressInput.tsx index 7c024a76b..5d0e421df 100644 --- a/client/src/components/Planner/AddressInput.tsx +++ b/client/src/components/Planner/AddressInput.tsx @@ -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([]) - const [highlight, setHighlight] = useState(-1) const [loading, setLoading] = useState(false) - const wrapRef = useRef(null) const debounceRef = useRef | 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) => { - 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 (
setOpen(true)} - onKeyDown={onKey} + onKeyDown={(e) => handleKey(e, results, pick)} className={className} /> - {open && (loading || results.length > 0) && ( -
- {loading && results.length === 0 && ( -
{t('common.loading')}
- )} + {open && ( + {results.map((r, i) => ( - + + ))} -
+ )}
) diff --git a/client/src/components/Planner/AirportSelect.tsx b/client/src/components/Planner/AirportSelect.tsx index d5811cd62..532e5705a 100644 --- a/client/src/components/Planner/AirportSelect.tsx +++ b/client/src/components/Planner/AirportSelect.tsx @@ -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([]) - const [highlight, setHighlight] = useState(-1) const [loading, setLoading] = useState(false) - const wrapRef = useRef(null) const abortRef = useRef(null) const debounceRef = useRef | 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) => { - 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 (
-
- - { 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 && ( - - )} -
+ } + 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) && ( -
- {loading && results.length === 0 && ( -
{t('common.loading')}
- )} + {open && ( + {results.map((a, i) => ( - + ))} -
+ )}
) diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 08562ac55..8b8106ef9 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -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([]) - const [highlight, setHighlight] = useState(-1) const [loading, setLoading] = useState(false) - const wrapRef = useRef(null) const debounceRef = useRef | 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) => { - 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 (
-
- - { 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 && ( - - )} -
+ } + 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) && ( -
- {loading && !showPicks && rows.length === 0 && ( -
{t('common.loading')}
- )} + {open && ( + {rows.map((r, i) => ( - + + ))} -
+ )}
) diff --git a/client/src/components/Planner/SuggestionDropdown.test.tsx b/client/src/components/Planner/SuggestionDropdown.test.tsx new file mode 100644 index 000000000..db19193dd --- /dev/null +++ b/client/src/components/Planner/SuggestionDropdown.test.tsx @@ -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> = {}) { + const props = { + icon: , + query: 'Ber', + placeholder: 'Search', + onType: vi.fn(), + onOpen: vi.fn(), + onKeyDown: vi.fn(), + ...over, + }; + render(); + 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( + + row + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-PLANNER-SUGGESTMARKUP-005: shows the loading line until rows arrive', () => { + const { rerender } = render( + + {null} + + ); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + rerender( + + row + + ); + 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( + + Berlin + + ); + 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( + + Paris + + ); + 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(); + expect(screen.getByText('Louvre')).toBeInTheDocument(); + expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument(); + rerender(); + expect(screen.getAllByText('Louvre')).toHaveLength(1); + rerender(); + expect(screen.getAllByText('Rue de Rivoli, Paris')).toHaveLength(1); + }); +}); diff --git a/client/src/components/Planner/SuggestionDropdown.tsx b/client/src/components/Planner/SuggestionDropdown.tsx new file mode 100644 index 000000000..2ea7a9499 --- /dev/null +++ b/client/src/components/Planner/SuggestionDropdown.tsx @@ -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) => 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 ( +
+ {icon} + 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 && ( + + )} +
+ ); +} + +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 ( +
+ {loading && rowCount === 0 && ( +
+ {t('common.loading')} +
+ )} + {children} +
+ ); +} + +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 ( + + ); +} + +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 ( + <> + + +
+ {name || address} +
+ {address && name && name !== address && ( +
+ {address} +
+ )} +
+ + ); +} diff --git a/client/src/components/Planner/useSuggestionDropdown.test.tsx b/client/src/components/Planner/useSuggestionDropdown.test.tsx new file mode 100644 index 000000000..83104a74d --- /dev/null +++ b/client/src/components/Planner/useSuggestionDropdown.test.tsx @@ -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 ( +
+
+ setOpen(true)} onKeyDown={(e) => handleKey(e, rows, onPick)} /> + {`${open ? 'open' : 'closed'}:${highlight}`} +
+ +
+ ); +} + +function state() { + return screen.getByTestId('state').textContent; +} + +describe('useSuggestionDropdown', () => { + it('FE-PLANNER-SUGGESTDD-001: starts closed with nothing highlighted', () => { + render(); + expect(state()).toBe('closed:-1'); + }); + + it('FE-PLANNER-SUGGESTDD-002: arrow keys move the highlight and stop at both ends', () => { + render(); + 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(); + 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(); + 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(); + fireEvent.keyDown(screen.getByLabelText('field'), { key: 'ArrowDown' }); + expect(state()).toBe('closed:-1'); + unmount(); + + render(); + 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(); + 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'); + }); +}); diff --git a/client/src/components/Planner/useSuggestionDropdown.ts b/client/src/components/Planner/useSuggestionDropdown.ts new file mode 100644 index 000000000..66d0f8f24 --- /dev/null +++ b/client/src/components/Planner/useSuggestionDropdown.ts @@ -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(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 = (e: React.KeyboardEvent, 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 }; +}