Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8549c01
feat: build out branch buttons and store state
djpowers Aug 21, 2026
1aad16e
feat: set ref to hide branch picker as appropriate
djpowers Aug 21, 2026
e2d9758
feat: handle active and inactive branch colors
djpowers Aug 21, 2026
35caae5
feat: change branch picker selection on pill click
djpowers Aug 21, 2026
6d3fa15
style: adjust branch picker and button sizing
djpowers Aug 21, 2026
b7c5061
test: add specs for picker visiblity and coloring
djpowers Aug 24, 2026
71cb03e
style: adjust button sizing to match glides
djpowers Aug 24, 2026
b340983
fix: address typescript errors
djpowers Aug 25, 2026
3cce000
build: run npm update rail-tech-ui
djpowers Aug 26, 2026
bbb0e77
Merge branch 'main' into djp-create-branch-picker-element
djpowers Aug 26, 2026
f39853f
style: replace css variables with tailwind values
djpowers Aug 27, 2026
1e70866
feat: focus on Ashmont by default
djpowers Aug 27, 2026
c5da29c
Merge branch 'main' into djp-create-branch-picker-element
djpowers Aug 28, 2026
c13dbe2
fix: show search cars field and branch picker
djpowers Aug 28, 2026
1cd1f3b
chore: update test ID name to clarify target
djpowers Aug 28, 2026
c5e3f69
style: match branch picker to background color
djpowers Aug 28, 2026
a9e3b0e
style: ensure address bar does not block picker
djpowers Aug 28, 2026
b0c4556
test: remove pill specs merged from deleted ladder
djpowers Aug 28, 2026
dd38cd1
chore: remove testing comment
djpowers Aug 28, 2026
6fd76b5
chore: remove redundant name from props
djpowers Aug 31, 2026
1423ffc
refactor: infer selection type from branches array
djpowers Aug 31, 2026
f88907a
refactor: nest branches under route ID key
djpowers Sep 1, 2026
d7efdc8
refactor: use existing RouteID type
djpowers Sep 1, 2026
9834228
refactor: do not use pick in branch colors type
djpowers Sep 1, 2026
3b06ead
refactor: set branch for each ladder on render
djpowers Sep 8, 2026
602d4f6
Merge origin/main into djp-create-branch-picker-element
djpowers Sep 8, 2026
1507e6a
refactor: nest branches under route key
djpowers Sep 9, 2026
184ec67
refactor: make branch selections route-agnostic
djpowers Sep 9, 2026
b117bcc
refactor: update tests to include route
djpowers Sep 9, 2026
7be6942
refactor: debounce window resize check
djpowers Sep 9, 2026
0b91e85
refactor: use index-based type for storing branch
djpowers Sep 11, 2026
9528ee0
Merge branch 'main' into djp-create-branch-picker-element
djpowers Sep 11, 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
4 changes: 4 additions & 0 deletions css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
@import "tailwindcss/utilities";

