Add FlightsMiniListItem component with Link navigation

This commit is contained in:
2026-04-16 23:26:18 +03:00
parent 58215a4bf0
commit 23fe6ae88d
3 changed files with 255 additions and 0 deletions
@@ -0,0 +1,74 @@
.mini-list {
display: flex;
flex-direction: column;
max-height: calc(100vh - 170px);
overflow-y: auto;
background: #fff;
border-radius: 8px;
&__item {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
text-decoration: none;
color: inherit;
display: block;
&:last-child {
border-bottom: none;
}
&:hover {
background: #f8f9fa;
}
&--selected {
border: 2px solid #2060c0;
border-radius: 4px;
}
}
&__flight-number {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
&__content {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows: auto auto;
gap: 8px 12px;
}
&__dep-time,
&__arr-time {
font-size: 16px;
font-weight: 500;
color: #1a3a5c;
}
&__arr-time {
text-align: right;
}
&__status-icon {
grid-column: 2;
grid-row: 1;
align-self: center;
}
&__dep-station {
grid-column: 1;
grid-row: 2;
font-size: 14px;
color: #333;
}
&__arr-station {
grid-column: 3;
grid-row: 2;
text-align: right;
font-size: 14px;
color: #333;
}
}
@@ -0,0 +1,103 @@
/**
* Tests for FlightsMiniListItem — a single row in the flights mini-list sidebar.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FlightsMiniListItem } from "./FlightsMiniListItem.js";
import type { ISimpleFlight } from "../../types.js";
vi.mock("@modern-js/runtime/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("@/i18n/provider.js", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
function makeDirectFlight(overrides: Partial<ISimpleFlight> = {}): ISimpleFlight {
return {
id: "SU0022-20260416",
routeType: "Direct" as const,
flyingTime: "1h 30m",
status: "Scheduled" as const,
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260416" },
operatingBy: {},
leg: {
arrival: {
scheduled: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" },
latest: { airport: "Pulkovo", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" },
dispatch: "", gate: "", terminal: "",
times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "09:30" } },
},
dayChange: 0,
departure: {
scheduled: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" },
latest: { airport: "Sheremetyevo", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" },
dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: 3, utc: "07:00" } },
},
equipment: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
flyingTime: "1h 30m",
index: 0,
operatingBy: {},
status: "Scheduled" as const,
updated: "",
},
...overrides,
} as ISimpleFlight;
}
describe("FlightsMiniListItem", () => {
it("renders flight number", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
});
it("renders departure and arrival times", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByText("10:00")).toBeTruthy();
expect(screen.getByText("12:30")).toBeTruthy();
});
it("renders departure and arrival station codes", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByText("SVO")).toBeTruthy();
expect(screen.getByText("LED")).toBeTruthy();
});
it("has data-testid based on flight id", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
expect(screen.getByTestId("mini-list-item-SU0022-20260416")).toBeTruthy();
});
it("applies selected modifier when isSelected", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={true} lang="ru" />);
const item = screen.getByTestId("mini-list-item-SU0022-20260416");
expect(item.className).toMatch(/--selected/);
});
it("does not apply selected modifier when not selected", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
const item = screen.getByTestId("mini-list-item-SU0022-20260416");
expect(item.className).not.toMatch(/--selected/);
});
it("link points to the flight details URL", () => {
const flight = makeDirectFlight();
render(<FlightsMiniListItem flight={flight} isSelected={false} lang="ru" />);
const link = screen.getByTestId("mini-list-item-SU0022-20260416") as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("/ru/onlineboard/SU0022-20260416");
});
});
@@ -0,0 +1,78 @@
/**
* FlightsMiniListItem — a single row in the flights mini-list sidebar.
*
* Renders a Link with flight number, departure/arrival times and station codes.
* Applies a `--selected` modifier when the item matches the currently-viewed flight.
*/
import { forwardRef } from "react";
import { Link } from "@modern-js/runtime/router";
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
import { buildOnlineBoardUrl } from "../../url.js";
import "./FlightsMiniList.scss";
export interface FlightsMiniListItemProps {
flight: ISimpleFlight;
isSelected: boolean;
lang: string;
}
/**
* Extract first-leg departure and last-leg arrival for display.
* Direct flights use the single leg; MultiLeg uses first and last.
*/
function getEndpoints(flight: ISimpleFlight): { dep: IFlightLeg["departure"]; arr: IFlightLeg["arrival"] } {
if (flight.routeType === "Direct") {
return { dep: flight.leg.departure, arr: flight.leg.arrival };
}
const firstLeg = flight.legs[0]!;
const lastLeg = flight.legs[flight.legs.length - 1]!;
return { dep: firstLeg.departure, arr: lastLeg.arrival };
}
function getDepTime(dep: IFlightLeg["departure"]): string {
return dep.times.actualBlockOff?.local ?? dep.times.scheduledDeparture.local;
}
function getArrTime(arr: IFlightLeg["arrival"]): string {
return arr.times.actualBlockOn?.local ?? arr.times.scheduledArrival.local;
}
export const FlightsMiniListItem = forwardRef<HTMLAnchorElement, FlightsMiniListItemProps>(
({ flight, isSelected, lang }, ref) => {
const { dep, arr } = getEndpoints(flight);
const href = `/${lang}/${buildOnlineBoardUrl({
type: "details",
carrier: flight.flightId.carrier,
flightNumber: flight.flightId.flightNumber,
...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}),
date: flight.flightId.date,
})}`;
const className = `mini-list__item${isSelected ? " mini-list__item--selected" : ""}`;
return (
<Link
ref={ref}
to={href}
className={className}
data-testid={`mini-list-item-${flight.id}`}
>
<div className="mini-list__flight-number">
{flight.flightId.carrier} {flight.flightId.flightNumber}
</div>
<div className="mini-list__content">
<span className="mini-list__dep-time">{getDepTime(dep)}</span>
<span className="mini-list__status-icon" aria-label={flight.status}>
{"\u2708"}
</span>
<span className="mini-list__arr-time">{getArrTime(arr)}</span>
<span className="mini-list__dep-station">{dep.scheduled.airportCode}</span>
<span className="mini-list__arr-station">{arr.scheduled.airportCode}</span>
</div>
</Link>
);
},
);
FlightsMiniListItem.displayName = "FlightsMiniListItem";