diff --git a/src/features/online-board/components/OnlineBoardFilter.scss b/src/features/online-board/components/OnlineBoardFilter.scss index 5c5cb601..5f3bba40 100644 --- a/src/features/online-board/components/OnlineBoardFilter.scss +++ b/src/features/online-board/components/OnlineBoardFilter.scss @@ -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 diff --git a/src/features/online-board/components/OnlineBoardFilter.test.tsx b/src/features/online-board/components/OnlineBoardFilter.test.tsx new file mode 100644 index 00000000..69d3201c --- /dev/null +++ b/src/features/online-board/components/OnlineBoardFilter.test.tsx @@ -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) => { + const inputRef = props["inputRef"] as React.RefObject | undefined; + return ( + + ); + }, +})); + +// PrimeReact Slider stub +vi.mock("primereact/slider", () => ({ + Slider: () =>
, +})); + +// DayQuickPick — not needed for these tests +vi.mock("@/ui/calendar/DayQuickPick.js", () => ({ + DayQuickPick: () => null, +})); + +vi.mock("@/ui/city-autocomplete/index.js", () => ({ + CityAutocomplete: (props: Record) => ( + + ), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderFlightTab() { + render(); +} + +function renderRouteTab() { + render(); +} + +// --------------------------------------------------------------------------- +// 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(); + expect(screen.queryByTestId("flight-date-clear")).toBeTruthy(); + }); + + it("4.1.9-R8: clicking flight-date X clears the date and hides X", () => { + render(); + 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(); + 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(); + expect(screen.queryByTestId("route-date-clear")).toBeTruthy(); + }); + + it("4.1.9-R12: clicking route-date X clears the date and hides X", () => { + render(); + const clearBtn = screen.queryByTestId("route-date-clear")!; + fireEvent.click(clearBtn); + expect(screen.queryByTestId("route-date-clear")).toBeNull(); + }); +}); diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index 182210fb..4b2c70a9 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -385,17 +385,20 @@ export const OnlineBoardFilter: FC = ({ }} data-testid="flight-number-input" /> - + {flightNumber && ( + + )}
@@ -408,19 +411,32 @@ export const OnlineBoardFilter: FC = ({ locale={language} onChange={setFlightDate} /> - 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} - /> +
+ 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 && ( + + )} +
@@ -532,19 +548,32 @@ export const OnlineBoardFilter: FC = ({ locale={language} onChange={setRouteDate} /> - 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} - /> +
+ 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 && ( + + )} +
diff --git a/src/features/schedule/components/ScheduleFilter.scss b/src/features/schedule/components/ScheduleFilter.scss index e88514fd..db4d9454 100644 --- a/src/features/schedule/components/ScheduleFilter.scss +++ b/src/features/schedule/components/ScheduleFilter.scss @@ -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; } diff --git a/src/features/schedule/components/ScheduleFilter.test.tsx b/src/features/schedule/components/ScheduleFilter.test.tsx new file mode 100644 index 00000000..5a9b49d5 --- /dev/null +++ b/src/features/schedule/components/ScheduleFilter.test.tsx @@ -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) => { + const inputRef = props["inputRef"] as React.RefObject | undefined; + return ( + + ); + }, +})); + +// PrimeReact Slider stub +vi.mock("primereact/slider", () => ({ + Slider: () =>
, +})); + +vi.mock("@/ui/city-autocomplete/index.js", () => ({ + CityAutocomplete: (props: Record) => ( + + ), +})); + +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(); + expect(screen.queryByTestId("schedule-date-clear")).toBeNull(); + }); + + it("4.1.9-S2: schedule date-range X is present when both dates are provided", () => { + render( + , + ); + expect(screen.queryByTestId("schedule-date-clear")).toBeTruthy(); + }); + + it("4.1.9-S3: clicking schedule date-range X clears the range and hides X", () => { + render( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + // Time selector is present + expect(screen.queryByTestId("time-selector")).toBeTruthy(); + // But no slider clear button + expect(screen.queryByTestId("time-slider-clear")).toBeNull(); + }); +}); diff --git a/src/features/schedule/components/ScheduleFilter.tsx b/src/features/schedule/components/ScheduleFilter.tsx index d95a573e..7dc38753 100644 --- a/src/features/schedule/components/ScheduleFilter.tsx +++ b/src/features/schedule/components/ScheduleFilter.tsx @@ -304,21 +304,34 @@ export const ScheduleFilter: FC = ({ - 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 - /> +
+ 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]) && ( + + )} +
@@ -371,24 +384,37 @@ export const ScheduleFilter: FC = ({ - - 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 - /> +
+ + 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]) && ( + + )} +
diff --git a/src/ui/city-autocomplete/CityAutocomplete.test.tsx b/src/ui/city-autocomplete/CityAutocomplete.test.tsx index 65f265d2..c21b62af 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.test.tsx +++ b/src/ui/city-autocomplete/CityAutocomplete.test.tsx @@ -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( + , + ); + 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( + , + ); + expect(container.querySelector(".city-autocomplete__input--has-value")).toBeNull(); + }); + + it("4.1.9-C3: clear button has aria-label for a11y", () => { + render( + , + ); + 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(