Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bc93421
feat: travel-card foundation
strandlie Jul 6, 2026
0c7ea43
feat: render refreshed trip pattern and fetch price on hover
strandlie Jun 22, 2026
e7f9538
style: remove code comments
strandlie Jun 29, 2026
73adf37
feat: add TintedMonoIcon
strandlie Jun 29, 2026
e85ade2
feat: add transport pill notification badge
strandlie Jun 29, 2026
c2a9b6c
feat: redesign travel card
strandlie Jun 29, 2026
c9d4302
refactor: replace travel-card magic numbers with design tokens
strandlie Jun 30, 2026
e7ba113
refactor: extract shared TransportNotificationBadge component
strandlie Jun 30, 2026
689ca11
feat: completely hide overflowing legs in trip pattern
strandlie Jun 30, 2026
e0a54ac
fix: drop removed shouldFetchPrice prop
strandlie Jul 6, 2026
99da356
refactor: drop vestigial shouldFetch prop on Price
strandlie Jul 6, 2026
1c52e14
fix: gate trip refresh polling to visible cards
strandlie Jul 7, 2026
d545bf8
style: apply prettier formatting
strandlie Jul 7, 2026
32e8f6e
chore: drop unused getBookingStatus import
strandlie Jul 7, 2026
591c9b7
feat: add line pill and inline walk icon in trip details
strandlie Jul 7, 2026
7641dc6
feat: show walking distance in walk sections
strandlie Jul 7, 2026
e4c182f
refactor: reuse humanizeDistance for total walk distance
strandlie Jul 7, 2026
2285c30
fix: show original times on walk-first trips from initial load
strandlie Jul 7, 2026
220ab4a
refactor: rename situations module to notifications
strandlie Jul 7, 2026
429b84c
refactor: extract getMostCriticalStatusColor into notifications
strandlie Jul 7, 2026
1e7db04
refactor: use IntersectionObserver for trip-pattern overflow
strandlie Jul 7, 2026
c797294
refactor: Refactored overflow from IntersectionObserver to ResizeObse…
strandlie Jul 8, 2026
2011767
refactor: Changed from notifications to situations-and-notices
strandlie Jul 8, 2026
73e7dfa
fix: Fixed a typo bug
strandlie Jul 8, 2026
4523fb9
refactor: Renamed StatusColorName to Statuses, equaling the app
strandlie Jul 8, 2026
5571d76
refactor: Implemented OverflowContainer like the app
strandlie Jul 8, 2026
bdd6f95
fix: Keyboard focus order in expanded trip pattern
strandlie Jul 8, 2026
d8b732e
style: fixed tripPattern gap
strandlie Jul 8, 2026
cad59a6
fix: Fixed button, map size and correct icon
strandlie Jul 8, 2026
30c3bad
fix: use TintedMonoIcon for See more button
strandlie Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/counter-box/counter-box.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.counterBox {
background-color: token('color.background.neutral.2.background');
border-radius: token('border.radius.circle');
padding: token('spacing.small') token('spacing.medium');
display: flex;
justify-content: center;
align-items: center;
}
26 changes: 26 additions & 0 deletions src/components/counter-box/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type ReactNode } from 'react';
import { Typo } from '@atb/components/typography';
import { and } from '@atb/utils/css';
import style from './counter-box.module.css';

export type CounterBoxProps = {
count: number;
notification?: ReactNode;
className?: string;
};

export function CounterBox({
count,
notification,
className,
}: CounterBoxProps) {
if (count < 1) return null;
return (
<>
<div className={and(style.counterBox, className)}>
<Typo.span textType="body__m__strong">+{count}</Typo.span>
</div>
{notification}
</>
);
}
1 change: 1 addition & 0 deletions src/components/icon/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './color-icon';
export * from './image';
export * from './mono-icon';
export * from './tinted-mono-icon';
export * from './checkbox-icon';
export type * from './generated-icons';
40 changes: 40 additions & 0 deletions src/components/icon/tinted-mono-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { CSSProperties } from 'react';
import { MonoIcons, icons } from './generated-icons';
import { SizeProps, useSize } from './utils';

export type TintedMonoIconProps = {
icon: MonoIcons;
size?: SizeProps;
className?: string;
};

export function TintedMonoIcon({
icon,
size = 'normal',
className,
}: TintedMonoIconProps) {
const wh = useSize(size);
const maskUrl = `/assets/${icons['mono'][icon].relative.replace(
'mono/',
'mono/dark/',
)}`;

const maskStyle: CSSProperties = {
display: 'inline-block',
width: wh,
height: wh,
backgroundColor: 'currentColor',
WebkitMaskImage: `url("${maskUrl}")`,
maskImage: `url("${maskUrl}")`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
maskPosition: 'center',
WebkitMaskSize: 'contain',
maskSize: 'contain',
};

return <span aria-hidden="true" className={className} style={maskStyle} />;
}

