diff --git a/src/ui/city-autocomplete/CityPickerPopup.scss b/src/ui/city-autocomplete/CityPickerPopup.scss new file mode 100644 index 00000000..2ffa9c08 --- /dev/null +++ b/src/ui/city-autocomplete/CityPickerPopup.scss @@ -0,0 +1,125 @@ +.city-autocomplete-popup { + position: absolute; + width: 630px; + background: #ffffff; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10000; + margin-top: 8px; + + .tabs { + display: flex; + align-items: stretch; + border-bottom: 1px solid #e0e0e0; + + .tab-button { + flex-grow: 1; + padding: 10px; + background-color: #f3f9ff; + border: none; + border-right: 1px solid #e0e0e0; + color: #2457ff; + font-weight: 700; + font-size: 14px; + cursor: pointer; + + &:last-child { border-right: none; } + + &.active { + background-color: #ffffff; + color: #333; + } + + &:hover { + background-color: #e8f1ff; + } + } + } + + .content { + padding: 12px 16px; + + .content--scroll-panel { + max-height: 350px; + overflow-y: auto; + } + + .row { + display: flex; + align-items: flex-start; + padding: 4px 0; + + &.country-start-row { + border-top: 1px solid #e0e0e0; + padding-top: 12px; + margin-top: 8px; + + &:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; + } + } + } + + .cell { + &.contry { + width: 30%; + font-weight: 700; + padding: 7.5px; + } + + &.city { + flex: 1; + padding-right: 16px; + + .city--item { + display: inline-block; + padding: 8px; + cursor: pointer; + border-radius: 4px; + transition-duration: 0.1s; + + &:hover { background-color: #f3f9ff; } + } + + &.city-active .city--item { + background-color: #dfe8ff; + } + + .airports-column { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 8px; + + .city--item { + color: #8a8a8a; + } + } + } + } + } + + .city-autocomplete-popup-footer { + background: #f3f9ff; + border-top: 1px solid #e0e0e0; + + .gps-contaner { + padding: 16px; + + .gps-button { + height: 40px; + width: 100%; + background-color: #4a8cff; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + + &:hover { background-color: #3a7aee; } + } + } + } +} diff --git a/src/ui/city-autocomplete/CityPickerPopup.test.tsx b/src/ui/city-autocomplete/CityPickerPopup.test.tsx new file mode 100644 index 00000000..26958203 --- /dev/null +++ b/src/ui/city-autocomplete/CityPickerPopup.test.tsx @@ -0,0 +1,87 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { CityPickerPopup } from "./CityPickerPopup.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: "Россия и страны СНГ" } }, + { world_region_id: 1, title: { ru: "Азия" } }, + ], + countries: [ + { code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }, + { code: "CN", title: { ru: "Китай" }, world_region_id: 1 }, + ], + 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 } }, + { code: "BJS", title: { ru: "Пекин" }, country_code: "CN", has_afl_flights: true, location: { lat: 39, lon: 116 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "DME", 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 } }, + { code: "PEK", city_code: "BJS", title: { ru: "Пекин аэропорт" }, has_afl_flights: true, location: { lat: 39, lon: 116 } }, + ], +}; +const dictionaries = transformDictionaries(raw, "ru"); + +describe("CityPickerPopup", () => { + it("renders a tab button per region", () => { + render(); + expect(screen.getByText("Россия и страны СНГ")).toBeTruthy(); + expect(screen.getByText("Азия")).toBeTruthy(); + }); + + it("shows the first region's rows by default", () => { + render(); + expect(screen.getByText("Москва")).toBeTruthy(); + }); + + it("switches to another region when tab clicked", () => { + render(); + fireEvent.click(screen.getByText("Азия")); + expect(screen.getByText("Пекин")).toBeTruthy(); + }); + + it("calls onSelect with city code when city cell clicked", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Санкт-Петербург")); + expect(onSelect).toHaveBeenCalledWith("LED"); + }); + + it("calls onSelect with airport code when airport cell clicked (multi-airport city)", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Шереметьево")); + expect(onSelect).toHaveBeenCalledWith("SVO"); + }); + + it("highlights the selectedCode cell", () => { + const { container } = render( + , + ); + const activeCells = container.querySelectorAll(".city-active"); + expect(activeCells.length).toBeGreaterThan(0); + }); + + it("hides GPS button when onLocate is not provided", () => { + render(); + expect(screen.queryByTestId("city-picker-locate")).toBeNull(); + }); + + it("shows GPS button when onLocate is provided and invokes it on click", () => { + const onLocate = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("city-picker-locate")); + expect(onLocate).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/city-autocomplete/CityPickerPopup.tsx b/src/ui/city-autocomplete/CityPickerPopup.tsx new file mode 100644 index 00000000..f227d786 --- /dev/null +++ b/src/ui/city-autocomplete/CityPickerPopup.tsx @@ -0,0 +1,154 @@ +import { useState, useMemo, type FC } from "react"; +import { buildCountryCityRows } from "./buildCountryCityRows.js"; +import type { IAirport, IDictionaries } from "@/shared/dictionaries/index.js"; +import "./CityPickerPopup.scss"; + +export interface CityPickerPopupProps { + dictionaries: IDictionaries; + selectedCode?: string; + onSelect: (code: string) => void; + onLocate?: () => void | Promise; +} + +export const CityPickerPopup: FC = ({ + dictionaries, + selectedCode, + onSelect, + onLocate, +}) => { + const regions = dictionaries.regions; + const [activeRegionId, setActiveRegionId] = useState( + regions[0]?.id ?? 0, + ); + + const rows = useMemo( + () => buildCountryCityRows(dictionaries, activeRegionId), + [dictionaries, activeRegionId], + ); + + return ( +
+
+ {regions.map((r) => ( + + ))} +
+ +
+
+ {rows.map((row, idx) => { + const rowKey = `${row.countryName ?? ""}-${row.city1?.code ?? ""}-${idx}`; + const isMulti = Boolean(row.city1Airports); + return ( +
+
+
{row.countryName ?? ""}
+
+ + {!isMulti && row.city1 && ( + <> +
+
onSelect(row.city1!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code); + }} + > + {row.city1.name} +
+
+
+ {row.city2 && ( +
onSelect(row.city2!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city2!.code); + }} + > + {row.city2.name} +
+ )} +
+ + )} + + {isMulti && row.city1 && ( +
+
onSelect(row.city1!.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code); + }} + > + {row.city1.name} +
+
+ {row.city1Airports!.map((ap: IAirport) => ( +
onSelect(ap.code)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onSelect(ap.code); + }} + > + {ap.name} +
+ ))} +
+
+ )} +
+ ); + })} +
+
+ + {onLocate && ( +
+
+ +
+
+ )} +
+ ); +};