/* This file is for your main application CSS */
.app-wrapper {
height: 100vh;
height: 100dvh;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highlighting my commit description: a9e3b0e

If there's a better approach here I'm open to it, but this seemed like the most direct way to ensure the branch picker was not blocked by mobile address bars.

2 changes: 1 addition & 1 deletion js/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const router = createBrowserRouter([
element: (
<DataWarningsProvider>
<AppcuesTrackPage />
<div className="flex flex-col h-screen">
<div className="flex flex-col app-wrapper">
<div className="sticky top-0 z-header">
<Banner />
<Header />
Expand Down
82 changes: 82 additions & 0 deletions js/components/ladderPageShared/branchPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export type BranchPickerSelection = "Alewife" | "Ashmont" | "Braintree";

const defaultBg =
"bg-ladder-branch-picker-inactive-bg-dark light:bg-ladder-branch-picker-inactive-bg-light";

const activeText =
"text-ladder-branch-picker-active-dot-dark light:text-ladder-branch-picker-active-dot-light";

const branchColors: Record<
BranchPickerSelection,
{ bg: string; dotText: string }
> = {
Alewife: {
bg: "bg-ladder-branch-picker-alewife-dot-dark light:bg-ladder-branch-picker-alewife-dot-light",
dotText:
"text-ladder-branch-picker-alewife-dot-dark light:text-ladder-branch-picker-alewife-dot-light",
},
Ashmont: {
bg: "bg-heavy-rail-ashmont",
dotText: "text-heavy-rail-ashmont",
},
Braintree: {
bg: "bg-heavy-rail-braintree",
dotText: "text-heavy-rail-braintree",
},
};
Comment thread
djpowers marked this conversation as resolved.
Outdated

const BranchButton = ({
branch,
isActive,
onClick,
}: {
branch: BranchPickerSelection;
isActive: boolean;
onClick: () => void;
}) => {
const { bg, dotText } = branchColors[branch];
const buttonBg = isActive ? bg : defaultBg;
const labelText = isActive ? activeText : "text-white";
const dotColor = isActive ? activeText : dotText;

return (
<button
className={`flex flex-col justify-center items-center grow ${buttonBg} rounded-t text-center font-semibold h-16 p-4 gap-1`}
onClick={onClick}
>
<div className={labelText} data-testid="branch-label">
{branch}
</div>
<div
className={`w-2 min-h-2 rounded-full bg-current ${dotColor}`}
data-testid="branch-dot"
/>
</button>
);
};

export const BranchPicker = ({
branchPickerSelection,
setBranchPickerSelection,
}: {
branchPickerSelection: BranchPickerSelection;
setBranchPickerSelection: (selection: BranchPickerSelection) => void;
Comment thread
djpowers marked this conversation as resolved.
Outdated
}) => {
return (
<div
className="flex justify-between h-14 self-center gap-1 w-full max-w-[371px]"
data-testid="branch-picker"
>
{(["Alewife", "Ashmont", "Braintree"] as const).map((branch) => (
Comment thread
djpowers marked this conversation as resolved.
Outdated
<BranchButton
key={branch}
branch={branch}
isActive={branchPickerSelection === branch}
onClick={() => {
setBranchPickerSelection(branch);
}}
/>
))}
</div>
);
};
31 changes: 29 additions & 2 deletions js/components/ladderPageShared/ladder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import { CarId, RouteId } from "../../models/common";
import { Station } from "../../models/station";
import { Vehicle } from "../../models/vehicle";
import { consistsEqual, remapLabel } from "../../util/consist";
import { BranchPickerSelection } from "./branchPicker";
import { SideBarSelection } from "./sidebar";
import { Ladder } from "rail-tech-ui";
import type { VehicleSelection } from "rail-tech-ui/dist/src/components/ladderPage/types";
import { RoutePatternId } from "rail-tech-ui/dist/src/models/route";
import type { TrainLoc } from "rail-tech-ui/dist/src/models/trainLocation";
import { ReactElement } from "react";
import { ReactElement, Ref } from "react";

const ROUTE_PATTERN_CONFIG: Readonly<
Record<RouteId, Record<RoutePatternId, { color: string; letter: string }>>
Expand Down Expand Up @@ -64,6 +65,12 @@ export type VehicleWithHeight = {
heights: TrainHeight;
};

const branchForLadder = (ladderConfig: LadderConfig): BranchPickerSelection => {
if (ladderConfig.some((s) => s.id === "place-asmnl")) return "Ashmont";
if (ladderConfig.some((s) => s.id === "place-brntn")) return "Braintree";
return "Alewife";
};

Comment thread
djpowers marked this conversation as resolved.
Outdated
// Adapt Orbit's Station (uses `location`, no `shortName`) to rail-tech-ui's
// LadderStation shape (`latLng`, requires `shortName`).
// TODO: After we remove the old ladder, this can be simplified
Expand Down Expand Up @@ -98,12 +105,16 @@ export const Ladders = ({
routeId,
sideBarSelection,
setSideBarSelection,
setBranchPickerSelection,
vehicles,
ref,
}: {
routeId: RouteId;
sideBarSelection: SideBarSelection | null;
setSideBarSelection: (selection: SideBarSelection | null) => void;
setBranchPickerSelection: (selection: BranchPickerSelection) => void;
vehicles: Vehicle[];
ref?: Ref<HTMLDivElement>;
}): ReactElement => {
const stationLists = Stations[routeId];
const vehiclesByBranch = vehicles.reduce(
Expand Down Expand Up @@ -137,6 +148,18 @@ export const Ladders = ({
),
);
if (match) {
// update branch picker to reflect the branch the clicked train is on
const matchingStationList = stationLists.find((stations) =>
stations.some((station) =>
station.stop_ids.some(
(stopId) => stopId === match.vehiclePosition.stopId,
),
),
);
if (matchingStationList) {
setBranchPickerSelection(branchForLadder(matchingStationList));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: I'm torn on this. spiritually it feels like we shouldn't have to search back through the stations to figure out what branch a train is on. is there a better way?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried a different approach in 3b06ead.

This change sets the branch for each ladder on render, so we can avoid the lookup on each click.

const sameVehicle =
sideBarSelection !== null &&
consistsEqual(
Expand All @@ -158,7 +181,11 @@ export const Ladders = ({
: null;

return (
<div className="relative flex w-full h-full justify-start min-[1485px]:justify-center overflow-x-auto snap-x snap-mandatory">
<div
ref={ref}
data-testid="ladders-scroll-container"
className="relative flex w-full h-full justify-start min-[1485px]:justify-center overflow-x-auto snap-x snap-mandatory"
>
{Array.from(vehiclesByBranch.entries()).map(
([stationList, branchVehicles], index) => (
<div
Expand Down
85 changes: 58 additions & 27 deletions js/components/ladderPageShared/ladderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { RouteId } from "../../models/common";
import { Vehicle } from "../../models/vehicle";
import { trackSideBarOpened } from "../../telemetry/trackingEvents";
import { className } from "../../util/dom";
import { BranchPicker, BranchPickerSelection } from "./branchPicker";
import { Ladders } from "./ladder";
import { SearchBar, VehicleSearchMatch } from "./search";
import { SideBar, SideBarSelection } from "./sidebar";
import { ReactElement, useCallback, useEffect, useState } from "react";
import { ReactElement, useCallback, useEffect, useRef, useState } from "react";

// Without this: each render on L17 will create a new array, causing the useEffect on L49 to run every time
const NO_VEHICLES: Vehicle[] = [];
Expand All @@ -17,7 +18,11 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
const vehicles = useVehicles() ?? NO_VEHICLES;
const [sideBarSelection, setSideBarSelection] =
useState<SideBarSelection | null>(null);
const [branchPickerSelection, setBranchPickerSelection] =
useState<BranchPickerSelection>("Ashmont");
const [searchQuery, setSearchQuery] = useState("");
const [isOverflowing, setIsOverflowing] = useState(false);
const laddersRef = useRef<HTMLDivElement>(null);

const openSideBar = useCallback(
(selection: SideBarSelection | null) => {
Expand Down Expand Up @@ -52,6 +57,19 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
};
}, [onEscape]);

useEffect(() => {
const el = laddersRef.current;
if (!el) return;
const check = () => {
setIsOverflowing(el.scrollWidth > el.clientWidth);
};
check();
window.addEventListener("resize", check);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: does this need to be debounced?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounce added in 7be6942.

return () => {
window.removeEventListener("resize", check);
};
}, []);

const onSearchMatch = useCallback(
(match: VehicleSearchMatch): boolean => {
openSideBar({ vehicle: match.vehicle, searchedCar: match.matchedCar });
Expand Down Expand Up @@ -89,31 +107,44 @@ export const LadderPage = ({ routeId }: { routeId: RouteId }): ReactElement => {
);

return (
<main className="bg-glides-blue-700 flex overflow-y-auto overflow-x-hidden justify-center">
{sideBarSelection !== null ?
<SideBar selection={sideBarSelection} close={close} />
: null}
<div
className={className([
"relative flex transition-all duration-300 ease-in-out overflow-x-auto w-full",
])}
// Close sidebar when clicking anywhere in the background
onClick={close}
>
<SearchBar
vehicles={vehicles}
query={searchQuery}
onSearchMatch={onSearchMatch}
onSearchCleared={onSearchCleared}
onQueryChange={onQueryChange}
/>
<Ladders
routeId={routeId}
vehicles={vehicles}
setSideBarSelection={openSideBarFromLadder}
sideBarSelection={sideBarSelection}
/>
</div>
</main>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<main className="bg-glides-blue-700 flex flex-1 min-h-0 overflow-y-auto overflow-x-hidden justify-center">
{sideBarSelection !== null ?
<SideBar selection={sideBarSelection} close={close} />
: null}
<div
data-testid="scroll-container"
className={className([
"relative flex transition-all duration-300 ease-in-out overflow-x-auto snap-x snap-mandatory w-full",
])}
// Close sidebar when clicking anywhere in the background
onClick={close}
>
<SearchBar
vehicles={vehicles}
query={searchQuery}
onSearchMatch={onSearchMatch}
onSearchCleared={onSearchCleared}
onQueryChange={onQueryChange}
/>
<Ladders
ref={laddersRef}
routeId={routeId}
vehicles={vehicles}
setSideBarSelection={openSideBarFromLadder}
setBranchPickerSelection={setBranchPickerSelection}
sideBarSelection={sideBarSelection}
/>
</div>
</main>
{isOverflowing && (
<div className="flex justify-center w-full bg-glides-blue-700">
<BranchPicker
branchPickerSelection={branchPickerSelection}
setBranchPickerSelection={setBranchPickerSelection}
/>
</div>
)}
</div>
);
};
Loading