Add clear-button (X) to filter fields per TZ 4.1.9 Tables 11/12/14

- OB flight-number: X was always visible; now conditionally rendered only
  when the field has a value (hides when empty)
- OB flight-date and route-date: add X button next to calendar icon,
  clears date state and hides itself when empty
- Schedule outbound and return date-range calendars: same inline X pattern
- CSS: .calendar-input-wrapper + .calendar-clear-btn added to both SCSS
  files (absolute-positioned left of the calendar icon)
- CityAutocomplete: already correct (CSS show/hide via has-value class)
- 21 new tests across OnlineBoardFilter, ScheduleFilter, CityAutocomplete
  (aria-label, visibility toggling, click-to-clear); all 640 pass
This commit is contained in:
2026-04-21 20:05:53 +03:00
parent 8f4d5fcaa2
commit 83951d4292
7 changed files with 608 additions and 70 deletions
@@ -276,6 +276,40 @@
// margin-top removed: vertical rhythm now driven by .filter-content gap.
}
.calendar-input-wrapper {
position: relative;
display: flex;
align-items: center;
.p-calendar {
flex: 1;
}
.calendar-clear-btn {
position: absolute;
// Place X to the left of the calendar icon (icon is ~38px wide)
right: 38px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: colors.$light-gray;
font-size: fonts.$font-size-xl;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: colors.$text-color;
}
}
}
.filter-content {
// Vertical rhythm between filter rows. Angular's accordion content
// separates fields by ~$space-l (15px); the previous default
@@ -0,0 +1,199 @@
/**
* Tests for OnlineBoardFilter — clear-button (X) behaviour per TZ §4.1.9
* Tables 11/12.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ lang: "ru-ru" }),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/i18n/useLocale.js", () => ({
useLocale: () => ({ locale: "ru-ru", language: "ru" }),
}));
vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
}));
vi.mock("@/shared/state/crossSectionNavigation.js", () => ({
setBoardFilter: vi.fn(),
}));
// PrimeReact Calendar is stubbed so tests don't require a DOM calendar widget.
vi.mock("primereact/calendar", () => ({
Calendar: (props: Record<string, unknown>) => {
const inputRef = props["inputRef"] as React.RefObject<HTMLInputElement> | undefined;
return (
<input
data-testid={props["data-testid"] as string}
placeholder={props["placeholder"] as string}
readOnly
ref={inputRef}
/>
);
},
}));
// PrimeReact Slider stub
vi.mock("primereact/slider", () => ({
Slider: () => <div data-testid="slider-stub" />,
}));
// DayQuickPick — not needed for these tests
vi.mock("@/ui/calendar/DayQuickPick.js", () => ({
DayQuickPick: () => null,
}));
vi.mock("@/ui/city-autocomplete/index.js", () => ({
CityAutocomplete: (props: Record<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
/>
),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderFlightTab() {
render(<OnlineBoardFilter initialTab="flight" />);
}
function renderRouteTab() {
render(<OnlineBoardFilter initialTab="route" />);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("OnlineBoardFilter clear-button (X) per TZ §4.1.9 Tables 11/12", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// -------------------------------------------------------------------------
// Flight-number field
// -------------------------------------------------------------------------
it("4.1.9-R1: flight-number X is hidden when input is empty", () => {
renderFlightTab();
expect(screen.queryByTestId("flight-number-clear")).toBeNull();
});
it("4.1.9-R2: flight-number X appears when a value is typed", () => {
renderFlightTab();
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "1234" },
});
expect(screen.queryByTestId("flight-number-clear")).toBeTruthy();
});
it("4.1.9-R3: clicking flight-number X clears the input and hides X", () => {
renderFlightTab();
const input = screen.getByTestId("flight-number-input");
fireEvent.change(input, { target: { value: "1234" } });
const clearBtn = screen.queryByTestId("flight-number-clear")!;
expect(clearBtn).toBeTruthy();
fireEvent.click(clearBtn);
expect((input as HTMLInputElement).value).toBe("");
expect(screen.queryByTestId("flight-number-clear")).toBeNull();
});
it("4.1.9-R4: flight-number X has aria-label for a11y", () => {
renderFlightTab();
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "999" },
});
const clearBtn = screen.queryByTestId("flight-number-clear")!;
expect(clearBtn.getAttribute("aria-label")).toBe("SHARED.A11Y-CLEAR");
});
it("4.1.9-R5: clicking X also clears any existing validation error", () => {
renderFlightTab();
// Submit with invalid number to trigger error
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "abc" },
});
fireEvent.submit(screen.getByTestId("search-form"));
expect(screen.queryByTestId("flight-number-error")).toBeTruthy();
fireEvent.click(screen.queryByTestId("flight-number-clear")!);
expect(screen.queryByTestId("flight-number-error")).toBeNull();
});
// -------------------------------------------------------------------------
// OB date (flight-tab Calendar)
// -------------------------------------------------------------------------
// Calendar is stubbed as a read-only input; the clear button is rendered
// conditionally based on the `flightDate` state (a Date | null). Because
// the Calendar stub doesn't fire an onChange, we seed the value via the
// `initialDate` + `initialTab` props which set flightDate during init.
it("4.1.9-R6: flight-date X is hidden when no date is selected", () => {
renderFlightTab();
expect(screen.queryByTestId("flight-date-clear")).toBeNull();
});
it("4.1.9-R7: flight-date X is present when an initial date is provided", () => {
render(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
expect(screen.queryByTestId("flight-date-clear")).toBeTruthy();
});
it("4.1.9-R8: clicking flight-date X clears the date and hides X", () => {
render(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
const clearBtn = screen.queryByTestId("flight-date-clear")!;
expect(clearBtn).toBeTruthy();
fireEvent.click(clearBtn);
expect(screen.queryByTestId("flight-date-clear")).toBeNull();
});
it("4.1.9-R9: flight-date clear X has aria-label for a11y", () => {
render(<OnlineBoardFilter initialTab="flight" initialDate="20260601" />);
expect(screen.queryByTestId("flight-date-clear")!.getAttribute("aria-label")).toBe(
"SHARED.A11Y-CLEAR",
);
});
// -------------------------------------------------------------------------
// Route-tab date Calendar
// -------------------------------------------------------------------------
it("4.1.9-R10: route-date X is hidden when no date is selected", () => {
renderRouteTab();
expect(screen.queryByTestId("route-date-clear")).toBeNull();
});
it("4.1.9-R11: route-date X is present when an initial date is provided", () => {
render(<OnlineBoardFilter initialTab="route" initialDate="20260601" />);
expect(screen.queryByTestId("route-date-clear")).toBeTruthy();
});
it("4.1.9-R12: clicking route-date X clears the date and hides X", () => {
render(<OnlineBoardFilter initialTab="route" initialDate="20260601" />);
const clearBtn = screen.queryByTestId("route-date-clear")!;
fireEvent.click(clearBtn);
expect(screen.queryByTestId("route-date-clear")).toBeNull();
});
});
@@ -385,17 +385,20 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
}}
data-testid="flight-number-input"
/>
<button
className="button-clear"
type="button"
onClick={() => {
setFlightNumber("");
setFlightNumberError(null);
}}
data-testid="flight-number-clear-button"
>
&times;
</button>
{flightNumber && (
<button
className="button-clear"
type="button"
aria-label={t("SHARED.A11Y-CLEAR")}
onClick={() => {
setFlightNumber("");
setFlightNumberError(null);
}}
data-testid="flight-number-clear"
>
&times;
</button>
)}
</div>
</div>
@@ -408,19 +411,32 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
locale={language}
onChange={setFlightDate}
/>
<Calendar
value={flightDate}
onChange={(e) => setFlightDate(e.value as Date | null)}
minDate={boardMinDate}
maxDate={boardMaxDate}
dateFormat="dd.mm.yy"
placeholder={t("SHARED.DATE_FORMAT")}
showIcon
className="input--filter"
data-testid="date-input"
inputId="search-date-input"
inputRef={flightDateInputRef}
/>
<div className="calendar-input-wrapper">
<Calendar
value={flightDate}
onChange={(e) => setFlightDate(e.value as Date | null)}
minDate={boardMinDate}
maxDate={boardMaxDate}
dateFormat="dd.mm.yy"
placeholder={t("SHARED.DATE_FORMAT")}
showIcon
className="input--filter"
data-testid="date-input"
inputId="search-date-input"
inputRef={flightDateInputRef}
/>
{flightDate && (
<button
type="button"
className="calendar-clear-btn"
aria-label={t("SHARED.A11Y-CLEAR")}
data-testid="flight-date-clear"
onClick={() => setFlightDate(null)}
>
&times;
</button>
)}
</div>
</div>
</div>
@@ -532,19 +548,32 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
locale={language}
onChange={setRouteDate}
/>
<Calendar
value={routeDate}
onChange={(e) => setRouteDate(e.value as Date | null)}
minDate={boardMinDate}
maxDate={boardMaxDate}
dateFormat="dd.mm.yy"
placeholder={t("SHARED.DATE_FORMAT")}
showIcon
className="input--filter"
data-testid="date-input"
inputId="route-date-input"
inputRef={routeDateInputRef}
/>
<div className="calendar-input-wrapper">
<Calendar
value={routeDate}
onChange={(e) => setRouteDate(e.value as Date | null)}
minDate={boardMinDate}
maxDate={boardMaxDate}
dateFormat="dd.mm.yy"
placeholder={t("SHARED.DATE_FORMAT")}
showIcon
className="input--filter"
data-testid="date-input"
inputId="route-date-input"
inputRef={routeDateInputRef}
/>
{routeDate && (
<button
type="button"
className="calendar-clear-btn"
aria-label={t("SHARED.A11Y-CLEAR")}
data-testid="route-date-clear"
onClick={() => setRouteDate(null)}
>
&times;
</button>
)}
</div>
</div>
</div>
@@ -104,6 +104,40 @@
font-weight: fonts.$font-medium;
}
.calendar-input-wrapper {
position: relative;
display: flex;
align-items: center;
.p-calendar {
flex: 1;
}
.calendar-clear-btn {
position: absolute;
// Place X to the left of the calendar icon (icon is ~38px wide)
right: 38px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: colors.$light-gray;
font-size: fonts.$font-size-xl;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: colors.$text-color;
}
}
}
.filter-button {
margin-top: vars.$space-l;
}
@@ -0,0 +1,172 @@
/**
* Tests for ScheduleFilter — clear-button (X) behaviour per TZ §4.1.9
* Tables 13/14.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleFilter } from "./ScheduleFilter.js";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ lang: "ru-ru" }),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/i18n/useLocale.js", () => ({
useLocale: () => ({ locale: "ru-ru", language: "ru" }),
}));
vi.mock("@/shared/dictionaries/index.js", () => ({
useDictionaries: () => ({ dictionaries: null, loading: false, error: null }),
}));
// PrimeReact Calendar stub — read-only input so state is driven by props
vi.mock("primereact/calendar", () => ({
Calendar: (props: Record<string, unknown>) => {
const inputRef = props["inputRef"] as React.RefObject<HTMLInputElement> | undefined;
return (
<input
data-testid={props["data-testid"] as string}
placeholder={props["placeholder"] as string}
readOnly
ref={inputRef}
/>
);
},
}));
// PrimeReact Slider stub
vi.mock("primereact/slider", () => ({
Slider: () => <div data-testid="slider-stub" />,
}));
vi.mock("@/ui/city-autocomplete/index.js", () => ({
CityAutocomplete: (props: Record<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
/>
),
}));
vi.mock("@/shared/dateWindow.js", () => ({
scheduleWindowBounds: () => {
const min = new Date(2026, 0, 1);
const max = new Date(2026, 11, 31);
return [min, max];
},
}));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ScheduleFilter clear-button (X) per TZ §4.1.9 Tables 13/14", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// -------------------------------------------------------------------------
// Outbound date range
// -------------------------------------------------------------------------
it("4.1.9-S1: schedule date-range X is hidden when no dates are selected", () => {
render(<ScheduleFilter />);
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
});
it("4.1.9-S2: schedule date-range X is present when both dates are provided", () => {
render(
<ScheduleFilter initialDateFrom="20260601" initialDateTo="20260607" />,
);
expect(screen.queryByTestId("schedule-date-clear")).toBeTruthy();
});
it("4.1.9-S3: clicking schedule date-range X clears the range and hides X", () => {
render(
<ScheduleFilter initialDateFrom="20260601" initialDateTo="20260607" />,
);
const clearBtn = screen.queryByTestId("schedule-date-clear")!;
fireEvent.click(clearBtn);
expect(screen.queryByTestId("schedule-date-clear")).toBeNull();
});
it("4.1.9-S4: schedule date-range clear X has aria-label for a11y", () => {
render(
<ScheduleFilter initialDateFrom="20260601" initialDateTo="20260607" />,
);
expect(
screen.queryByTestId("schedule-date-clear")!.getAttribute("aria-label"),
).toBe("SHARED.A11Y-CLEAR");
});
// -------------------------------------------------------------------------
// Return date range (only visible when `initialReturnFlights` = true)
// -------------------------------------------------------------------------
it("4.1.9-S5: return date-range X is hidden when no return dates are selected", () => {
render(<ScheduleFilter initialReturnFlights={true} />);
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
});
it("4.1.9-S6: return date-range X is present when return dates are provided", () => {
render(
<ScheduleFilter
initialReturnFlights={true}
initialReturnDateFrom="20260608"
initialReturnDateTo="20260614"
/>,
);
expect(screen.queryByTestId("schedule-return-date-clear")).toBeTruthy();
});
it("4.1.9-S7: clicking return date-range X clears the range and hides X", () => {
render(
<ScheduleFilter
initialReturnFlights={true}
initialReturnDateFrom="20260608"
initialReturnDateTo="20260614"
/>,
);
const clearBtn = screen.queryByTestId("schedule-return-date-clear")!;
fireEvent.click(clearBtn);
expect(screen.queryByTestId("schedule-return-date-clear")).toBeNull();
});
it("4.1.9-S8: return date-range clear X has aria-label for a11y", () => {
render(
<ScheduleFilter
initialReturnFlights={true}
initialReturnDateFrom="20260608"
initialReturnDateTo="20260614"
/>,
);
expect(
screen.queryByTestId("schedule-return-date-clear")!.getAttribute("aria-label"),
).toBe("SHARED.A11Y-CLEAR");
});
// -------------------------------------------------------------------------
// Sliders — no X required (TZ Table 12/14 exclusion)
// -------------------------------------------------------------------------
it("4.1.9-S9: no clear button is rendered for the departure-time slider", () => {
render(<ScheduleFilter />);
// Time selector is present
expect(screen.queryByTestId("time-selector")).toBeTruthy();
// But no slider clear button
expect(screen.queryByTestId("time-slider-clear")).toBeNull();
});
});
@@ -304,21 +304,34 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<label className="label--filter">
{t("SHARED.SCHEDULES_DATE")}
</label>
<Calendar
value={dateRange}
onChange={(e) => setDateRange((e.value as (Date | null)[]) ?? [null, null])}
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
data-testid="schedule-date-input"
inputId="schedule-date-input"
inputRef={dateRangeInputRef}
readOnlyInput
/>
<div className="calendar-input-wrapper">
<Calendar
value={dateRange}
onChange={(e) => setDateRange((e.value as (Date | null)[]) ?? [null, null])}
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
data-testid="schedule-date-input"
inputId="schedule-date-input"
inputRef={dateRangeInputRef}
readOnlyInput
/>
{(dateRange[0] || dateRange[1]) && (
<button
type="button"
className="calendar-clear-btn"
aria-label={t("SHARED.A11Y-CLEAR")}
data-testid="schedule-date-clear"
onClick={() => setDateRange([null, null])}
>
&times;
</button>
)}
</div>
</div>
<div className="wrapper--time-selector compact-view" data-testid="time-selector">
@@ -371,24 +384,37 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<label className="label--filter">
{t("SHARED.RETURN_FLIGHT_DATE")}
</label>
<Calendar
value={returnDateRange}
onChange={(e) =>
setReturnDateRange(
(e.value as (Date | null)[]) ?? [null, null],
)
}
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
data-testid="schedule-return-date-input"
inputId="schedule-return-date-input"
readOnlyInput
/>
<div className="calendar-input-wrapper">
<Calendar
value={returnDateRange}
onChange={(e) =>
setReturnDateRange(
(e.value as (Date | null)[]) ?? [null, null],
)
}
selectionMode="range"
minDate={scheduleMinDate}
maxDate={scheduleMaxDate}
dateFormat="dd.mm.yy"
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
showIcon
className="input--filter"
data-testid="schedule-return-date-input"
inputId="schedule-return-date-input"
readOnlyInput
/>
{(returnDateRange[0] || returnDateRange[1]) && (
<button
type="button"
className="calendar-clear-btn"
aria-label={t("SHARED.A11Y-CLEAR")}
data-testid="schedule-return-date-clear"
onClick={() => setReturnDateRange([null, null])}
>
&times;
</button>
)}
</div>
</div>
<div className="wrapper--time-selector compact-view" data-testid="return-time-selector">
@@ -204,6 +204,50 @@ describe("CityAutocomplete", () => {
expect(onChange).not.toHaveBeenCalled();
});
it("4.1.9-C1: has-value CSS class is applied when value is non-empty (clear button becomes visible)", () => {
const { container } = render(
<CityAutocomplete
label="City"
placeholder="Pick"
value="MOW"
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
expect(container.querySelector(".city-autocomplete__input--has-value")).toBeTruthy();
});
it("4.1.9-C2: has-value CSS class is absent when value is empty (clear button hidden)", () => {
const { container } = render(
<CityAutocomplete
label="City"
placeholder="Pick"
value=""
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
expect(container.querySelector(".city-autocomplete__input--has-value")).toBeNull();
});
it("4.1.9-C3: clear button has aria-label for a11y", () => {
render(
<CityAutocomplete
label="City"
placeholder="Pick"
value="MOW"
onChange={vi.fn()}
dictionaries={dictionaries}
testIdPrefix="test"
/>,
);
const clearBtn = screen.getByTestId("test-clear-button");
// aria-label is always rendered; visibility is controlled via CSS class
expect(clearBtn.getAttribute("aria-label")).toBeTruthy();
});
it("does not open popup when dictionaries is null", () => {
render(
<CityAutocomplete