Add FlightsMiniList container with scroll-into-view behavior

This commit is contained in:
2026-04-16 23:28:36 +03:00
parent 23fe6ae88d
commit bfe14012c7
3 changed files with 174 additions and 0 deletions
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { forwardRef } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FlightsMiniList } from "./FlightsMiniList.js";
import type { ISimpleFlight } from "../../types.js";
// Match the router mock convention used across the project.
// Forward the ref so parent-held refs to list items still resolve to DOM nodes.
vi.mock("@modern-js/runtime/router", () => ({
Link: forwardRef<HTMLAnchorElement, { children: React.ReactNode; to: string; [k: string]: unknown }>(
({ children, to, ...props }, ref) => (
<a ref={ref} href={to} {...props}>{children}</a>
),
),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// Mock scrollIntoView (not implemented in jsdom)
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
});
function makeFlight(id: string, date = "20260416"): ISimpleFlight {
return {
id,
routeType: "Direct" as const,
flyingTime: "1h",
status: "Scheduled" as const,
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date },
operatingBy: {},
leg: {
arrival: {
scheduled: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" },
latest: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" },
dispatch: "", gate: "", terminal: "",
times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "", tzOffset: 0, utc: "" } },
},
dayChange: 0,
departure: {
scheduled: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" },
latest: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" },
dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "", tzOffset: 0, utc: "" } },
},
equipment: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
flyingTime: "1h",
index: 0,
operatingBy: {},
status: "Scheduled" as const,
updated: "",
},
};
}
describe("FlightsMiniList", () => {
it("returns null when flights array is empty", () => {
const current = makeFlight("SU0022-20260416");
const { container } = render(
<FlightsMiniList flights={[]} currentFlight={current} lang="ru" />,
);
expect(container.firstChild).toBeNull();
});
it("returns null when flights array has only one flight", () => {
const current = makeFlight("SU0022-20260416");
const { container } = render(
<FlightsMiniList flights={[current]} currentFlight={current} lang="ru" />,
);
expect(container.firstChild).toBeNull();
});
it("renders one item per flight when multiple flights", () => {
const flights = [
makeFlight("SU0022-20260416", "20260416"),
makeFlight("SU0022-20260417", "20260417"),
makeFlight("SU0022-20260418", "20260418"),
];
render(<FlightsMiniList flights={flights} currentFlight={flights[0]!} lang="ru" />);
expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy();
expect(screen.getByTestId("mini-list-item-SU0022-20260417")).toBeTruthy();
expect(screen.getByTestId("mini-list-item-SU0022-20260418")).toBeTruthy();
});
it("highlights the item matching currentFlight.id", () => {
const flights = [
makeFlight("SU0022-20260416", "20260416"),
makeFlight("SU0022-20260417", "20260417"),
];
render(<FlightsMiniList flights={flights} currentFlight={flights[1]!} lang="ru" />);
const selected = screen.getByTestId("mini-list-item-SU0022-20260417");
const notSelected = screen.getByTestId("mini-list-item-SU0022-20260416");
expect(selected.className).toMatch(/--selected/);
expect(notSelected.className).not.toMatch(/--selected/);
});
it("calls scrollIntoView on the selected item after mount", async () => {
const flights = [
makeFlight("SU0022-20260416", "20260416"),
makeFlight("SU0022-20260417", "20260417"),
];
render(<FlightsMiniList flights={flights} currentFlight={flights[1]!} lang="ru" />);
await Promise.resolve();
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ block: "center", behavior: "smooth" }),
);
});
it("has data-testid on the container", () => {
const flights = [
makeFlight("SU0022-20260416", "20260416"),
makeFlight("SU0022-20260417", "20260417"),
];
render(<FlightsMiniList flights={flights} currentFlight={flights[0]!} lang="ru" />);
expect(screen.getByTestId("flights-mini-list")).toBeTruthy();
});
});
@@ -0,0 +1,49 @@
import { type FC, useEffect, useRef } from "react";
import type { ISimpleFlight } from "../../types.js";
import { FlightsMiniListItem } from "./FlightsMiniListItem.js";
import "./FlightsMiniList.scss";
export interface FlightsMiniListProps {
flights: ISimpleFlight[];
currentFlight: ISimpleFlight;
lang: string;
}
export const FlightsMiniList: FC<FlightsMiniListProps> = ({
flights,
currentFlight,
lang,
}) => {
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
useEffect(() => {
const selected = itemRefs.current.get(currentFlight.id);
if (selected) {
selected.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [currentFlight.id]);
if (flights.length <= 1) {
return null;
}
return (
<section className="mini-list" data-testid="flights-mini-list">
{flights.map((flight) => (
<FlightsMiniListItem
key={flight.id}
ref={(el) => {
if (el) {
itemRefs.current.set(flight.id, el);
} else {
itemRefs.current.delete(flight.id);
}
}}
flight={flight}
isSelected={flight.id === currentFlight.id}
lang={lang}
/>
))}
</section>
);
};
@@ -0,0 +1,4 @@
export { FlightsMiniList } from "./FlightsMiniList.js";
export type { FlightsMiniListProps } from "./FlightsMiniList.js";
export { FlightsMiniListItem } from "./FlightsMiniListItem.js";
export type { FlightsMiniListItemProps } from "./FlightsMiniListItem.js";