Schedule mini-list: three-day [X-1]/[X]/[X+1] accordion (TZ §4.1.16.2)
New ScheduleFlightsMiniList groups sibling flights by scheduled departure date into three accordions. [X] (the selected flight's day) opens by default; adjacent days open only when the user clicks them. Days without any flights in the loaded context render locked and dimmed and cannot be expanded, matching TZ §4.1.16.2 R10-R21. ScheduleDetailsPage swaps the flat FlightsMiniList for this new component; the OB mini-list remains unchanged since its layout is per-day-tabs-driven and already matches §4.1.15.2.
This commit is contained in:
@@ -29,7 +29,7 @@ import { buildScheduleFlightJsonLd } from "../json-ld.js";
|
||||
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
|
||||
import { FlightSchedule } from "@/features/online-board/components/FlightSchedule/index.js";
|
||||
import { FullRouteTimeline } from "@/features/online-board/components/FullRouteTimeline/index.js";
|
||||
import { FlightsMiniList } from "@/features/online-board/components/FlightsMiniList/index.js";
|
||||
import { ScheduleFlightsMiniList } from "./ScheduleFlightsMiniList.js";
|
||||
import { DayTabs } from "@/features/online-board/components/DayTabs/index.js";
|
||||
import type { IScheduleFlightId, IFlightLeg, ISimpleFlight } from "../types.js";
|
||||
import "./ScheduleDetailsPage.scss";
|
||||
@@ -314,8 +314,9 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
breadcrumbs={breadcrumbs}
|
||||
contentLeft={
|
||||
miniListCurrentFlight ? (
|
||||
// TZ §4.1.16.2 R10-R21: schedule mini-list (desktop/tablet only)
|
||||
<FlightsMiniList
|
||||
// TZ §4.1.16.2 R10-R21: schedule mini-list grouped into
|
||||
// [X-1] / [X] / [X+1] day accordions (desktop/tablet only).
|
||||
<ScheduleFlightsMiniList
|
||||
flights={flights as unknown as ISimpleFlight[]}
|
||||
currentFlight={miniListCurrentFlight}
|
||||
lang={locale}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
@use "../../../styles/colors" as colors;
|
||||
@use "../../../styles/variables" as vars;
|
||||
@use "../../../styles/fonts" as fonts;
|
||||
|
||||
.schedule-mini-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: vars.$space-s;
|
||||
|
||||
&__day {
|
||||
border: 1px solid colors.$border;
|
||||
border-radius: vars.$border-radius;
|
||||
overflow: hidden;
|
||||
background: colors.$white;
|
||||
|
||||
&--locked {
|
||||
background: colors.$blue-extra-light;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: vars.$space-m vars.$space-l;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: colors.$blue-dark;
|
||||
font-size: fonts.$font-size-m;
|
||||
font-weight: fonts.$font-medium;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid colors.$blue;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__day-label {
|
||||
flex: 1;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__day-chevron {
|
||||
width: 10px;
|
||||
height: 6px;
|
||||
background: currentColor;
|
||||
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__day-body {
|
||||
padding: 0 vars.$space-s vars.$space-s vars.$space-s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: vars.$space-xs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment jsdom
|
||||
import { forwardRef } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ScheduleFlightsMiniList } from "./ScheduleFlightsMiniList.js";
|
||||
import type { ISimpleFlight } from "@/features/online-board/types.js";
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
Link: forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }
|
||||
>(({ children, to, ...props }, ref) => (
|
||||
<a ref={ref} href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
function flight(id: string, ymd: string): ISimpleFlight {
|
||||
const iso = `${ymd}T10:00:00`;
|
||||
const arrIso = `${ymd}T13:00:00`;
|
||||
return {
|
||||
routeType: "Direct",
|
||||
id,
|
||||
flightId: { carrier: "SU", flightNumber: "1", date: ymd.replace(/-/g, ""), suffix: "" },
|
||||
operatingBy: { scheduled: "SU" },
|
||||
flyingTime: "03:00:00",
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
dayChange: 0,
|
||||
flyingTime: "03:00:00",
|
||||
updated: "",
|
||||
equipment: {} as never,
|
||||
operatingBy: { scheduled: "SU" },
|
||||
status: "Scheduled",
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
departure: {
|
||||
scheduled: { airport: "SVO", airportCode: "SVO", city: "Moscow", cityCode: "MOW", countryCode: "RU" },
|
||||
checkingStatus: "",
|
||||
times: {
|
||||
scheduledDeparture: { local: iso, localTime: "10:00", tzOffset: 0, utc: iso, dayChange: { value: 0 } as never },
|
||||
},
|
||||
} as never,
|
||||
arrival: {
|
||||
scheduled: { airport: "LED", airportCode: "LED", city: "St Petersburg", cityCode: "LED", countryCode: "RU" },
|
||||
times: {
|
||||
scheduledArrival: { local: arrIso, localTime: "13:00", tzOffset: 0, utc: arrIso, dayChange: { value: 0 } as never },
|
||||
},
|
||||
} as never,
|
||||
} as never,
|
||||
} as unknown as ISimpleFlight;
|
||||
}
|
||||
|
||||
const wrap = (ui: React.ReactElement) => ui;
|
||||
|
||||
describe("ScheduleFlightsMiniList §4.1.16.2", () => {
|
||||
it("renders three day accordions: [X-1], [X], [X+1]", () => {
|
||||
const current = flight("a", "2026-04-22");
|
||||
render(
|
||||
wrap(
|
||||
<ScheduleFlightsMiniList
|
||||
flights={[current]}
|
||||
currentFlight={current}
|
||||
lang="en-us"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-21")).toBeTruthy();
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-22")).toBeTruthy();
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-23")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("expands [X] by default and keeps empty [X-1]/[X+1] locked", () => {
|
||||
const current = flight("a", "2026-04-22");
|
||||
render(
|
||||
wrap(
|
||||
<ScheduleFlightsMiniList
|
||||
flights={[current]}
|
||||
currentFlight={current}
|
||||
lang="en-us"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-22").className).toContain("--open");
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-21").className).toContain("--locked");
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-23").className).toContain("--locked");
|
||||
});
|
||||
|
||||
it("does not toggle a locked (empty) day on click", () => {
|
||||
const current = flight("a", "2026-04-22");
|
||||
render(
|
||||
wrap(
|
||||
<ScheduleFlightsMiniList
|
||||
flights={[current]}
|
||||
currentFlight={current}
|
||||
lang="en-us"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const header = screen
|
||||
.getByTestId("mini-list-day-2026-04-21")
|
||||
.querySelector("button");
|
||||
if (header) fireEvent.click(header);
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-21").className).not.toContain("--open");
|
||||
});
|
||||
|
||||
it("opens [X-1] when it has flights", () => {
|
||||
const current = flight("a", "2026-04-22");
|
||||
const prevDay = flight("b", "2026-04-21");
|
||||
render(
|
||||
wrap(
|
||||
<ScheduleFlightsMiniList
|
||||
flights={[prevDay, current]}
|
||||
currentFlight={current}
|
||||
lang="en-us"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
// Non-empty [X-1] is unlocked by default but closed. Clicking opens it.
|
||||
const prev = screen.getByTestId("mini-list-day-2026-04-21");
|
||||
expect(prev.className).not.toContain("--locked");
|
||||
expect(prev.className).not.toContain("--open");
|
||||
fireEvent.click(prev.querySelector("button")!);
|
||||
expect(screen.getByTestId("mini-list-day-2026-04-21").className).toContain("--open");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* TZ §4.1.16.2 — Schedule flight-card mini-list grouped by day.
|
||||
*
|
||||
* Renders three accordion sections for the days `[X-1]`, `[X]`, `[X+1]`,
|
||||
* where X is the scheduled departure date of the currently-opened
|
||||
* flight. The `[X]` accordion is open by default; `[X-1]` and `[X+1]`
|
||||
* start collapsed. Days whose group has no flights render as locked
|
||||
* and dimmed — users cannot expand them.
|
||||
*
|
||||
* Reuses `FlightsMiniListItem` from the OB mini-list to render the
|
||||
* individual rows (same layout requirement per §4.1.16.2 R13–R21).
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ISimpleFlight } from "@/features/online-board/types.js";
|
||||
import { FlightsMiniListItem } from "@/features/online-board/components/FlightsMiniList/FlightsMiniListItem.js";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "@/features/online-board/components/FlightsMiniList/FlightsMiniList.scss";
|
||||
import "./ScheduleFlightsMiniList.scss";
|
||||
|
||||
export interface ScheduleFlightsMiniListProps {
|
||||
flights: ISimpleFlight[];
|
||||
currentFlight: ISimpleFlight;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
/** Scheduled local-departure YYYY-MM-DD of the flight's primary leg. */
|
||||
function primaryDepartureYmd(f: ISimpleFlight): string | null {
|
||||
const leg = f.routeType === "Direct" ? f.leg : f.legs[0];
|
||||
const iso = leg?.departure.times.scheduledDeparture.local;
|
||||
return iso ? iso.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
function addDaysYmd(ymd: string, days: number): string | null {
|
||||
const d = new Date(`${ymd}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setDate(d.getDate() + days);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function weekdayAndDate(ymd: string, lang: string, t: (k: string) => string): string {
|
||||
const d = new Date(`${ymd}T00:00:00`);
|
||||
const weekday = d.toLocaleDateString(lang, { weekday: "long" });
|
||||
const dayNum = d.getDate();
|
||||
const monthShort = t(`MONTH-SHORT.${d.getMonth() + 1}`);
|
||||
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
|
||||
return `${weekdayCap}, ${dayNum} ${monthShort}`;
|
||||
}
|
||||
|
||||
export const ScheduleFlightsMiniList: FC<ScheduleFlightsMiniListProps> = ({
|
||||
flights,
|
||||
currentFlight,
|
||||
lang,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const itemRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
|
||||
|
||||
const currentYmd = primaryDepartureYmd(currentFlight);
|
||||
const prevYmd = currentYmd ? addDaysYmd(currentYmd, -1) : null;
|
||||
const nextYmd = currentYmd ? addDaysYmd(currentYmd, 1) : null;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const g: Record<string, ISimpleFlight[]> = {};
|
||||
for (const f of flights) {
|
||||
const d = primaryDepartureYmd(f);
|
||||
if (!d) continue;
|
||||
(g[d] ??= []).push(f);
|
||||
}
|
||||
return g;
|
||||
}, [flights]);
|
||||
|
||||
// [X] is always expanded by default. User toggles override defaults.
|
||||
const [openDays, setOpenDays] = useState<Set<string>>(
|
||||
() => new Set(currentYmd ? [currentYmd] : []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentYmd) return;
|
||||
setOpenDays((prev) => {
|
||||
if (prev.has(currentYmd)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(currentYmd);
|
||||
return next;
|
||||
});
|
||||
}, [currentYmd]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = itemRefs.current.get(currentFlight.id);
|
||||
if (selected) selected.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}, [currentFlight.id]);
|
||||
|
||||
if (!currentYmd || !prevYmd || !nextYmd) {
|
||||
// Fall back to a flat list when we can't pin the current day (e.g.
|
||||
// test fixtures with missing times). Preserves previous behavior.
|
||||
return (
|
||||
<section className="mini-list" data-testid="flights-mini-list">
|
||||
{flights.map((f) => (
|
||||
<FlightsMiniListItem
|
||||
key={f.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(f.id, el);
|
||||
else itemRefs.current.delete(f.id);
|
||||
}}
|
||||
flight={f}
|
||||
isSelected={f.id === currentFlight.id}
|
||||
lang={lang}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const days: Array<{ ymd: string; flightsOnDay: ISimpleFlight[] }> = [
|
||||
{ ymd: prevYmd, flightsOnDay: groups[prevYmd] ?? [] },
|
||||
{ ymd: currentYmd, flightsOnDay: groups[currentYmd] ?? [currentFlight] },
|
||||
{ ymd: nextYmd, flightsOnDay: groups[nextYmd] ?? [] },
|
||||
];
|
||||
|
||||
const toggle = (ymd: string, locked: boolean) => {
|
||||
if (locked) return;
|
||||
setOpenDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ymd)) next.delete(ymd);
|
||||
else next.add(ymd);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mini-list schedule-mini-list"
|
||||
data-testid="flights-mini-list"
|
||||
>
|
||||
{days.map(({ ymd, flightsOnDay }) => {
|
||||
const locked = flightsOnDay.length === 0;
|
||||
const isOpen = openDays.has(ymd) && !locked;
|
||||
return (
|
||||
<div
|
||||
key={ymd}
|
||||
className={`schedule-mini-list__day${
|
||||
locked ? " schedule-mini-list__day--locked" : ""
|
||||
}${isOpen ? " schedule-mini-list__day--open" : ""}`}
|
||||
data-day={ymd}
|
||||
data-testid={`mini-list-day-${ymd}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-mini-list__day-header"
|
||||
onClick={() => toggle(ymd, locked)}
|
||||
aria-expanded={isOpen}
|
||||
aria-disabled={locked}
|
||||
disabled={locked}
|
||||
>
|
||||
<span className="schedule-mini-list__day-label">
|
||||
{weekdayAndDate(ymd, lang, t)}
|
||||
</span>
|
||||
<span
|
||||
className={`schedule-mini-list__day-chevron${
|
||||
isOpen ? " schedule-mini-list__day-chevron--open" : ""
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="schedule-mini-list__day-body">
|
||||
{flightsOnDay.map((f) => (
|
||||
<FlightsMiniListItem
|
||||
key={f.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(f.id, el);
|
||||
else itemRefs.current.delete(f.id);
|
||||
}}
|
||||
flight={f}
|
||||
isSelected={f.id === currentFlight.id}
|
||||
lang={lang}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user