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. // 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 { .filter-content {
// Vertical rhythm between filter rows. Angular's accordion content // Vertical rhythm between filter rows. Angular's accordion content
// separates fields by ~$space-l (15px); the previous default // 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" data-testid="flight-number-input"
/> />
<button {flightNumber && (
className="button-clear" <button
type="button" className="button-clear"
onClick={() => { type="button"
setFlightNumber(""); aria-label={t("SHARED.A11Y-CLEAR")}
setFlightNumberError(null); onClick={() => {
}} setFlightNumber("");
data-testid="flight-number-clear-button" setFlightNumberError(null);
> }}
&times; data-testid="flight-number-clear"
</button> >
&times;
</button>
)}
</div> </div>
</div> </div>
@@ -408,19 +411,32 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
locale={language} locale={language}
onChange={setFlightDate} onChange={setFlightDate}
/> />
<Calendar <div className="calendar-input-wrapper">
value={flightDate} <Calendar
onChange={(e) => setFlightDate(e.value as Date | null)} value={flightDate}
minDate={boardMinDate} onChange={(e) => setFlightDate(e.value as Date | null)}
maxDate={boardMaxDate} minDate={boardMinDate}
dateFormat="dd.mm.yy" maxDate={boardMaxDate}
placeholder={t("SHARED.DATE_FORMAT")} dateFormat="dd.mm.yy"
showIcon placeholder={t("SHARED.DATE_FORMAT")}
className="input--filter" showIcon
data-testid="date-input" className="input--filter"
inputId="search-date-input" data-testid="date-input"
inputRef={flightDateInputRef} 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>
</div> </div>
@@ -532,19 +548,32 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
locale={language} locale={language}
onChange={setRouteDate} onChange={setRouteDate}
/> />
<Calendar <div className="calendar-input-wrapper">
value={routeDate} <Calendar
onChange={(e) => setRouteDate(e.value as Date | null)} value={routeDate}
minDate={boardMinDate} onChange={(e) => setRouteDate(e.value as Date | null)}
maxDate={boardMaxDate} minDate={boardMinDate}
dateFormat="dd.mm.yy" maxDate={boardMaxDate}
placeholder={t("SHARED.DATE_FORMAT")} dateFormat="dd.mm.yy"
showIcon placeholder={t("SHARED.DATE_FORMAT")}
className="input--filter" showIcon
data-testid="date-input" className="input--filter"
inputId="route-date-input" data-testid="date-input"
inputRef={routeDateInputRef} 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>
</div> </div>
@@ -104,6 +104,40 @@
font-weight: fonts.$font-medium; 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 { .filter-button {
margin-top: vars.$space-l; 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"> <label className="label--filter">
{t("SHARED.SCHEDULES_DATE")} {t("SHARED.SCHEDULES_DATE")}
</label> </label>
<Calendar <div className="calendar-input-wrapper">
value={dateRange} <Calendar
onChange={(e) => setDateRange((e.value as (Date | null)[]) ?? [null, null])} value={dateRange}
selectionMode="range" onChange={(e) => setDateRange((e.value as (Date | null)[]) ?? [null, null])}
minDate={scheduleMinDate} selectionMode="range"
maxDate={scheduleMaxDate} minDate={scheduleMinDate}
dateFormat="dd.mm.yy" maxDate={scheduleMaxDate}
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} dateFormat="dd.mm.yy"
showIcon placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
className="input--filter" showIcon
data-testid="schedule-date-input" className="input--filter"
inputId="schedule-date-input" data-testid="schedule-date-input"
inputRef={dateRangeInputRef} inputId="schedule-date-input"
readOnlyInput 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>
<div className="wrapper--time-selector compact-view" data-testid="time-selector"> <div className="wrapper--time-selector compact-view" data-testid="time-selector">
@@ -371,24 +384,37 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
<label className="label--filter"> <label className="label--filter">
{t("SHARED.RETURN_FLIGHT_DATE")} {t("SHARED.RETURN_FLIGHT_DATE")}
</label> </label>
<Calendar <div className="calendar-input-wrapper">
value={returnDateRange} <Calendar
onChange={(e) => value={returnDateRange}
setReturnDateRange( onChange={(e) =>
(e.value as (Date | null)[]) ?? [null, null], setReturnDateRange(
) (e.value as (Date | null)[]) ?? [null, null],
} )
selectionMode="range" }
minDate={scheduleMinDate} selectionMode="range"
maxDate={scheduleMaxDate} minDate={scheduleMinDate}
dateFormat="dd.mm.yy" maxDate={scheduleMaxDate}
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} dateFormat="dd.mm.yy"
showIcon placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
className="input--filter" showIcon
data-testid="schedule-return-date-input" className="input--filter"
inputId="schedule-return-date-input" data-testid="schedule-return-date-input"
readOnlyInput 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>
<div className="wrapper--time-selector compact-view" data-testid="return-time-selector"> <div className="wrapper--time-selector compact-view" data-testid="return-time-selector">
@@ -204,6 +204,50 @@ describe("CityAutocomplete", () => {
expect(onChange).not.toHaveBeenCalled(); 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", () => { it("does not open popup when dictionaries is null", () => {
render( render(
<CityAutocomplete <CityAutocomplete