Extract SwapCityButton so all 3 filter blocks share the same DOM

OnlineBoard, Schedule and FlightsMap each inlined their own swap-cities
wrapper — three different class names and, in FlightsMap's case, a different
inline SVG. Angular keeps logic separate per filter (Schedule/FlightsMap
clear validation on swap, OnlineBoard doesn't) but its DOM is identical
across the three. Mirror that: ship a shared <SwapCityButton> that owns
the `.change-container > .button-change > .svg--change-city` markup and
CSS, keep each caller's onClick local.

Also align filter visuals: FlightsMapFilter row gap $space-m → $space-l to
match OnlineBoard/Schedule, and CityAutocomplete label gutter $space-s2 →
$space-m to match Angular's city-autocomplete.component.scss.
This commit is contained in:
2026-04-22 11:03:57 +03:00
parent 408afa6ab5
commit 848ba48484
17 changed files with 124 additions and 166 deletions
@@ -24,6 +24,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
placeholder={props["placeholder"] as string}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
vi.mock("@/shared/dictionaries/index.js", () => ({
@@ -11,7 +11,7 @@ import { type FC, useCallback, useEffect, useMemo, type FormEvent } from "react"
import { Calendar } from "primereact/calendar";
import { useTranslation } from "@/i18n/provider.js";
import { useLocale } from "@/i18n/useLocale.js";
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.js";
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
import { useDictionaries, findCityByCoord } from "@/shared/dictionaries/index.js";
import {
@@ -185,33 +185,11 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
testIdPrefix="fm-departure"
/>
<button
type="button"
className="flights-map-filter__exchange"
<SwapCityButton
onClick={handleExchange}
aria-label={t("SHARED.CITY_CHANGE")}
data-testid="fm-exchange-btn"
>
<svg
className="flights-map-filter__exchange-icon"
width="12"
height="25"
viewBox="0 0 12 25"
fill="#1b62b4"
aria-hidden="true"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 21.25H12L9.5 24.25L7 21.25H9V3.25H10V21.25Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 3.25H0L2.5 0.25L5 3.25H3V21.25H2V3.25Z"
/>
</svg>
</button>
ariaLabel={t("SHARED.CITY_CHANGE")}
testId="fm-exchange-btn"
/>
<CityAutocomplete
label={t("SHARED.ARRIVAL_CITY")}
@@ -169,7 +169,9 @@
padding: vars.$space-xl;
display: flex;
flex-direction: column;
gap: vars.$space-m;
// Match OnlineBoard + Schedule filter row gap ($space-l = 15px) — Angular's
// `.filter-content` uses $space-l everywhere.
gap: vars.$space-l;
.flights-map-filter-header {
padding: vars.$space-m 0;
@@ -216,32 +218,8 @@
}
}
// Mirrors Angular's `.change-container .button-change` under
// left-accordeon: a 35×40 pill sitting between the two city pickers,
// shifted up/down by $space-m so the button overlaps the two inputs
// rather than sitting in the vertical gap between them.
&__exchange {
align-self: center;
width: 35px;
height: vars.$medium-button-height;
margin-top: vars.$space-m;
margin-bottom: -(vars.$space-m);
background: colors.$white;
border: none;
box-shadow: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover { background-color: colors.$white; }
}
&__exchange-icon {
width: 12px;
height: 25px;
}
// Swap-cities button moved to shared `SwapCityButton` component —
// classes `.change-container` + `.button-change` are now global.
&__info {
padding: vars.$space-s 0;
@@ -149,37 +149,10 @@
position: relative;
}
.change-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.button-change {
margin-top: vars.$space-m;
margin-bottom: -(vars.$space-m);
display: flex;
align-items: center;
justify-content: center;
height: vars.$medium-button-height;
width: 35px;
background-color: colors.$white;
border: none;
box-shadow: none;
cursor: pointer;
&:hover {
background-color: colors.$white;
}
.svg--change-city {
width: 25px;
height: 12px;
fill: colors.$blue;
transform: rotate(90deg);
}
}
}
// Swap-cities button styling moved to `ui/city-autocomplete/SwapCityButton.scss`
// so every filter sidebar (OnlineBoard / Schedule / FlightsMap) shares the
// exact same DOM + CSS. The `.svg--change-city` sizing lives globally in
// `styles/_icons.scss`.
.wrapper--time-selector {
margin-top: vars.$space-xl;
@@ -73,6 +73,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
defaultValue={(props["value"] as string) ?? ""}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
// ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { useLocale } from "@/i18n/useLocale.js";
import { Calendar } from "primereact/calendar";
import { Slider, type SliderChangeEvent } from "primereact/slider";
import { useTranslation } from "@/i18n/provider.js";
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.js";
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { buildOnlineBoardUrl } from "../url.js";
@@ -536,18 +536,10 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
testIdPrefix="route-departure"
/>
<div className="change-container">
<button
className="button-change"
type="button"
onClick={handleExchange}
data-testid="swap-cities-button"
>
<svg className="svg--change-city">
<use xlinkHref="/assets/img/sprite.svg#changeCity" />
</svg>
</button>
</div>
<SwapCityButton
onClick={handleExchange}
testId="swap-cities-button"
/>
<CityAutocomplete
label={t("SHARED.ARRIVAL_CITY")}
@@ -93,6 +93,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
defaultValue={(props["value"] as string) ?? ""}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
// ---------------------------------------------------------------------------
@@ -41,27 +41,8 @@
width: 100%;
}
&__swap {
display: flex;
justify-content: center;
padding: vars.$space-s 0;
}
&__swap-btn {
background: transparent;
border: 0;
padding: 4px;
cursor: pointer;
color: colors.$blue;
border-radius: vars.$border-radius;
&:hover { background: colors.$blue-extra-light; }
.svg--change-city {
width: 24px;
height: 24px;
}
}
// Swap-cities button moved to the shared `SwapCityButton` component —
// classes `.change-container` + `.button-change` are now global.
&__checkbox {
display: flex;
@@ -70,6 +70,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
defaultValue={(props["value"] as string) ?? ""}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
vi.mock("@/shared/dateWindow.js", () => ({
@@ -14,7 +14,7 @@ import { Calendar } from "primereact/calendar";
import { Slider, type SliderChangeEvent } from "primereact/slider";
import { useTranslation } from "@/i18n/provider.js";
import { useLocale } from "@/i18n/useLocale.js";
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import { buildScheduleUrl } from "../url.js";
import type { ScheduleParams } from "../url.js";
@@ -315,19 +315,10 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
testIdPrefix="schedule-departure"
/>
<div className="schedule-filter__swap">
<button
type="button"
className="schedule-filter__swap-btn"
onClick={handleSwap}
aria-label="swap"
data-testid="swap-cities-button"
>
<svg className="svg--change-city">
<use xlinkHref="/assets/img/sprite.svg#changeCity" />
</svg>
</button>
</div>
<SwapCityButton
onClick={handleSwap}
testId="swap-cities-button"
/>
<CityAutocomplete
label={t("SHARED.ARRIVAL_CITY")}
@@ -355,36 +355,6 @@
padding-right: 2rem; // leave room for the trigger icon on the right
}
// Swap button — match Angular's flat style
.change-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.button-change {
margin-top: vars.$space-m;
margin-bottom: -(vars.$space-m);
display: flex;
align-items: center;
justify-content: center;
height: vars.$medium-button-height;
width: 35px;
background-color: colors.$white;
border: none;
box-shadow: none;
cursor: pointer;
&:hover {
background-color: colors.$white;
}
.svg--change-city {
width: 25px;
height: 12px;
fill: colors.$blue;
transform: rotate(90deg);
}
}
}
// Swap-cities button styling moved to the shared `SwapCityButton` component
// — classes `.change-container` + `.button-change` are now global.
}
@@ -83,6 +83,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
defaultValue={(props["value"] as string) ?? ""}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
vi.mock("@/ui/layout/SearchHistory.js", () => ({
@@ -12,7 +12,9 @@
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: vars.$space-s2;
// Matches Angular `city-autocomplete__labels-container { margin-bottom: $space-m }`
// in `components/city-autocomplete/city-autocomplete.component.scss`.
margin-bottom: vars.$space-m;
}
&__label {
@@ -0,0 +1,30 @@
@use "../../styles/colors" as colors;
@use "../../styles/variables" as vars;
// Shared swap-cities button styling for every filter block. Mirrors Angular's
// `.change-container .button-change` in `online-board-route-filter.scss`
// byte-for-byte so the three filter sidebars render an identical 35×40 pill.
.change-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.button-change {
margin-top: vars.$space-m;
margin-bottom: -(vars.$space-m);
display: flex;
align-items: center;
justify-content: center;
height: vars.$medium-button-height;
width: 35px;
background-color: colors.$white;
border: none;
box-shadow: none;
cursor: pointer;
&:hover {
background-color: colors.$white;
}
}
}
@@ -0,0 +1,43 @@
import "./SwapCityButton.scss";
/**
* Swap-cities button used between the departure and arrival CityAutocomplete
* inputs in every filter (OnlineBoard route, Schedule, FlightsMap).
*
* The DOM shape mirrors Angular's `.change-container > .button-change >
* .svg--change-city` so the global `.svg--change-city` rule in
* `styles/_icons.scss` renders the sprite identically on every page.
* Each caller keeps its own onClick handler — swap semantics differ between
* pages (Schedule clears validation, FlightsMap routes through a state
* service, OnlineBoard just swaps values), so only the DOM/CSS is shared.
*/
export interface SwapCityButtonProps {
onClick: () => void;
/** data-testid for the inner <button>. */
testId?: string;
/** Accessible label. Defaults to "swap". */
ariaLabel?: string;
}
export function SwapCityButton({
onClick,
testId,
ariaLabel = "swap",
}: SwapCityButtonProps): JSX.Element {
return (
<div className="change-container">
<button
type="button"
className="button-change"
onClick={onClick}
aria-label={ariaLabel}
data-testid={testId}
>
<svg className="svg--change-city">
<use xlinkHref="/assets/img/sprite.svg#changeCity" />
</svg>
</button>
</div>
);
}
+2
View File
@@ -2,5 +2,7 @@ export { CityAutocomplete } from "./CityAutocomplete.js";
export type { CityAutocompleteProps } from "./CityAutocomplete.js";
export { CityPickerPopup } from "./CityPickerPopup.js";
export type { CityPickerPopupProps } from "./CityPickerPopup.js";
export { SwapCityButton } from "./SwapCityButton.js";
export type { SwapCityButtonProps } from "./SwapCityButton.js";
export { buildCountryCityRows } from "./buildCountryCityRows.js";
export type { ICountryCityRow } from "./buildCountryCityRows.js";
@@ -56,6 +56,9 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
placeholder={props["placeholder"] as string}
/>
),
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
<button type="button" data-testid={props.testId} onClick={props.onClick} />
),
}));
// ---------------------------------------------------------------------------