mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>()
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
|
||||
const Icon = meta[k].Icon
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="grid h-[18px] w-[18px] place-items-center rounded-md"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`.
|
||||
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={10} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<Tooltip label={names} disabled={open || !picked.length}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={picked.length ? `${t('roadtrip.poi.looking')}: ${names}` : undefined}
|
||||
style={{ fontSize: FS.control }}
|
||||
className={`${DROPDOWN_TRIGGER} w-full`}
|
||||
>
|
||||
{picked.length ? (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
{picked.map(k => {
|
||||
const Icon = meta[k].Icon
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md"
|
||||
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable: road-signage palette
|
||||
>
|
||||
<Icon size={10} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-content-faint">{t('roadtrip.poi.looking')}</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
|
||||
>
|
||||
<div role="listbox" aria-multiselectable className={`${DROPDOWN_LIST} start-0`}>
|
||||
{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}
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette
|
||||
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable: road-signage palette
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
<span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
|
||||
|
||||
@@ -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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1), day(2, 2)])} />)
|
||||
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(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1)])} />)
|
||||
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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
@@ -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(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
|
||||
expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument()
|
||||
|
||||
@@ -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<HTMLDivElement>()
|
||||
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 (
|
||||
<div ref={panel.ref} className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
|
||||
{narrow ? null : (
|
||||
<h2 className="min-w-0 truncate font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
|
||||
{t('roadtrip.poi.title')}
|
||||
</h2>
|
||||
)}
|
||||
{dayOptions.length > 1 ? (
|
||||
<div className="ms-auto min-w-0">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{tripId !== undefined && tripId > 0 && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
|
||||
{tripId !== undefined && tripId > 0 && canImport && (
|
||||
<span className="ms-auto"><GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} /></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What to look for. */}
|
||||
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
</div>
|
||||
{routes.days.length > 1 ? (
|
||||
<RoadtripDayPicker days={routes.days} value={corridor.dayId} onChange={corridor.setDayId} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="h-px bg-edge-faint" aria-hidden />
|
||||
|
||||
@@ -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(<RoadtripDayPicker days={DAYS} value={value} onChange={onChange} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLDivElement>()
|
||||
const current = days.find(d => String(d.dayId) === value)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style={{ fontSize: FS.control }}
|
||||
className={DROPDOWN_TRIGGER}
|
||||
>
|
||||
<span className="whitespace-nowrap font-medium text-content">
|
||||
{current ? t('roadtrip.day', { number: current.dayNumber }) : null}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div role="listbox" className={`${DROPDOWN_LIST} end-0 max-h-[18rem] overflow-y-auto`}>
|
||||
{days.map(day => {
|
||||
const on = String(day.dayId) === value
|
||||
return (
|
||||
<button
|
||||
key={day.dayId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
onClick={() => { onChange(String(day.dayId)); setOpen(false) }}
|
||||
style={{ fontSize: FS.control }}
|
||||
className={DROPDOWN_ROW}
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px] bg-surface-tertiary font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{day.dayNumber}
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate pe-4 ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
{t('roadtrip.day', { number: day.dayNumber })}
|
||||
</span>
|
||||
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
wide.unmount()
|
||||
|
||||
railWidth.value = 300
|
||||
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
|
||||
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.
|
||||
|
||||
@@ -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 (
|
||||
<header className="mx-3.5 rounded-2xl border border-edge-faint bg-surface-card px-3 pb-3 pt-3.5">
|
||||
@@ -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 ? (
|
||||
<span className={`${DAY_BADGE} bg-surface-card`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: destinationCount(day) })}
|
||||
</span>
|
||||
@@ -2183,6 +2195,8 @@ export default function RoadtripSidebar({
|
||||
collapsedDayIds, onToggleDay, onFocusPoint,
|
||||
}: RoadtripSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const rail = useElementSize<HTMLDivElement>()
|
||||
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<DragState['from']>(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.
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
|
||||
<div ref={rail.ref} className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
|
||||
<div className="shrink-0">
|
||||
<TripSummary routes={routes} />
|
||||
<TripSummary routes={routes} narrow={narrow} />
|
||||
{routes.dayWindowIssue ? (
|
||||
<p role="status" className="mx-3.5 mt-2 rounded-xl bg-warning-soft p-3 text-caption text-content">
|
||||
{t(`roadtrip.window.${routes.dayWindowIssue}`, { days: MAX_TRIP_DAYS })}
|
||||
@@ -2237,6 +2251,7 @@ export default function RoadtripSidebar({
|
||||
<DaySection
|
||||
key={day.dayId}
|
||||
day={day}
|
||||
narrow={narrow}
|
||||
onFocusPoint={onFocusPoint}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
onSelectStop={onSelectStop}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The dropdown the corridor panel asks its question with: what to look for, and along which
|
||||
* day. One shape for both, so the two sit side by side as one line of the same control.
|
||||
*/
|
||||
|
||||
/** The closed control: one line, on the card's own ground, with the card's edge. */
|
||||
export const DROPDOWN_TRIGGER =
|
||||
'flex h-[32px] items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint'
|
||||
|
||||
/** The list it opens, under it and at least as wide. */
|
||||
export const DROPDOWN_LIST =
|
||||
'absolute top-full z-[var(--z-toast)] mt-1 w-max min-w-full overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur'
|
||||
|
||||
/** One option in that list. */
|
||||
export const DROPDOWN_ROW = 'flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover'
|
||||
|
||||
/** Open or not, closed again by a press outside it or by Escape. */
|
||||
export function useDropdown<T extends HTMLElement>() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<T>(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 }
|
||||
}
|
||||
Reference in New Issue
Block a user