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 } +}