export default TintedMonoIcon;
5 changes: 3 additions & 2 deletions src/components/map/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function Map({ layer, onSelectStopPlace, ...props }: MapProps) {
}, [isMobileDevice, initializeMap]);

return (
<div className={style.map} aria-hidden="true">
<div className={style.map}>
<Button
className={style.fullscreenButton}
title={t(ComponentText.Map.map.openFullscreenButton)}
Expand All @@ -95,7 +95,7 @@ export default function Map({ layer, onSelectStopPlace, ...props }: MapProps) {
<div className={style.mapWrapper} ref={mapWrapper}>
<FocusScope
contain={isFullscreen}
restoreFocus
restoreFocus={isFullscreen}
autoFocus={isFullscreen}
>
Comment thread
strandlie marked this conversation as resolved.
<Button
Expand Down Expand Up @@ -123,6 +123,7 @@ export default function Map({ layer, onSelectStopPlace, ...props }: MapProps) {
/>
<div
ref={mapContainer}
aria-hidden="true"
className={and(
style.mapContainer,
mapLegs && style.mapContainer__borderRadius,
Expand Down
4 changes: 2 additions & 2 deletions src/components/message-box/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTranslation } from '@atb/translations';
import { StatusColorName, useTheme } from '@atb/modules/theme';
import { Statuses, useTheme } from '@atb/modules/theme';
import { andIf } from '@atb/utils/css';
import { MonoIcon, MonoIconProps } from '@atb/components/icon';
import { messageTypeToMonoIcon } from '@atb/modules/situations-and-notices';
Expand All @@ -11,7 +11,7 @@ import { colorToOverrideMode } from '@atb/utils/color';
import { HTMLAttributes } from 'react';
import Link from 'next/link';

export type MessageMode = StatusColorName;
export type MessageMode = Statuses;

export type OnClickConfig = {
text: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { OverflowContainer } from '../';

afterEach(function () {
cleanup();
});

function renderContainer(labels: string[]) {
return render(
<OverflowContainer overflow={(hiddenCount) => <span>+{hiddenCount}</span>}>
{labels.map((label) => (
<span key={label}>{label}</span>
))}
</OverflowContainer>,
);
}

describe('overflow container', function () {
it('renders all children without marking any as overflowing', () => {
const output = renderContainer(['a', 'b', 'c']);

expect(output.getByText('a')).toBeInTheDocument();
expect(output.getByText('b')).toBeInTheDocument();
expect(output.getByText('c')).toBeInTheDocument();
expect(
output.container.querySelectorAll('[data-overflowing]'),
).toHaveLength(0);
});

it('renders the worst-case overflow indicator only inside the hidden probe', () => {
const output = renderContainer(['a', 'b', 'c']);

const worstCase = output.getByText('+3');
expect(worstCase.parentElement).toHaveAttribute('aria-hidden', 'true');
});

it('renders no probe when there are no children', () => {
const output = renderContainer([]);

expect(output.container.querySelector('[aria-hidden="true"]')).toBeNull();
});
});
62 changes: 62 additions & 0 deletions src/components/overflow-container/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Children, isValidElement, type ReactNode } from 'react';
import { and } from '@atb/utils/css';
import { useOverflowingChildren } from './use-overflowing-children';
import style from './overflow-container.module.css';

export type OverflowContainerProps = {
children: ReactNode[];
overflow: (hiddenCount: number) => ReactNode;
className?: string;
};

export function OverflowContainer({
children,
overflow,
className,
}: OverflowContainerProps) {
const items = Children.toArray(children);
const keys = items.map((child, i) =>
isValidElement(child) && child.key != null ? child.key : `__idx_${i}`,
);

const {
rootRef,
overflowProbeRef,
visibleCount,
overflowIndicatorPositionLeft,
} = useOverflowingChildren(keys.join('|'));

const hasOverflow = visibleCount < items.length;
const hiddenCount = hasOverflow ? items.length - visibleCount : 0;

return (
<div className={and(style.container, className)}>
<div className={style.row} ref={rootRef}>
{items.map((child, i) => (
<div
key={keys[i]}
className={style.item}
data-overflowing={
hasOverflow && i >= visibleCount ? 'true' : undefined
}
>
{child}
</div>
))}
</div>
{hasOverflow && (
<div
className={style.indicator}
style={{ left: overflowIndicatorPositionLeft }}
>
{overflow(hiddenCount)}
</div>
)}
{items.length > 0 && (
<div className={style.probe} aria-hidden="true" ref={overflowProbeRef}>
{overflow(items.length)}
</div>
)}
</div>
);
}
41 changes: 41 additions & 0 deletions src/components/overflow-container/overflow-container.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.container {
position: relative;
min-width: 0;
}

.row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--overflow-container-gap, 0);
min-width: 0;
overflow: hidden;
}

.item {
display: flex;
flex-shrink: 0;
}

.item[data-overflowing='true'] {
visibility: hidden;
}

.indicator {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
}

.probe {
position: absolute;
top: 0;
left: 0;
width: max-content;
display: flex;
align-items: center;
visibility: hidden;
pointer-events: none;
}
71 changes: 71 additions & 0 deletions src/components/overflow-container/use-overflowing-children.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useRef, useState } from 'react';
import { useIsomorphicLayoutEffect } from '@atb/utils/use-isomorphic-layout-effect';

type OverflowState = {
visibleCount: number;

// To determine how many elements fit before overflow, we need to lay out all
// elements and measure (with some hidden if they don't fit).
// But that means that if we need an overflow indicator, another element
// already has its position in the flex flow
//
// Therefore, we absolutely position the overflow indicator at this position:
overflowIndicatorPositionLeft: number;
};

export function useOverflowingChildren(depKey: string) {
const rootRef = useRef<HTMLDivElement | null>(null);
const overflowProbeRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<OverflowState>({
visibleCount: Number.POSITIVE_INFINITY,
overflowIndicatorPositionLeft: 0,
});

useIsomorphicLayoutEffect(() => {
const root = rootRef.current;
const overflowIndicatorProbe = overflowProbeRef.current;
if (
!root ||
!overflowIndicatorProbe ||
typeof ResizeObserver === 'undefined'
)
return;

const measure = () => {
const rootRect = root.getBoundingClientRect();
if (rootRect.width === 0) return;
const rects = Array.from(root.children, (child) =>
child.getBoundingClientRect(),
);
const lastRect = rects[rects.length - 1];
let visibleCount = rects.length;
let overflowIndicatorPositionLeft = 0;
if (lastRect && lastRect.right > rootRect.right) {
const gap = parseFloat(getComputedStyle(root).columnGap) || 0;
const limit = rootRect.right - overflowIndicatorProbe.offsetWidth - gap;
visibleCount = 0;
for (const rect of rects) {
if (rect.right > limit) break;
visibleCount++;
overflowIndicatorPositionLeft = rect.right - rootRect.left + gap;
}
}
setState((previous) =>
previous.visibleCount === visibleCount &&
previous.overflowIndicatorPositionLeft === overflowIndicatorPositionLeft
? previous
: { visibleCount, overflowIndicatorPositionLeft },
);
};

const observer = new ResizeObserver(measure);
observer.observe(root);
observer.observe(overflowIndicatorProbe);
Array.from(root.children).forEach((child) => observer.observe(child));
measure();

return () => observer.disconnect();
}, [depKey]);

return { rootRef, overflowProbeRef, ...state };
}
4 changes: 2 additions & 2 deletions src/components/tag/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StatusColorName, useTheme } from '@atb/modules/theme';
import { Statuses, useTheme } from '@atb/modules/theme';
import { andIf } from '@atb/utils/css';
import { ColorIcon, MonoIcon, MonoIcons } from '@atb/components/icon';
import { messageTypeToColorIcon } from '@atb/modules/situations-and-notices';
Expand All @@ -7,7 +7,7 @@ import { Typo } from '@atb/components/typography';
import style from './tag.module.css';
import { HTMLAttributes } from 'react';

