Add CityPickerPopup with regional tabs and country/city grid
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user