feat: create CityAutocomplete airport search component

This commit is contained in:
gnezim
2026-04-05 21:02:00 +03:00
parent fd054bc688
commit 0d7f6c0954
3 changed files with 181 additions and 362 deletions
@@ -1,184 +1,82 @@
$space-m: 1rem;
$gray: #666;
$border-input: #ddd;
$red: #d32f2f;
$white: #fff;
$buttons-width: 45px;
$border-radius: 4px;
$standard-button-height: 42px;
.city-autocomplete {
display: flex;
flex-direction: column;
&__labels-container {
justify-content: space-between;
margin-bottom: $space-m;
width: 100%;
}
.city-autocomplete__input-group {
display: flex;
align-items: center;
}
&__label {
font-size: 0.875rem;
color: $gray;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:last-child {
text-align: right;
}
}
&__error {
font-size: 0.75rem;
color: $red;
margin-bottom: 0.5rem;
}
&__input {
display: flex;
flex-direction: row;
gap: 8px;
position: relative;
align-items: center;
width: 100%;
border: 1px solid $border-input;
border-radius: $border-radius;
background-color: $white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
:global {
.p-autocomplete {
flex: 1;
}
&__field {
flex: 1;
border: none;
padding: 0.75rem;
font-size: 1rem;
outline: none;
background: transparent;
.p-autocomplete-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 14px;
&::placeholder {
&:focus {
border-color: #1976d2;
outline: none;
}
}
.p-autocomplete-panel {
max-height: 300px;
.p-autocomplete-list {
max-height: 300px;
}
}
}
}
.city-autocomplete__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
}
.city-autocomplete__code {
font-weight: 600;
color: #1976d2;
min-width: 40px;
}
.city-autocomplete__details {
flex: 1;
}
.city-autocomplete__name {
font-weight: 500;
color: #333;
}
.city-autocomplete__country {
font-size: 12px;
color: #999;
}
.city-autocomplete__clear {
:global {
&.p-button-rounded {
width: 36px;
height: 36px;
}
&__clear-button {
width: $buttons-width !important;
min-width: $buttons-width;
border: none !important;
background-color: transparent !important;
height: $standard-button-height - 2px;
cursor: pointer;
font-size: 1.5rem;
padding: 0;
display: none;
&:hover {
color: $red;
}
}
&__search-button {
width: $buttons-width !important;
min-width: $buttons-width;
border-radius: 0 $border-radius $border-radius 0 !important;
border: none !important;
border-left: 1px solid $white !important;
background-color: transparent !important;
height: $standard-button-height - 2px;
cursor: pointer;
font-size: 0.75rem;
padding: 0;
transition: all 0.2s ease;
&:hover {
background-color: $white !important;
border-left-color: $border-input !important;
}
&--opened {
background-color: #f5f5f5 !important;
border-left-color: $border-input !important;
.city-autocomplete__search {
:global {
&.p-button-rounded {
width: 36px;
height: 36px;
}
}
&__input--has-value {
.city-autocomplete__clear-button {
display: block !important;
}
.city-autocomplete__search-button {
border-left: 1px solid $border-input !important;
}
}
&__input--has-error {
border-color: $red;
}
&__suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: $white;
border: 1px solid $border-input;
border-top: none;
border-radius: 0 0 $border-radius $border-radius;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
}
&__suggestion-item {
padding: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
}
&__suggestion-name {
flex: 1;
}
&__suggestion-code {
font-weight: bold;
color: $gray;
margin-left: 1rem;
}
}
.city-autocomplete-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.city-autocomplete-popup {
background-color: $white;
border-radius: $border-radius;
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.button-clear {
display: none !important;
}
@@ -1,212 +1,132 @@
import React, { useState, useRef, useEffect, forwardRef, useCallback } from 'react'
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { AutoComplete } from 'primereact/autocomplete'
import { Button } from 'primereact/button'
import axios from 'axios'
import './city-autocomplete.scss'
export interface City {
code: string
name: string
country?: string
}
export interface CityAutocompleteProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'data-testid' | 'onChange' | 'onSelect'> {
label?: string
export interface CityAutocompleteProps {
value?: City | null
onChange?: (city: City | null) => void
placeholder?: string
error?: string
onErrorChange?: (error?: string) => void
onChange?: (value?: string) => void
onSelect?: (city: City) => void
disabled?: boolean
onCitySelectClick?: () => void
'data-testid'?: string
label?: string
}
export const CityAutocomplete = forwardRef<HTMLInputElement, CityAutocompleteProps>(
({
label,
placeholder = 'Enter city or airport',
error,
onErrorChange,
export const CityAutocomplete: React.FC<CityAutocompleteProps> = ({
value,
onChange,
onSelect,
className = '',
'data-testid': dataTestId = 'city-autocomplete-input',
...props
}, ref) => {
const [city, setCity] = useState<City | null>(null)
const [inputValue, setInputValue] = useState('')
placeholder = 'Select city',
disabled = false,
onCitySelectClick,
'data-testid': dataTestId,
}) => {
const [suggestions, setSuggestions] = useState<City[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [openedPopup, setOpenedPopup] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [loading, setLoading] = useState(false)
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
// Simulate fetching cities (in real app, would call an API)
const filterCities = useCallback((query: string) => {
if (!query.trim()) {
const search = useCallback(async (query: string) => {
if (!query || query.length < 2) {
setSuggestions([])
return
}
// Mock cities data
const mockCities: City[] = [
{ code: 'MOW', name: 'Moscow' },
{ code: 'LED', name: 'Saint Petersburg' },
{ code: 'SVO', name: 'Sheremetyevo' },
{ code: 'DME', name: 'Domodedovo' },
{ code: 'NYC', name: 'New York' },
{ code: 'JFK', name: 'John F. Kennedy' },
{ code: 'PAR', name: 'Paris' },
{ code: 'CDG', name: 'Charles de Gaulle' },
]
const filtered = mockCities.filter(
c =>
c.name.toLowerCase().includes(query.toLowerCase()) ||
c.code.toUpperCase().includes(query.toUpperCase())
)
setSuggestions(filtered)
setShowSuggestions(filtered.length > 0)
try {
setLoading(true)
const response = await axios.get(`/api/cities/search`, {
params: { q: query },
})
setSuggestions(response.data || [])
} catch (error) {
console.error('City search failed:', error)
setSuggestions([])
} finally {
setLoading(false)
}
}, [])
// Debounced input handler
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
const handleSearch = (e: { query: string }) => {
// Clear existing debounce timer
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
// Debounce the API call
debounceTimer.current = setTimeout(() => {
filterCities(value)
search(e.query)
}, 300)
resetError()
}
const handleSelectCity = (selectedCity: City) => {
setCity(selectedCity)
setInputValue(selectedCity.name)
const handleSelect = (city: City) => {
onChange?.(city)
}
const handleClear = () => {
onChange?.(null)
setSuggestions([])
setShowSuggestions(false)
onChange?.(selectedCity.code)
onSelect?.(selectedCity)
resetError()
}
const handleClearInput = () => {
setCity(null)
setInputValue('')
setSuggestions([])
setShowSuggestions(false)
onChange?.(undefined)
resetError()
const itemTemplate = (city: City) => {
return (
<div className="city-autocomplete__item">
<div className="city-autocomplete__code">{city.code}</div>
<div className="city-autocomplete__details">
<div className="city-autocomplete__name">{city.name}</div>
{city.country && <div className="city-autocomplete__country">{city.country}</div>}
</div>
</div>
)
}
const handlePopupToggle = () => {
setOpenedPopup(!openedPopup)
resetError()
}
const resetError = () => {
onErrorChange?.(undefined)
}
// Close popup when clicking outside
// Cleanup debounce timer on unmount
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowSuggestions(false)
setOpenedPopup(false)
}
}
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
return (
<div className="city-autocomplete" ref={containerRef}>
<div className="city-autocomplete__labels-container">
{label && <label className="city-autocomplete__label">{label}</label>}
{city && <label className="city-autocomplete__label" data-testid="city-code">{city.code}</label>}
</div>
{error && (
<div className="city-autocomplete__error">
{error}
</div>
)}
<div
className={`city-autocomplete__input ${city ? 'city-autocomplete__input--has-value' : ''} ${
error ? 'city-autocomplete__input--has-error' : ''
}`}
>
<input
ref={ref}
type="text"
className={`city-autocomplete__field ${className}`.trim()}
<div className="city-autocomplete" data-testid={dataTestId || 'city-autocomplete'}>
<div className="city-autocomplete__input-group">
<AutoComplete<City>
value={value || undefined}
suggestions={suggestions}
completeMethod={handleSearch}
field="name"
onSelect={(e) => handleSelect(e.value)}
placeholder={placeholder}
value={inputValue}
onChange={handleInputChange}
data-testid={dataTestId}
autoComplete="off"
{...props}
disabled={disabled || loading}
itemTemplate={itemTemplate}
inputClassName="city-autocomplete__field"
dropdown
forceSelection
/>
{value && (
<Button
icon="pi pi-times"
className="p-button-rounded p-button-text city-autocomplete__clear"
onClick={handleClear}
data-testid={`${dataTestId || 'city-autocomplete'}-clear`}
/>
{city && (
<button
type="button"
className="city-autocomplete__clear-button button-clear"
onClick={handleClearInput}
data-testid="autocomplete-clear-input"
aria-label="Clear input"
>
×
</button>
)}
<button
type="button"
className={`city-autocomplete__search-button ${
openedPopup ? 'city-autocomplete__search-button--opened' : ''
}`}
onClick={handlePopupToggle}
data-testid="autocomplete-popup-button"
aria-label="Toggle city selector"
>
</button>
{showSuggestions && suggestions.length > 0 && (
<div className="city-autocomplete__suggestions">
{suggestions.map(suggestion => (
<div
key={suggestion.code}
className="city-autocomplete__suggestion-item"
onClick={() => handleSelectCity(suggestion)}
data-testid={`suggestion-${suggestion.code}`}
>
<span className="city-autocomplete__suggestion-name">{suggestion.name}</span>
<span className="city-autocomplete__suggestion-code">{suggestion.code}</span>
</div>
))}
</div>
{onCitySelectClick && (
<Button
icon="pi pi-search"
className="p-button-rounded p-button-text city-autocomplete__search"
onClick={onCitySelectClick}
data-testid={`${dataTestId || 'city-autocomplete'}-search`}
/>
)}
</div>
{openedPopup && (
<div className="city-autocomplete-popup-wrapper" data-testid="city-select-popup">
{/* City selector popup would go here */}
<div className="city-autocomplete-popup">
<p>City selector popup</p>
</div>
</div>
)}
</div>
)
}
)
CityAutocomplete.displayName = 'CityAutocomplete'
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { CityAutocomplete } from '../../components/city-autocomplete'
import { CityAutocomplete, City } from '../../components/city-autocomplete'
import { Button } from '../../components/button'
import { DatePicker } from '../../components/datepicker'
import { Flight } from '../OnlineBoard'
@@ -63,8 +63,8 @@ const OnlineBoardSearch: React.FC<OnlineBoardSearchProps> = ({
setSearchError(undefined)
}
const handleDestinationChange = (cityCode?: string) => {
setDestination(cityCode)
const handleDestinationChange = (city: City | null) => {
setDestination(city?.code)
}
return (
@@ -72,6 +72,7 @@ const OnlineBoardSearch: React.FC<OnlineBoardSearchProps> = ({
<div className="online-board-search__container">
<div className="online-board-search__field">
<CityAutocomplete
value={destination ? { code: destination, name: '' } : null}
label={mode === 'arrivals' ? 'From' : 'To'}
placeholder="Select city or airport"
onChange={handleDestinationChange}