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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user