diff --git a/src/ui/city-autocomplete/CityAutocomplete.scss b/src/ui/city-autocomplete/CityAutocomplete.scss new file mode 100644 index 00000000..5f0c57c2 --- /dev/null +++ b/src/ui/city-autocomplete/CityAutocomplete.scss @@ -0,0 +1,102 @@ +.city-autocomplete { + display: flex; + flex-direction: column; + position: relative; + + &__labels-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 8px; + } + + &__label { + color: #8a8a8a; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__input { + display: flex; + flex-direction: row; + position: relative; + align-items: center; + width: 100%; + box-shadow: 0 0 0 1px #e0e0e0; + border-radius: 4px; + + .p-autocomplete { + flex: 1; + } + + .button-clear { + display: none; + width: 32px; + height: 38px; + border: none; + background: transparent; + cursor: pointer; + font-size: 18px; + color: #8a8a8a; + + &::before { + content: "×"; + } + } + } + + &__input--has-value { + .button-clear { + display: block; + } + + .city-autocomplete__search-button { + border-left: 1px solid #e0e0e0 !important; + } + } + + &__input--has-error { + box-shadow: 0 0 0 1px #e55353; + } + + &__search-button { + width: 38px !important; + min-width: 38px; + height: 38px; + border-radius: 0 4px 4px 0 !important; + border: none !important; + border-left: 1px solid white !important; + background-color: transparent !important; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + + &::before { + content: "▾"; + display: inline-block; + color: #2457ff; + } + + &:hover { + background-color: #fff !important; + border-left-color: #e0e0e0 !important; + } + + &--opened { + background-color: #eef3ff !important; + } + } +} + +.city-autocomplete-popup-wrapper { + position: relative; +} + +.tooltip { + color: #e55353; + font-size: 12px; + margin-bottom: 4px; +} diff --git a/src/ui/city-autocomplete/CityAutocomplete.test.tsx b/src/ui/city-autocomplete/CityAutocomplete.test.tsx new file mode 100644 index 00000000..51eec265 --- /dev/null +++ b/src/ui/city-autocomplete/CityAutocomplete.test.tsx @@ -0,0 +1,167 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { CityAutocomplete } from "./CityAutocomplete.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IRawDictionaries } from "@/shared/dictionaries/index.js"; + +const raw: IRawDictionaries = { + regions: [{ world_region_id: 500374, title: { ru: "Россия" } }], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], +}; +const dictionaries = transformDictionaries(raw, "ru"); + +vi.mock("primereact/autocomplete", () => ({ + AutoComplete: (props: Record) => ( + { + const onChange = props["onChange"] as ((ev: { value: string }) => void) | undefined; + onChange?.({ value: e.target.value }); + }} + /> + ), +})); + +describe("CityAutocomplete", () => { + it("renders label and placeholder", () => { + render( + , + ); + expect(screen.getByText("City")).toBeTruthy(); + expect(screen.getByPlaceholderText("Pick one")).toBeTruthy(); + }); + + it("shows the selected city code in the right label when value is set", () => { + render( + , + ); + expect(screen.getByTestId("test-code").textContent).toBe("MOW"); + }); + + it("opens the regional picker when the popup-trigger button is clicked", () => { + render( + , + ); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.getByTestId("city-picker-popup")).toBeTruthy(); + }); + + it("calls onChange with the selected code when a popup cell is clicked", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + fireEvent.click(screen.getByText("Санкт-Петербург")); + expect(onChange).toHaveBeenCalledWith("LED"); + }); + + it("clears the value when the clear button is clicked", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("test-clear-button")); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("closes the popup when clicking outside the component", () => { + render( +
+ +
outside
+
, + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.getByTestId("city-picker-popup")).toBeTruthy(); + fireEvent.mouseDown(screen.getByTestId("outside")); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + }); + + it("renders error tooltip and has-error class when error prop is set", () => { + const { container } = render( + , + ); + expect(screen.getByTestId("test-error").textContent).toBe("required"); + expect(container.querySelector(".city-autocomplete__input--has-error")).toBeTruthy(); + }); + + it("does not open popup when dictionaries is null", () => { + render( + , + ); + fireEvent.click(screen.getByTestId("test-popup-button")); + expect(screen.queryByTestId("city-picker-popup")).toBeNull(); + }); +}); diff --git a/src/ui/city-autocomplete/CityAutocomplete.tsx b/src/ui/city-autocomplete/CityAutocomplete.tsx new file mode 100644 index 00000000..e9df234c --- /dev/null +++ b/src/ui/city-autocomplete/CityAutocomplete.tsx @@ -0,0 +1,180 @@ +import { useState, useRef, useCallback, useEffect, type FC } from "react"; +import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; +import { CityPickerPopup } from "./CityPickerPopup.js"; +import type { IDictionaries } from "@/shared/dictionaries/index.js"; +import "./CityAutocomplete.scss"; + +interface CitySuggestion { + code: string; + name: string; +} + +export interface CityAutocompleteProps { + label: string; + placeholder: string; + value: string; + onChange: (code: string) => void; + dictionaries: IDictionaries | null; + onLocate?: () => void | Promise; + error?: string; + testIdPrefix?: string; +} + +export const CityAutocomplete: FC = ({ + label, + placeholder, + value, + onChange, + dictionaries, + onLocate, + error, + testIdPrefix = "city-autocomplete", +}) => { + const [inputValue, setInputValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); + const [popupOpen, setPopupOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setPopupOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const handleComplete = useCallback( + (event: AutoCompleteCompleteEvent) => { + if (!dictionaries) { + setSuggestions([]); + return; + } + const q = event.query.trim().toUpperCase(); + if (q.length === 0) { + setSuggestions([]); + return; + } + + if (q.length === 3) { + const byCity = dictionaries.cityByCode.get(q); + if (byCity) { + setSuggestions([{ code: byCity.code, name: byCity.name }]); + return; + } + } + + const lc = event.query.toLowerCase(); + const byName = dictionaries.cities + .filter((c) => c.name.toLowerCase().includes(lc)) + .slice(0, 15) + .map((c) => ({ code: c.code, name: c.name })); + if (byName.length) { + setSuggestions(byName); + return; + } + + const byAirport = dictionaries.airportByCode.get(q); + if (byAirport) { + const city = dictionaries.cityByCode.get(byAirport.city_code.toUpperCase()); + if (city) { + setSuggestions([{ code: city.code, name: city.name }]); + return; + } + } + setSuggestions([]); + }, + [dictionaries], + ); + + const handleSelect = useCallback( + (code: string) => { + onChange(code); + setPopupOpen(false); + }, + [onChange], + ); + + const handleClear = useCallback(() => { + onChange(""); + setInputValue(""); + }, [onChange]); + + const selectedCity = dictionaries?.cityByCode.get(value.toUpperCase()) ?? null; + const hasValue = Boolean(selectedCity); + + return ( +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ setInputValue(e.value as CitySuggestion | string)} + onSelect={(e) => { + const sel = e.value as CitySuggestion; + if (sel?.code) handleSelect(sel.code); + }} + data-testid={`${testIdPrefix}-input`} + inputClassName="input--filter" + className="input--filter" + /> +
+ + {popupOpen && dictionaries && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/ui/city-autocomplete/index.ts b/src/ui/city-autocomplete/index.ts new file mode 100644 index 00000000..378c00ec --- /dev/null +++ b/src/ui/city-autocomplete/index.ts @@ -0,0 +1,6 @@ +export { CityAutocomplete } from "./CityAutocomplete.js"; +export type { CityAutocompleteProps } from "./CityAutocomplete.js"; +export { CityPickerPopup } from "./CityPickerPopup.js"; +export type { CityPickerPopupProps } from "./CityPickerPopup.js"; +export { buildCountryCityRows } from "./buildCountryCityRows.js"; +export type { ICountryCityRow } from "./buildCountryCityRows.js";