Implement inline expandable flight-card details

Clicking a row on the board search results page now toggles an inline
details panel instead of immediately navigating away. The layout
matches Angular's board-flight-header:

- Aircraft model ('Sukhoi SuperJet 100') appears below the flight
  number when expanded.
- 'Время' detail row: По расписанию / Фактическое times with UTC
  offsets for both the departure and the arrival sides.
- 'Посадка' detail row: boarding status (через the
  BOARDING-STATUSES.* keys), start and end times.
- 'Детали рейса' button (blue) in the bottom-right navigates to the
  full details page.
- Active rows get a blue left border + light-blue background.
- Chevron icon on the right rotates on expand.

Wire-up: FlightCard has two new props (expandable, onViewDetails).
FlightList automatically passes expandable=true when a click handler
is provided. Added SHARED.BOARDING-START / SHARED.BOARDING-END keys
across all nine locales for the time captions.
This commit is contained in:
2026-04-18 15:36:14 +03:00
parent 916e594f06
commit 76f7acb0dd
12 changed files with 395 additions and 92 deletions
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Bitte beachten Sie:",
+3 -1
View File
@@ -416,7 +416,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Please note:",
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Nota:",
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Remarque:",
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "Attenzione:",
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "ご注意:",
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "참고:",
+3 -1
View File
@@ -416,7 +416,9 @@
"A11Y-OPEN-PICKER": "Открыть список городов",
"A11Y-BREADCRUMB": "Навигационная цепочка",
"A11Y-PREV-LEGS": "Предыдущие сегменты",
"A11Y-NEXT-LEGS": "Следующие сегменты"
"A11Y-NEXT-LEGS": "Следующие сегменты",
"BOARDING-START": "Время начала",
"BOARDING-END": "Время окончания"
},
"SMOKE": {
"HEADING": "Страница проверки"
+3 -1
View File
@@ -389,7 +389,9 @@
"A11Y-OPEN-PICKER": "Open city picker",
"A11Y-BREADCRUMB": "Breadcrumb",
"A11Y-PREV-LEGS": "Previous legs",
"A11Y-NEXT-LEGS": "Next legs"
"A11Y-NEXT-LEGS": "Next legs",
"BOARDING-START": "Start time",
"BOARDING-END": "End time"
},
"WARNING": {
"IFLY_HIGHLIGHT": "请注意:",
+120 -11
View File
@@ -4,24 +4,25 @@
@use "../../styles/screen" as screen;
.flight-card {
display: grid;
grid-template-columns: 70px 100px 80px 1fr 90px 80px 1fr;
align-items: center;
gap: 12px;
padding: 18px vars.$space-xl;
display: flex;
flex-direction: column;
background: transparent;
transition: background-color 120ms ease;
min-height: 68px;
& + & {
border-top: 1px dashed colors.$border;
}
&--clickable {
&--expanded {
background: #f3f6fb;
border-left: 3px solid colors.$blue;
}
&--clickable .flight-card__row {
cursor: pointer;
&:hover {
background-color: #eef3ff;
background-color: rgba(46, 87, 255, 0.04);
}
&:focus-visible {
@@ -30,12 +31,28 @@
}
}
&__row {
display: grid;
grid-template-columns: 70px 100px 80px 1fr 90px 80px 1fr auto;
align-items: center;
gap: 12px;
padding: 18px vars.$space-xl;
min-height: 68px;
}
&__number {
font-weight: fonts.$font-medium;
color: #222;
font-size: 14px;
}
&__aircraft {
font-size: 11px;
color: #8a8a8a;
font-weight: normal;
margin-top: 4px;
}
&__operator {
display: flex;
align-items: center;
@@ -70,17 +87,109 @@
justify-content: center;
}
@include screen.mobile {
grid-template-columns: 1fr 1fr;
&__chevron {
color: colors.$blue;
font-size: 18px;
transition: transform 150ms ease;
&--open {
transform: rotate(180deg);
}
}
&__expanded {
padding: 0 vars.$space-xl vars.$space-xl;
display: flex;
flex-direction: column;
gap: vars.$space-m;
background: #f8fafd;
}
&__detail-row {
display: grid;
grid-template-columns: 140px 1fr 1fr;
gap: vars.$space-xl;
padding: vars.$space-m 0;
border-bottom: 1px dashed #e0e6f0;
align-items: flex-start;
&:last-child {
border-bottom: none;
}
}
&__detail-label {
color: #8a8a8a;
font-size: 14px;
}
&__detail-group {
display: flex;
gap: vars.$space-xl;
flex-wrap: wrap;
> div {
display: flex;
flex-direction: column;
gap: 2px;
}
}
&__detail-caption {
font-size: 12px;
color: #8a8a8a;
}
&__detail-value {
font-weight: 500;
color: #222;
font-size: 14px;
}
&__detail-status {
color: #2457ff;
}
&__actions {
display: flex;
justify-content: flex-end;
padding-top: vars.$space-s;
}
&__details-btn {
background: colors.$blue;
color: #fff;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 150ms ease;
&:hover {
background: #1c45cc;
}
}
@include screen.mobile {
&__row {
grid-template-columns: 1fr 1fr;
gap: vars.$space-m;
}
&__operator,
&__status {
&__status,
&__chevron {
display: none;
}
&__station--arrival {
text-align: left;
}
&__detail-row {
grid-template-columns: 1fr;
}
}
}
+244 -71
View File
@@ -1,6 +1,11 @@
import type { FC, KeyboardEvent } from "react";
import { useState, type FC, type KeyboardEvent } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import { operatingCarrier } from "@/features/online-board/types.js";
import {
formatLocalTime,
formatUtcOffset,
} from "@/shared/utils/datetime/index.js";
import { StationDisplay } from "./StationDisplay.js";
import { TimeGroup } from "./TimeGroup.js";
import { FlightStatus } from "./FlightStatus.js";
@@ -9,7 +14,19 @@ import "./FlightCard.scss";
export interface FlightCardProps {
flight: ISimpleFlight;
/**
* Legacy single-click navigation handler. Kept for callers that don't
* need inline expansion (e.g. details-page summary row).
*/
onClick?: () => void;
/**
* When true, a single row click toggles an inline detail panel (time
* row, boarding row, 'Детали рейса' button) and the bottom button
* fires onViewDetails. Matches Angular's board-flight-header behaviour.
*/
expandable?: boolean;
/** Fired when the user clicks 'Детали рейса' in the expanded panel. */
onViewDetails?: () => void;
}
/** Extract the primary leg from a flight (first leg for multi-leg) */
@@ -28,13 +45,31 @@ function getFinalLeg(flight: ISimpleFlight): IFlightLeg {
return last;
}
/** Pretty "HH:mm UTC±HH:mm" string from an offset-aware ISO timestamp. */
function timeWithOffset(iso: string | undefined): string {
if (!iso) return "";
const time = formatLocalTime(iso);
const offset = formatUtcOffset(iso);
return offset ? `${time} ${offset}` : time;
}
/**
* A single flight row in search results.
*
* Matches Angular's board-flight-header layout:
* flight#+status | operator-logo | dep-time | dep-city/terminal | status-icon | arr-time | arr-city/terminal
* Header row matches Angular's board-flight-header layout:
* flight#+aircraft | operator-logo | dep-time | dep-city/terminal | status-icon | arr-time | arr-city/terminal | chevron
*
* When `expandable`, a row click toggles an inline details panel with
* scheduled/expected time rows for departure + arrival, a boarding
* status row, and a 'Детали рейса' navigation button.
*/
export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
export const FlightCard: FC<FlightCardProps> = ({
flight,
onClick,
expandable,
onViewDetails,
}) => {
const { t } = useTranslation();
const departureLeg = getPrimaryLeg(flight);
const arrivalLeg = getFinalLeg(flight);
@@ -45,82 +80,220 @@ export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
const clickable = Boolean(onClick);
const aircraftName =
departureLeg.equipment?.aircraft?.actual?.title ??
departureLeg.equipment?.aircraft?.scheduled?.title ??
null;
const [expanded, setExpanded] = useState(false);
const rowClickable = expandable || Boolean(onClick);
const toggleExpanded = (): void => {
if (expandable) {
setExpanded((v) => !v);
} else if (onClick) {
onClick();
}
};
const depScheduled = timeWithOffset(depTimes.scheduledDeparture.local);
const depActual = depTimes.actualBlockOff?.local
? timeWithOffset(depTimes.actualBlockOff.local)
: null;
const arrScheduled = timeWithOffset(arrTimes.scheduledArrival.local);
const arrActual = arrTimes.actualBlockOn?.local
? timeWithOffset(arrTimes.actualBlockOn.local)
: null;
const boarding = departureLeg.transition?.boarding;
const BOARDING_STATUS_KEY: Record<string, string> = {
Finished: "BOARDING-STATUSES.Finished",
Expected: "BOARDING-STATUSES.Expected",
InProgress: "BOARDING-STATUSES.InProgress",
Specified: "BOARDING-STATUSES.Specified",
Scheduled: "BOARDING-STATUSES.Scheduled",
};
return (
<div
className={`flight-card${clickable ? " flight-card--clickable" : ""}`}
className={`flight-card${rowClickable ? " flight-card--clickable" : ""}${expanded ? " flight-card--expanded" : ""}`}
data-flight-id={flight.id}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick,
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick!();
}
},
}
: {})}
>
<div className="flight-card__number" data-testid="flight-carrier-number">
<div>{flightNumber}</div>
<div
className="flight-card__row"
{...(rowClickable
? {
role: "button",
tabIndex: 0,
onClick: toggleExpanded,
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleExpanded();
}
},
}
: {})}
>
<div className="flight-card__number" data-testid="flight-carrier-number">
<div>{flightNumber}</div>
{expanded && aircraftName && (
<div className="flight-card__aircraft">{aircraftName}</div>
)}
</div>
<div className="flight-card__operator">
<OperatorLogo carrier={carrier} locale="ru" />
</div>
<div className="flight-card__time">
<TimeGroup
scheduled={depTimes.scheduledDeparture.local}
actual={depTimes.actualBlockOff?.local}
dayChange={
depTimes.actualBlockOff?.dayChange.value ??
depTimes.scheduledDeparture.dayChange?.value
}
/>
</div>
<div className="flight-card__station">
<StationDisplay
airportCode={depStation.scheduled.airportCode}
airportName={depStation.scheduled.airport}
cityName={depStation.scheduled.city}
{...(depStation.terminal ? { terminal: depStation.terminal } : {})}
cityFirst
/>
</div>
<div className="flight-card__status">
<FlightStatus status={flight.status} />
</div>
<div className="flight-card__time flight-card__time--arrival">
<TimeGroup
scheduled={arrTimes.scheduledArrival.local}
actual={arrTimes.actualBlockOn?.local}
dayChange={
arrTimes.actualBlockOn?.dayChange.value ??
arrTimes.scheduledArrival.dayChange?.value
}
/>
</div>
<div className="flight-card__station flight-card__station--arrival">
<StationDisplay
airportCode={arrStation.scheduled.airportCode}
airportName={arrStation.scheduled.airport}
cityName={arrStation.scheduled.city}
{...(arrStation.terminal ? { terminal: arrStation.terminal } : {})}
cityFirst
/>
</div>
{expandable && (
<div
className={`flight-card__chevron${expanded ? " flight-card__chevron--open" : ""}`}
aria-hidden="true"
>
{"\u25BE"}
</div>
)}
</div>
<div className="flight-card__operator">
<OperatorLogo carrier={carrier} locale="ru" />
</div>
{expandable && expanded && (
<div className="flight-card__expanded" data-testid="flight-card-expanded">
<div className="flight-card__detail-row">
<div className="flight-card__detail-label">{t("SHARED.TIME")}</div>
<div className="flight-card__detail-group">
<div>
<span className="flight-card__detail-caption">
{t("SHARED.SCHEDULED")}
</span>
<span className="flight-card__detail-value">{depScheduled}</span>
</div>
{depActual && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.ACTUAL")}
</span>
<span className="flight-card__detail-value">{depActual}</span>
</div>
)}
</div>
<div className="flight-card__detail-group">
<div>
<span className="flight-card__detail-caption">
{t("SHARED.SCHEDULED")}
</span>
<span className="flight-card__detail-value">{arrScheduled}</span>
</div>
{arrActual && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.ACTUAL")}
</span>
<span className="flight-card__detail-value">{arrActual}</span>
</div>
)}
</div>
</div>
<div className="flight-card__time">
<TimeGroup
scheduled={depTimes.scheduledDeparture.local}
actual={depTimes.actualBlockOff?.local}
// Prefer the actual day-offset but fall back to the scheduled one
// so scheduled-only flights still show the '+1' marker when they
// cross midnight (SU 6805 departs at 23:30 and arrives at 00:55+1).
dayChange={
depTimes.actualBlockOff?.dayChange.value ??
depTimes.scheduledDeparture.dayChange?.value
}
/>
</div>
{boarding && (
<div className="flight-card__detail-row">
<div className="flight-card__detail-label">
{t("DETAILS.BOARDING")}
</div>
<div className="flight-card__detail-group">
<div>
<span className="flight-card__detail-caption">
{t("DETAILS.STATUS")}
</span>
<span className="flight-card__detail-value flight-card__detail-status">
{t(BOARDING_STATUS_KEY[boarding.status] ?? boarding.status)}
</span>
</div>
{boarding.start?.local && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.BOARDING-START")}
</span>
<span className="flight-card__detail-value">
{formatLocalTime(boarding.start.local)}
</span>
</div>
)}
{boarding.end?.local && (
<div>
<span className="flight-card__detail-caption">
{t("SHARED.BOARDING-END")}
</span>
<span className="flight-card__detail-value">
{formatLocalTime(boarding.end.local)}
</span>
</div>
)}
</div>
</div>
)}
<div className="flight-card__station">
<StationDisplay
airportCode={depStation.scheduled.airportCode}
airportName={depStation.scheduled.airport}
cityName={depStation.scheduled.city}
{...(depStation.terminal ? { terminal: depStation.terminal } : {})}
cityFirst
/>
</div>
<div className="flight-card__status">
<FlightStatus status={flight.status} />
</div>
<div className="flight-card__time flight-card__time--arrival">
<TimeGroup
scheduled={arrTimes.scheduledArrival.local}
actual={arrTimes.actualBlockOn?.local}
dayChange={
arrTimes.actualBlockOn?.dayChange.value ??
arrTimes.scheduledArrival.dayChange?.value
}
/>
</div>
<div className="flight-card__station flight-card__station--arrival">
<StationDisplay
airportCode={arrStation.scheduled.airportCode}
airportName={arrStation.scheduled.airport}
cityName={arrStation.scheduled.city}
{...(arrStation.terminal ? { terminal: arrStation.terminal } : {})}
cityFirst
/>
</div>
{onViewDetails && (
<div className="flight-card__actions">
<button
type="button"
className="flight-card__details-btn"
data-testid="flight-details-button"
onClick={(e) => {
e.stopPropagation();
onViewDetails();
}}
>
{t("BOARD.DETAILS-TITLE")}
</button>
</div>
)}
</div>
)}
</div>
);
};
+4 -1
View File
@@ -47,7 +47,10 @@ export const FlightList: FC<FlightListProps> = ({
<FlightCard
key={flight.id}
flight={flight}
{...(onFlightClick ? { onClick: () => onFlightClick(flight) } : {})}
expandable={Boolean(onFlightClick)}
{...(onFlightClick
? { onViewDetails: () => onFlightClick(flight) }
: {})}
/>
))}
</div>