type TagStatuses = 'primary' | 'secondary' | StatusColorName;
type TagStatuses = 'primary' | 'secondary' | Statuses;

type TagSize = 'small' | 'regular';

Expand Down
23 changes: 22 additions & 1 deletion src/modules/situations-and-notices/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getAffectedStopNames } from '../utils';
import { getAffectedStopNames, getMostCriticalStatus } from '../utils';
import { SituationFragment } from '@atb/page-modules/assistant/journey-gql/trip-with-details.generated.ts';

type Affects = SituationFragment['affects'];
Expand Down Expand Up @@ -52,3 +52,24 @@ describe('getAffectedStopNames', () => {
expect(getAffectedStopNames(affects)).toEqual(['A', 'B']);
});
});

describe('getMostCriticalStatusColor', () => {
it('returns undefined for an empty list', () => {
expect(getMostCriticalStatus([])).toBeUndefined();
});

it('returns undefined when every entry is undefined', () => {
expect(getMostCriticalStatus([undefined, undefined])).toBeUndefined();
});

it('ignores undefined entries', () => {
expect(getMostCriticalStatus([undefined, 'info', undefined])).toBe('info');
});

it('picks the most severe status regardless of order', () => {
expect(getMostCriticalStatus(['valid', 'error', 'info', 'warning'])).toBe(
'error',
);
expect(getMostCriticalStatus(['info', 'warning', 'valid'])).toBe('warning');
});
});
Loading
Loading