Add CityAutocomplete composite with clear and regional-picker trigger

This commit is contained in:
2026-04-17 15:06:46 +03:00
parent 419b4b8df1
commit ba302c6b03
4 changed files with 455 additions and 0 deletions
@@ -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;
}
@@ -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<string, unknown>) => (
<input
data-testid={props["data-testid"] as string}
placeholder={props["placeholder"] as string}
value={typeof props["value"] === "string" ? props["value"] : ""}
onChange={(e) => {
const onChange = props["onChange"] as ((ev: { value: string }) => void) | undefined;
onChange?.({ value: e.target.value });
}}
/>
),
}));
describe("CityAutocomplete", () => {
it("renders label and placeholder", () => {
render(
<CityAutocomplete
label="City"
placeholder="Pick one"
value=""
onChange={vi.fn()}
dictionaries={dictionaries}
/>,
);
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(
<CityAutocomplete
label="City"
placeholder="Pick"
value="MOW"
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
expect(screen.getByTestId("test-code").textContent).toBe("MOW");
});
it("opens the regional picker when the popup-trigger button is clicked", () => {
render(
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
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(
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={onChange}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
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(
<CityAutocomplete
label="City"
placeholder="Pick"
value="MOW"
onChange={onChange}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
fireEvent.click(screen.getByTestId("test-clear-button"));
expect(onChange).toHaveBeenCalledWith("");
});
it("closes the popup when clicking outside the component", () => {
render(
<div>
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>
<div data-testid="outside">outside</div>
</div>,
);
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(
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={vi.fn()}
dictionaries={dictionaries}
error="required"
testIdPrefix="test"
/>,
);
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(
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={vi.fn()}
dictionaries={null}
testIdPrefix="test"
/>,
);
fireEvent.click(screen.getByTestId("test-popup-button"));
expect(screen.queryByTestId("city-picker-popup")).toBeNull();
});
});
@@ -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<void>;
error?: string;
testIdPrefix?: string;
}
export const CityAutocomplete: FC<CityAutocompleteProps> = ({
label,
placeholder,
value,
onChange,
dictionaries,
onLocate,
error,
testIdPrefix = "city-autocomplete",
}) => {
const [inputValue, setInputValue] = useState<CitySuggestion | string>(value);
const [suggestions, setSuggestions] = useState<CitySuggestion[]>([]);
const [popupOpen, setPopupOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} className="city-autocomplete">
<div className="city-autocomplete__labels-container">
<label className="city-autocomplete__label">{label}</label>
<label className="city-autocomplete__label" data-testid={`${testIdPrefix}-code`}>
{selectedCity?.code ?? ""}
</label>
</div>
{error && (
<div className="tooltip" data-testid={`${testIdPrefix}-error`}>
{error}
</div>
)}
<div
className={`city-autocomplete__input${
hasValue ? " city-autocomplete__input--has-value" : ""
}${error ? " city-autocomplete__input--has-error" : ""}`}
>
<AutoComplete
value={inputValue}
suggestions={suggestions}
completeMethod={handleComplete}
field="name"
minLength={1}
placeholder={placeholder}
onChange={(e) => 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"
/>
<button
type="button"
className="button-clear"
onClick={handleClear}
data-testid={`${testIdPrefix}-clear-button`}
aria-label="clear"
/>
<button
type="button"
className={`city-autocomplete__search-button${
popupOpen ? " city-autocomplete__search-button--opened" : ""
}`}
onClick={() => {
if (!dictionaries) return;
setPopupOpen((v) => !v);
}}
data-testid={`${testIdPrefix}-popup-button`}
aria-label="open regional picker"
/>
</div>
{popupOpen && dictionaries && (
<div className="city-autocomplete-popup-wrapper">
<CityPickerPopup
dictionaries={dictionaries}
selectedCode={value}
onSelect={handleSelect}
{...(onLocate ? { onLocate } : {})}
/>
</div>
)}
</div>
);
};
+6
View File
@@ -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";