Add CityAutocomplete composite with clear and regional-picker trigger
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user