From 8779529efb181bd88a9b966d118ae3dbd2f47593 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 24 Sep 2026 10:58:49 +0200 Subject: [PATCH] feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops Along the route: once kinds are picked, the closed "Looking for" control shows them by their coloured icons alone, and names them in its tooltip and to a screen reader; the names stay in the list. The day the search runs along moved out of the header into the same line, as a dropdown of the same shape, so the question reads as one: these kinds, along this day. Both share one small module for the shape and for closing on a click outside or Escape. Pulled narrow, the road trip rail drops the stop count from its head, with the hairline beside it, and from every day header, and keeps distance and time. --- .../Roadtrip/RoadtripCategoryPicker.test.tsx | 19 ++- .../Roadtrip/RoadtripCategoryPicker.tsx | 118 ++++++++---------- .../Roadtrip/RoadtripCorridorPanel.test.tsx | 23 +++- .../Roadtrip/RoadtripCorridorPanel.tsx | 53 ++++---- .../Roadtrip/RoadtripDayPicker.test.tsx | 53 ++++++++ .../components/Roadtrip/RoadtripDayPicker.tsx | 75 +++++++++++ .../Roadtrip/RoadtripSidebar.test.tsx | 26 +++- .../components/Roadtrip/RoadtripSidebar.tsx | 27 +++- .../src/components/Roadtrip/railDropdown.ts | 39 ++++++ 9 files changed, 323 insertions(+), 110 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripDayPicker.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripDayPicker.tsx create mode 100644 client/src/components/Roadtrip/railDropdown.ts diff --git a/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx b/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx index 9c7117392..5115022e2 100644 --- a/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx @@ -5,12 +5,12 @@ import { fireEvent, render, screen, within } from '../../../tests/helpers/render import RoadtripCategoryPicker from './RoadtripCategoryPicker' /** - * FE-CATPICK-001..008 — what the corridor is asked to look for. + * FE-CATPICK-001..009 — what the corridor is asked to look for. * * Multi-select, so it stays open on a click: picking "fuel and charging" is one - * gesture rather than two round trips through a menu. The summary names the - * kinds rather than counting them, because "Fuel, Charging" is the answer and - * "2 kinds" is a riddle. + * gesture rather than two round trips through a menu. Closed, it draws the picked + * kinds as icons only and names them to a screen reader and in its tooltip, + * because "Fuel, Charging" is the answer and "2 kinds" is a riddle. */ const KEYS = ['fuel', 'charging', 'food'] as const @@ -40,11 +40,18 @@ describe('RoadtripCategoryPicker', () => { expect(screen.getByText('Looking for')).toBeInTheDocument() }) - it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => { + it('FE-CATPICK-003: closed, it names the kinds in the order they are offered, without printing them', () => { // Reversed on the way in: the control lists them the way it offers them, not // the way they happened to be clicked. picker(['food', 'fuel']) - expect(screen.getByText('Fuel, Food')).toBeInTheDocument() + expect(trigger()).toHaveAccessibleName('Looking for: Fuel, Food') + expect(screen.queryByText('Fuel, Food')).toBeNull() + expect(screen.queryByText('Fuel')).toBeNull() + }) + + it('FE-CATPICK-009: closed, every picked kind shows as its icon, however many there are', () => { + picker(['food', 'fuel', 'charging']) + expect(trigger().querySelectorAll('span.rounded-md svg')).toHaveLength(3) }) it('FE-CATPICK-004: opening lists every kind', () => { diff --git a/client/src/components/Roadtrip/RoadtripCategoryPicker.tsx b/client/src/components/Roadtrip/RoadtripCategoryPicker.tsx index 6fd0d95f0..21e9aa43e 100644 --- a/client/src/components/Roadtrip/RoadtripCategoryPicker.tsx +++ b/client/src/components/Roadtrip/RoadtripCategoryPicker.tsx @@ -1,6 +1,8 @@ -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' import { Check, ChevronDown, type LucideIcon } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' +import { Tooltip } from '../shared/Tooltip' +import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown' import { serviceColor } from './roadtripModel' import { FS } from './typeScale' @@ -13,82 +15,68 @@ interface RoadtripCategoryPickerProps { } /** - * What to look for, as one full-width control. + * What to look for, as one line. * * Six pills wrapped onto three lines and took the height of the whole card for a set of * answers that rarely changes. Folded into a dropdown it is one line, and the space goes - * to the results — which is what the column is actually for. + * to the results, which is what the column is actually for. + * + * Closed, it shows the picked kinds by their coloured icons alone, the same ones the rail + * puts on its dashed line and the map puts on the route; the names are in the list and in + * the tooltip. That leaves the line room for the day picker beside it. * * Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture - * rather than two round trips through a menu. Each row leads with its own coloured icon, - * the same one the rail puts on its dashed line and the map puts on the route. + * rather than two round trips through a menu. */ export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement { const { t } = useTranslation() - const [open, setOpen] = useState(false) - const ref = useRef(null) - - useEffect(() => { - if (!open) return - const onPointer = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } - document.addEventListener('mousedown', onPointer) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onPointer) - document.removeEventListener('keydown', onKey) - } - }, [open]) + const { open, setOpen, ref } = useDropdown() + const picked = keys.filter(k => selected.includes(k)) // The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds" - // is a riddle. Truncated when it runs long, which is what the icons in front are for. - const summary = selected.length - ? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ') - : t('roadtrip.poi.looking') + // is a riddle. Read out and shown on hover, since the line only draws their icons. + const names = picked.map(k => t(meta[k].labelKey)).join(', ') return (
- + + + {open ? ( -
+
{keys.map(key => { const { labelKey, Icon } = meta[key] const on = selected.includes(key) @@ -101,15 +89,15 @@ export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle // Stays open: picking two kinds is one gesture, not two. onClick={() => onToggle(key)} style={{ fontSize: FS.control }} - className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover" + className={DROPDOWN_ROW} > - + {t(labelKey)} {on ? : null} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index 3129b6b45..3755ff8b1 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -138,6 +138,25 @@ const searchRow = (): HTMLButtonElement[] => { } describe('RoadtripCorridorPanel', () => { + it('FE-ROADTRIP-PANEL-036: the day sits beside what is looked for, not in the header, and picking one moves the search', () => { + const c = corridor() + wrap() + const dayTrigger = screen.getByText('Day 1').closest('button')! + expect(dayTrigger.closest('header, h2')).toBeNull() + // One line: the kinds and the day share the row under "Looking for". + const row = dayTrigger.parentElement!.parentElement! + expect(row.querySelector('[aria-haspopup="listbox"]')).not.toBe(dayTrigger) + expect(row.querySelectorAll('[aria-haspopup="listbox"]')).toHaveLength(2) + fireEvent.click(dayTrigger) + fireEvent.click(screen.getByRole('option', { name: /Day 2/ })) + expect(c.setDayId).toHaveBeenCalledWith('2') + }) + + it('FE-ROADTRIP-PANEL-037: a trip of one day offers no day to pick', () => { + wrap() + expect(screen.queryByText('Day 1')).toBeNull() + }) + it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => { const c = corridor() wrap() @@ -512,8 +531,8 @@ describe('RoadtripCorridorPanel', () => { }) it('FE-ROADTRIP-PANEL-032: a narrow panel gives the heading up before it gives up a control', () => { - // The heading repeats what the column already is, and the day picker beside it is - // the part somebody came here to change. + // The heading repeats what the column already is; the controls are what somebody came + // here to change. panelWidth.value = 320 const { unmount } = wrap() expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 850044776..07018c197 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -11,6 +11,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' +import RoadtripDayPicker from './RoadtripDayPicker' import { serviceColor } from './roadtripModel' import { alongLabel, offRouteLabel } from './corridorSearchModel' import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds' @@ -296,9 +297,9 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusP * the list answers "which of these" and the map answers "which side of the road". * * Three cards down one column, the same shapes the rail opposite uses: what to look for, - * how the search is getting on, and what it found. The day it searches sits in the - * header rather than among the filters — it is the question's subject, not one of its - * conditions. + * how the search is getting on, and what it found. The day it searches sits beside what + * it looks for, in the same dropdown, so the question reads as one line: these kinds, + * along this day. */ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, onFocusPoint, onAddManual, tripId, canImport, @@ -319,11 +320,6 @@ export default function RoadtripCorridorPanel({ const panel = useElementSize() const narrow = panel.width > 0 && panel.width < NARROW_PANEL_PX - const dayOptions = routes.days.map(d => ({ - value: String(d.dayId), - label: t('roadtrip.day', { number: d.dayNumber }), - })) - const grouped = CORRIDOR_CATEGORY_KEYS .map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) })) .filter(g => g.pois.length > 0) @@ -378,40 +374,37 @@ export default function RoadtripCorridorPanel({ return (
- {/* Header: the panel's name, and the day the question is about. - - Pulled at, the name is the first thing to go. It repeats what the column - already is, the day picker beside it is the part somebody came here to change, - and a heading that shortens to an ellipsis says less than no heading at all. */} + {/* Header: the panel's name. Pulled at, it is the first thing to go: it repeats + what the column already is, and a heading that shortens to an ellipsis says less + than no heading at all. */}
{narrow ? null : (

{t('roadtrip.poi.title')}

)} - {dayOptions.length > 1 ? ( -
- corridor.setDayId(String(value))} - options={dayOptions} - size="sm" - /> -
- ) : null} - {tripId !== undefined && tripId > 0 && canImport && } + {tripId !== undefined && tripId > 0 && canImport && ( + + )}
{/* What to look for. */}
{t('roadtrip.poi.looking')} - +
+
+ +
+ {routes.days.length > 1 ? ( + + ) : null} +
diff --git a/client/src/components/Roadtrip/RoadtripDayPicker.test.tsx b/client/src/components/Roadtrip/RoadtripDayPicker.test.tsx new file mode 100644 index 000000000..3962ced61 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripDayPicker.test.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import RoadtripDayPicker from './RoadtripDayPicker' + +/** + * FE-DAYPICK-001..004 — the day the corridor searches along, in the same dropdown as what + * it looks for. + */ + +const DAYS = [{ dayId: 11, dayNumber: 1 }, { dayId: 12, dayNumber: 2 }, { dayId: 13, dayNumber: 3 }] + +function picker(value = '12', onChange = vi.fn()) { + render() + return { onChange, trigger: () => screen.getAllByRole('button')[0] } +} + +describe('RoadtripDayPicker', () => { + it('FE-DAYPICK-001: closed, it names the picked day and nothing else', () => { + const { trigger } = picker() + expect(trigger()).toHaveTextContent('Day 2') + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('listbox')).toBeNull() + }) + + it('FE-DAYPICK-002: opened, it lists every day and marks the picked one', () => { + const { trigger } = picker() + fireEvent.click(trigger()) + const options = screen.getAllByRole('option') + expect(options.map(o => o.textContent)).toEqual(['1Day 1', '2Day 2', '3Day 3']) + expect(options[1]).toHaveAttribute('aria-selected', 'true') + expect(options[0]).toHaveAttribute('aria-selected', 'false') + }) + + it('FE-DAYPICK-003: picking a day reports its id and closes, a single choice being one gesture', () => { + const { trigger, onChange } = picker() + fireEvent.click(trigger()) + fireEvent.click(screen.getByRole('option', { name: /Day 3/ })) + expect(onChange).toHaveBeenCalledWith('13') + expect(screen.queryByRole('listbox')).toBeNull() + }) + + it('FE-DAYPICK-004: a click outside or Escape closes it without a choice', () => { + const { trigger, onChange } = picker() + fireEvent.click(trigger()) + fireEvent.mouseDown(document.body) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(trigger()) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripDayPicker.tsx b/client/src/components/Roadtrip/RoadtripDayPicker.tsx new file mode 100644 index 000000000..15fc53433 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripDayPicker.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { DROPDOWN_LIST, DROPDOWN_ROW, DROPDOWN_TRIGGER, useDropdown } from './railDropdown' +import { FS } from './typeScale' + +interface RoadtripDayPickerProps { + days: readonly { dayId: number; dayNumber: number }[] + /** The picked day's id, as the corridor keeps it. */ + value: string + onChange: (dayId: string) => void +} + +/** + * The day whose drive the corridor searches along, in the same dropdown as what it looks + * for and on the same line, so the question reads as one: these kinds, along this day. + * Each day leads with its number where a kind leads with its icon. + */ +export default function RoadtripDayPicker({ days, value, onChange }: RoadtripDayPickerProps): React.ReactElement { + const { t } = useTranslation() + const { open, setOpen, ref } = useDropdown() + const current = days.find(d => String(d.dayId) === value) + + return ( +
+ + + {open ? ( +
+ {days.map(day => { + const on = String(day.dayId) === value + return ( + + ) + })} +
+ ) : null} +
+ ) +} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index 22573ebe5..9a4638ba7 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -1,7 +1,15 @@ import React from 'react' -import { describe, it, expect, vi } from 'vitest' +import { afterEach, describe, it, expect, vi } from 'vitest' import { act, render, screen, within, fireEvent } from '@testing-library/react' import { TranslationProvider } from '../../i18n' +// The rail measures itself to drop the stop counts when pulled narrow. Zero reads as "not +// measured yet", which keeps every figure, and is the default here. +const { railWidth } = vi.hoisted(() => ({ railWidth: { value: 0 } })) +vi.mock('../../hooks/useElementSize', () => ({ + useElementSize: () => ({ ref: () => {}, width: railWidth.value, height: 0 }), +})) +afterEach(() => { railWidth.value = 0 }) + import RoadtripSidebar from './RoadtripSidebar' import RoadtripModeSwitch from './RoadtripModeSwitch' import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' @@ -127,6 +135,22 @@ describe('RoadtripSidebar', () => { expect(total('Stops')).toBe('5') }) + it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the rail stops counting stops, in its head and on every day', () => { + const wide = wrap() + expect(screen.getByText('2 stops')).toBeInTheDocument() + wide.unmount() + + railWidth.value = 300 + wrap() + expect(total('Distance')).toBe('250 km') + expect(total('Driving time')).toBe('2 h 30 min') + const head = screen.getByText('Distance').closest('header')! + // The figure goes with its hairline, so the two left share the width. + expect(within(head).queryByText('Stops')).toBeNull() + expect(head.querySelectorAll('span.w-px')).toHaveLength(1) + expect(screen.queryByText('2 stops')).toBeNull() + }) + it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => { // Trip and day totals deliberately differ from the single leg, so the leg's own two // values are the only place '100 km' and '1 h' can come from. diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 209d38aa1..4393b733c 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -1,5 +1,6 @@ import ChargingInfo from './ChargingInfo' import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' +import { useElementSize } from '../../hooks/useElementSize' import React, { useMemo, useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, @@ -173,6 +174,12 @@ const DISC = 'grid h-6 w-6 shrink-0 place-items-center rounded-full' */ const STAT_LABEL = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint' +/** + * Below this width the rail stops counting stops: the trip head drops its third figure and + * the day headers their count, rather than squeezing every badge onto a line too short. + */ +const RAIL_NARROW_PX = 330 + /** * A day-header badge: the date, the drive, the count. * @@ -340,8 +347,11 @@ function splitValue(value: string): React.ReactNode { * Three equal centred columns with hairlines between them, rather than three labelled * rows: the labels are the quiet part and the numbers are what the head exists for, so * the numbers get the size and the labels get the letter-spacing. + * + * Pulled narrow, the stops go first, with their hairline: they are the figure the rail + * itself shows best, one row per stop, while distance and time are only summed up here. */ -function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement { +function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const cells: [string, string][] = [ @@ -349,7 +359,7 @@ function TripSummary({ routes }: { routes: RoadtripRoutes }): React.ReactElement // below count the same thing without either of them recounting the other's stops. [t('roadtrip.summary.distance'), formatDistance(routes.totalDistance / 1000, distanceUnit)], [t('roadtrip.summary.driving'), formatDurationShort(routes.totalDuration)], - [t('roadtrip.summary.stops'), String(routes.totalStops)], + ...(narrow ? [] : [[t('roadtrip.summary.stops'), String(routes.totalStops)] as [string, string]]), ] return (
@@ -1550,8 +1560,10 @@ function SpillBlock({ spill, children }: { * move, a stay edit or a refuel offer still names the day the server knows it by. See * `nightSpill.ts`. */ -function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint }: { +function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, canEditBookings, reservations, dayOrder, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading, collapsed, onToggle, onFocusPoint, narrow }: { onFocusPoint?: RoadtripSidebarProps['onFocusPoint'] + /** The rail is pulled too narrow for every badge; the stop count gives way. */ + narrow?: boolean day: RoadtripDay /** Folded down to the header, and off the map with it. */ collapsed?: boolean @@ -1955,7 +1967,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onOpenBooking, ca ) : null} {/* Nor does a day whose drive runs from one hotel to the next with no stop of its own: "0 stops" beside the drive reads as the stops having gone missing. */} - {day.legs.length > 0 && destinationCount(day) > 0 ? ( + {day.legs.length > 0 && destinationCount(day) > 0 && !narrow ? ( {t('roadtrip.day.stopCount', { count: destinationCount(day) })} @@ -2183,6 +2195,8 @@ export default function RoadtripSidebar({ collapsedDayIds, onToggleDay, onFocusPoint, }: RoadtripSidebarProps): React.ReactElement { const { t } = useTranslation() + const rail = useElementSize() + const narrow = rail.width > 0 && rail.width < RAIL_NARROW_PX // One drag state for the whole rail rather than one per day: a stop that cannot leave // its own day is exactly the move a road trip needs when a leg turns out too long. const [from, setFrom] = React.useState(null) @@ -2222,9 +2236,9 @@ export default function RoadtripSidebar({ return ( // The totals hold still while the days move under them: they are the answer to "how // long is this trip", and an answer that scrolls away is one you have to go back for. -
+
- + {routes.dayWindowIssue ? (

{t(`roadtrip.window.${routes.dayWindowIssue}`, { days: MAX_TRIP_DAYS })} @@ -2237,6 +2251,7 @@ export default function RoadtripSidebar({ () { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onPointer = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('mousedown', onPointer) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onPointer) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + return { open, setOpen, ref } +}