Add CityPickerPopup with regional tabs and country/city grid

This commit is contained in:
2026-04-17 15:03:39 +03:00
parent 6820a11e83
commit 419b4b8df1
3 changed files with 366 additions and 0 deletions
@@ -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; }
}
}
}
}
@@ -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(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
expect(screen.getByText("Россия и страны СНГ")).toBeTruthy();
expect(screen.getByText("Азия")).toBeTruthy();
});
it("shows the first region's rows by default", () => {
render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
expect(screen.getByText("Москва")).toBeTruthy();
});
it("switches to another region when tab clicked", () => {
render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
fireEvent.click(screen.getByText("Азия"));
expect(screen.getByText("Пекин")).toBeTruthy();
});
it("calls onSelect with city code when city cell clicked", () => {
const onSelect = vi.fn();
render(<CityPickerPopup dictionaries={dictionaries} onSelect={onSelect} />);
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(<CityPickerPopup dictionaries={dictionaries} onSelect={onSelect} />);
fireEvent.click(screen.getByText("Шереметьево"));
expect(onSelect).toHaveBeenCalledWith("SVO");
});
it("highlights the selectedCode cell", () => {
const { container } = render(
<CityPickerPopup dictionaries={dictionaries} selectedCode="LED" onSelect={vi.fn()} />,
);
const activeCells = container.querySelectorAll(".city-active");
expect(activeCells.length).toBeGreaterThan(0);
});
it("hides GPS button when onLocate is not provided", () => {
render(<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} />);
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(
<CityPickerPopup dictionaries={dictionaries} onSelect={vi.fn()} onLocate={onLocate} />,
);
fireEvent.click(screen.getByTestId("city-picker-locate"));
expect(onLocate).toHaveBeenCalled();
});
});
@@ -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<void>;
}
export const CityPickerPopup: FC<CityPickerPopupProps> = ({
dictionaries,
selectedCode,
onSelect,
onLocate,
}) => {
const regions = dictionaries.regions;
const [activeRegionId, setActiveRegionId] = useState<number>(
regions[0]?.id ?? 0,
);
const rows = useMemo(
() => buildCountryCityRows(dictionaries, activeRegionId),
[dictionaries, activeRegionId],
);
return (
<div className="city-autocomplete-popup" data-testid="city-picker-popup">
<div className="tabs">
{regions.map((r) => (
<button
key={r.id}
type="button"
role="tab"
aria-selected={r.id === activeRegionId}
className={`tab-button${r.id === activeRegionId ? " active" : ""}`}
onClick={() => setActiveRegionId(r.id)}
data-testid={`city-picker-tab-${r.id}`}
>
{r.name}
</button>
))}
</div>
<div className="content">
<div className="content--scroll-panel">
{rows.map((row, idx) => {
const rowKey = `${row.countryName ?? ""}-${row.city1?.code ?? ""}-${idx}`;
const isMulti = Boolean(row.city1Airports);
return (
<div
key={rowKey}
className={`row${row.countryName ? " country-start-row" : ""}`}
>
<div className="cell contry">
<div>{row.countryName ?? ""}</div>
</div>
{!isMulti && row.city1 && (
<>
<div
className={`cell city${row.city1.code === selectedCode ? " city-active" : ""}`}
data-testid={`city-name-cell-${row.city1.name}`}
>
<div
className="city--item"
role="button"
tabIndex={0}
onClick={() => onSelect(row.city1!.code)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code);
}}
>
{row.city1.name}
</div>
</div>
<div className={`cell city${row.city2?.code === selectedCode ? " city-active" : ""}`}>
{row.city2 && (
<div
className="city--item"
role="button"
tabIndex={0}
data-testid={`city-name-cell-${row.city2.name}`}
onClick={() => onSelect(row.city2!.code)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(row.city2!.code);
}}
>
{row.city2.name}
</div>
)}
</div>
</>
)}
{isMulti && row.city1 && (
<div className={`cell city${row.city1.code === selectedCode ? " city-active" : ""}`}>
<div
className="city--item"
role="button"
tabIndex={0}
data-testid={`city-name-cell-${row.city1.name}`}
onClick={() => onSelect(row.city1!.code)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(row.city1!.code);
}}
>
{row.city1.name}
</div>
<div className="airports-column">
{row.city1Airports!.map((ap: IAirport) => (
<div
key={ap.code}
title={ap.name}
className="city--item"
role="button"
tabIndex={0}
data-testid={`airport-name-cell-${ap.name}`}
onClick={() => onSelect(ap.code)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(ap.code);
}}
>
{ap.name}
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{onLocate && (
<div className="city-autocomplete-popup-footer">
<div className="gps-contaner">
<button
type="button"
className="gps-button color blue-light"
onClick={() => void onLocate()}
data-testid="city-picker-locate"
>
GPS
</button>
</div>
</div>
)}
</div>
);
};