feat: create CityAutocomplete airport search component
This commit is contained in:
@@ -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%;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
&__field {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
.city-autocomplete__input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
:global {
|
||||
.p-autocomplete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.p-autocomplete-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1976d2;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-autocomplete-panel {
|
||||
max-height: 300px;
|
||||
|
||||
.p-autocomplete-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
.city-autocomplete__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.button-clear {
|
||||
display: none !important;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.city-autocomplete__search {
|
||||
:global {
|
||||
&.p-button-rounded {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
onChange,
|
||||
onSelect,
|
||||
className = '',
|
||||
'data-testid': dataTestId = 'city-autocomplete-input',
|
||||
...props
|
||||
}, ref) => {
|
||||
const [city, setCity] = useState<City | null>(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<City[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [openedPopup, setOpenedPopup] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
export const CityAutocomplete: React.FC<CityAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select city',
|
||||
disabled = false,
|
||||
onCitySelectClick,
|
||||
'data-testid': dataTestId,
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<City[]>([])
|
||||
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()) {
|
||||
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)
|
||||
}, [])
|
||||
|
||||
// Debounced input handler
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInputValue(value)
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
filterCities(value)
|
||||
}, 300)
|
||||
|
||||
resetError()
|
||||
}
|
||||
|
||||
const handleSelectCity = (selectedCity: City) => {
|
||||
setCity(selectedCity)
|
||||
setInputValue(selectedCity.name)
|
||||
const search = useCallback(async (query: string) => {
|
||||
if (!query || query.length < 2) {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
onChange?.(selectedCity.code)
|
||||
onSelect?.(selectedCity)
|
||||
resetError()
|
||||
return
|
||||
}
|
||||
|
||||
const handleClearInput = () => {
|
||||
setCity(null)
|
||||
setInputValue('')
|
||||
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([])
|
||||
setShowSuggestions(false)
|
||||
onChange?.(undefined)
|
||||
resetError()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearch = (e: { query: string }) => {
|
||||
// Clear existing debounce timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
|
||||
const handlePopupToggle = () => {
|
||||
setOpenedPopup(!openedPopup)
|
||||
resetError()
|
||||
}
|
||||
// Debounce the API call
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
search(e.query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const resetError = () => {
|
||||
onErrorChange?.(undefined)
|
||||
}
|
||||
const handleSelect = (city: City) => {
|
||||
onChange?.(city)
|
||||
}
|
||||
|
||||
// Close popup when clicking outside
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
const handleClear = () => {
|
||||
onChange?.(null)
|
||||
setSuggestions([])
|
||||
}
|
||||
|
||||
const itemTemplate = (city: City) => {
|
||||
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 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>
|
||||
|
||||
{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()}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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'
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<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}
|
||||
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`}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user