Sortable schedule columns + collapsible day accordion

Add sort arrows on ВЫЛЕТ / ВРЕМЯ В ПУТИ / ПРИЛЕТ headers — clicking
toggles ascending/descending order; clicking again clears the sort.

Day groups (Понедельник 20 апреля, etc.) are now collapsible via the
header chevron — matches Angular's p-accordion structure where each
day is an accordionTab. Default state expanded.
This commit is contained in:
2026-04-19 23:29:48 +03:00
parent 4c487ab1b2
commit d74061e03b
2 changed files with 214 additions and 22 deletions
@@ -1,3 +1,5 @@
@use "../../../styles/colors" as colors;
.day-grouped-flight-list {
display: flex;
flex-direction: column;
@@ -5,15 +7,49 @@
&__column-headers {
display: grid;
grid-template-columns: 80px 1fr 100px 120px 1fr;
gap: 12px;
padding: 8px 16px;
// Match FlightCard schedule grid column widths so labels align
// with their data columns.
grid-template-columns: 80px 120px 100px minmax(80px, 1fr) 100px 100px minmax(80px, 1fr) 16px;
gap: 16px;
padding: 14px 24px;
color: #6b7280;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e8edf3;
// The first two header labels span the first two grid columns.
> span:nth-child(1) { grid-column: 1; }
> span:nth-child(2) { grid-column: 2 / span 2; }
> span:nth-child(3) { grid-column: 4 / span 1; } // ВЫЛЕТ over dep-station
> span:nth-child(4) { grid-column: 5 / span 1; } // ВРЕМЯ В ПУТИ over duration
> span:nth-child(5) { grid-column: 7 / span 1; } // ПРИЛЕТ over arr-station
}
&__col-sortable {
display: inline-flex;
align-items: center;
gap: 6px;
}
&__sort-group {
display: inline-flex;
flex-direction: column;
gap: 1px;
}
&__sort {
background: transparent;
border: 0;
padding: 1px 2px;
cursor: pointer;
color: #b3becd;
line-height: 0;
border-radius: 2px;
&:hover { color: colors.$blue; }
&--active { color: colors.$blue; }
}
&__group {
@@ -25,11 +61,22 @@
&__header {
display: flex;
align-items: baseline;
align-items: center;
gap: 12px;
padding: 12px 18px;
background: #f6f9fd;
border-bottom: 1px solid #e8edf3;
cursor: pointer;
user-select: none;
&:hover {
background: #eef3fa;
}
&:focus-visible {
outline: 2px solid colors.$blue;
outline-offset: -2px;
}
}
&__weekday {
@@ -44,4 +91,18 @@
font-size: 18px;
font-weight: 500;
}
&__chevron {
margin-left: auto;
color: colors.$blue;
transition: transform 150ms ease;
&--collapsed {
transform: rotate(-90deg);
}
}
&__group--collapsed &__header {
border-bottom: none;
}
}
@@ -10,7 +10,7 @@
* (e.g. single-day search) — no header noise.
*/
import { type FC, useMemo } from "react";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { FlightList } from "@/ui/flights/FlightList.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
@@ -18,6 +18,15 @@ import { useLocale } from "@/i18n/useLocale.js";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import "./DayGroupedFlightList.scss";
type SortMode =
| "none"
| "departureUp"
| "departureDown"
| "timeUp"
| "timeDown"
| "arrivalUp"
| "arrivalDown";
export interface DayGroupedFlightListProps {
flights: ISimpleFlight[];
loading?: boolean;
@@ -66,6 +75,42 @@ function groupFlightsByDay(flights: ISimpleFlight[]): DayGroup[] {
});
}
/** Departure-time sort key from the primary leg (used by `departureUp/Down`). */
function depMinutes(f: ISimpleFlight): number {
const iso = getPrimaryLeg(f).departure.times.scheduledDeparture.local ?? "";
const t = iso.slice(11, 16); // 'HH:mm'
if (!t) return 0;
const [h, m] = t.split(":").map((s) => parseInt(s, 10));
return (h ?? 0) * 60 + (m ?? 0);
}
function arrMinutes(f: ISimpleFlight): number {
const last = f.routeType === "Direct" ? f.leg : f.legs[f.legs.length - 1]!;
const iso = last.arrival.times.scheduledArrival.local ?? "";
const t = iso.slice(11, 16);
if (!t) return 0;
const [h, m] = t.split(":").map((s) => parseInt(s, 10));
return (h ?? 0) * 60 + (m ?? 0);
}
function flyingMinutes(f: ISimpleFlight): number {
const ft = f.flyingTime ?? "";
const m = /^(\d+):(\d+)/.exec(ft);
if (!m) return 0;
return parseInt(m[1]!, 10) * 60 + parseInt(m[2]!, 10);
}
function sortFlights(flights: ISimpleFlight[], mode: SortMode): ISimpleFlight[] {
if (mode === "none") return flights;
const arr = flights.slice();
switch (mode) {
case "departureUp": return arr.sort((a, b) => depMinutes(a) - depMinutes(b));
case "departureDown": return arr.sort((a, b) => depMinutes(b) - depMinutes(a));
case "timeUp": return arr.sort((a, b) => flyingMinutes(a) - flyingMinutes(b));
case "timeDown": return arr.sort((a, b) => flyingMinutes(b) - flyingMinutes(a));
case "arrivalUp": return arr.sort((a, b) => arrMinutes(a) - arrMinutes(b));
case "arrivalDown": return arr.sort((a, b) => arrMinutes(b) - arrMinutes(a));
}
}
export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
flights,
loading,
@@ -74,11 +119,43 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
}) => {
const { language } = useLocale();
const { t } = useTranslation();
const groups = useMemo(() => groupFlightsByDay(flights), [flights]);
const [sortMode, setSortMode] = useState<SortMode>("none");
// Track collapsed days. Default: every day group expanded — matches
// Angular which auto-opens days on initial render. User can collapse
// individual days by clicking the header chevron.
const [collapsedDays, setCollapsedDays] = useState<Set<string>>(() => new Set());
const groups = useMemo(() => {
const g = groupFlightsByDay(flights);
return g.map((day) => ({ ...day, flights: sortFlights(day.flights, sortMode) }));
}, [flights, sortMode]);
const setSort = (mode: SortMode) => {
setSortMode((cur) => (cur === mode ? "none" : mode));
};
/** Angular's schedule renders a sortable column header row above the
* list. We render the same labels as a non-sortable bar — sortable
* headers are tracked separately. Skipped while loading or empty. */
* list. Click an arrow to toggle ascending/descending; clicking the
* same arrow again clears the sort. */
const sortBtn = (mode: SortMode, dir: "up" | "down") => (
<button
type="button"
className={`day-grouped-flight-list__sort day-grouped-flight-list__sort--${dir}${
sortMode === mode ? " day-grouped-flight-list__sort--active" : ""
}`}
onClick={() => setSort(mode)}
aria-label={`${dir === "up" ? "↑" : "↓"} ${mode}`}
data-testid={`schedule-sort-${mode}`}
>
<svg viewBox="0 0 8 6" width="8" height="6" aria-hidden="true">
{dir === "up" ? (
<path d="M4 0L8 6L0 6Z" fill="currentColor" />
) : (
<path d="M4 6L0 0L8 0Z" fill="currentColor" />
)}
</svg>
</button>
);
const headerRow = (
<div
className="day-grouped-flight-list__column-headers"
@@ -86,9 +163,27 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
>
<span>{t("SCHEDULE.COL-FLIGHT") || "РЕЙС"}</span>
<span>{t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"}</span>
<span>{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}</span>
<span>{t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"}</span>
<span>{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}</span>
<span className="day-grouped-flight-list__col-sortable">
{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}
<span className="day-grouped-flight-list__sort-group">
{sortBtn("departureUp", "up")}
{sortBtn("departureDown", "down")}
</span>
</span>
<span className="day-grouped-flight-list__col-sortable">
{t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"}
<span className="day-grouped-flight-list__sort-group">
{sortBtn("timeUp", "up")}
{sortBtn("timeDown", "down")}
</span>
</span>
<span className="day-grouped-flight-list__col-sortable">
{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}
<span className="day-grouped-flight-list__sort-group">
{sortBtn("arrivalUp", "up")}
{sortBtn("arrivalDown", "down")}
</span>
</span>
</div>
);
@@ -122,33 +217,69 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
month: "long",
});
const toggleDay = (date: string): void => {
setCollapsedDays((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
};
return (
<div className="day-grouped-flight-list" data-testid="day-grouped-flight-list">
{headerRow}
{groups.map((g) => {
const weekday = weekdayFmt.format(g.parsed);
const dayMonth = dayMonthFmt.format(g.parsed);
const collapsed = collapsedDays.has(g.date);
return (
<section
key={g.date}
className="day-grouped-flight-list__group"
className={`day-grouped-flight-list__group${
collapsed ? " day-grouped-flight-list__group--collapsed" : ""
}`}
data-day={g.date}
>
<header className="day-grouped-flight-list__header">
<header
className="day-grouped-flight-list__header"
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => toggleDay(g.date)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleDay(g.date);
}
}}
>
<span className="day-grouped-flight-list__weekday">
{weekday.charAt(0).toUpperCase() + weekday.slice(1)}
</span>
<h3 className="day-grouped-flight-list__date">{dayMonth}</h3>
<span
className={`day-grouped-flight-list__chevron${
collapsed ? " day-grouped-flight-list__chevron--collapsed" : ""
}`}
aria-hidden="true"
>
<svg viewBox="0 0 12 8" width="12" height="8">
<path d="M1 1L6 6L11 1" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</span>
</header>
<FlightList
flights={g.flights}
loading={false}
direction="schedule"
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId
? { initialCurrentFlightId }
: {})}
/>
{!collapsed && (
<FlightList
flights={g.flights}
loading={false}
direction="schedule"
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId
? { initialCurrentFlightId }
: {})}
/>
)}
</section>
);
})}