mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): the phone map loses the stage bar over the dock
It named the day's destination with its picture, its stop count, its arrival and the stage distance, and it stood there for the whole of the road trip tab. The day and the distance are in the shell's stage header already, and on a map that fills the screen a permanent card over the bottom edge costs more than the three facts it added. Tapping it opened the destination's stop sheet; the chain opens the same sheet from the row that names it. The slot stays for the one bar that earns it: while other ways of driving a leg are on offer, the picker's bar stands there. That wrapper is now mounted with the bar rather than lying empty over the map's own buttons the rest of the time, and the map floor lifts only while it is up, so on the stage the round controls stand on the dock like everywhere else.
This commit is contained in:
@@ -311,27 +311,28 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
// timeline (z-10) or the browse/tab overlays (z-30) above it.
|
||||
//
|
||||
// --m-map-floor is the top edge of whatever the map ends at: the dock, 62px tall at
|
||||
// safe-bottom + 12, or on the road trip tab the stage bar, which takes the band the
|
||||
// round controls would otherwise sit in and is what --m-stage-lift adds. The round
|
||||
// controls sit straight on that floor and add their own 12px, close enough to the
|
||||
// thumb to reach one-handed. Everything that reads --bottom-nav-h (the compass, both
|
||||
// engines' locate button and base-layer switcher, the overview stack) follows on its
|
||||
// own. The band used to float a further 38px up to leave the corner under it to the
|
||||
// map credit; the phone map carries no visible credit any more (see mobile.css), so
|
||||
// that row would now only be a gap over the dock.
|
||||
// safe-bottom + 12, or the alternatives bar when the picker puts one over it, which is
|
||||
// what --m-stage-lift adds. The round controls sit straight on that floor and add
|
||||
// their own 12px, close enough to the thumb to reach one-handed. Everything that reads
|
||||
// --bottom-nav-h (the compass, both engines' locate button and base-layer switcher,
|
||||
// the overview stack) follows on its own. The band used to float a further 38px up to
|
||||
// leave the corner under it to the map credit; the phone map carries no visible credit
|
||||
// any more (see mobile.css), so that row would now only be a gap over the dock.
|
||||
//
|
||||
// The metrics are classes rather than an inline --bottom-nav-h, and the lift is its
|
||||
// OWN variable folded into them: the compass band below is identified by being the
|
||||
// one element that sets that name inline, and a second one would make that ambiguous.
|
||||
<div
|
||||
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] [--bottom-nav-h:var(--m-map-floor)]"
|
||||
// 76px is the stage bar's own height plus the gap it keeps on both sides, so the
|
||||
// round controls land one gap above the bar instead of on its top edge. While other
|
||||
// ways of driving a leg are on offer their bar stands in that slot instead, taller,
|
||||
// and the floor clears it the same way.
|
||||
// A stage bar used to stand over the dock for the whole of the road trip tab, and the
|
||||
// floor cleared its 61px plus a gap the year round. It is gone, so the only thing
|
||||
// left in that slot is the bar the picker raises while other ways of driving a leg
|
||||
// are on offer, and the floor clears that one the same way: its height plus the gap
|
||||
// it keeps from the dock, so the round controls land above it rather than on its
|
||||
// top edge.
|
||||
style={{
|
||||
['--m-stage-lift' as string]: onStage && mapActive
|
||||
? (planner.routeAlternatives.open ? `${RT_ALT_BAR_LIFT}px` : '76px')
|
||||
['--m-stage-lift' as string]: onStage && mapActive && planner.routeAlternatives.open
|
||||
? `${RT_ALT_BAR_LIFT}px`
|
||||
: '0px',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -7,14 +7,10 @@ import { useMRtAlternatives } from './useMRtAlternatives'
|
||||
import MRtCorridorBar from './MRtCorridorBar'
|
||||
import MRtAlternativesBar from './MRtAlternativesBar'
|
||||
import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
|
||||
import { badgeLabel, distanceBadge } from './stageBadges'
|
||||
import MBadge from '../../../components/MBadge'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
|
||||
import { dayColor } from '../../../../components/Roadtrip/dayColors'
|
||||
import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { getNavigationTargets } from '../../../../components/Planner/placeNavigation'
|
||||
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
@@ -84,29 +80,32 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── Map half: the two bars, and nothing else. Everything between them belongs to the
|
||||
// map instance the shell keeps mounted, so this layer must not swallow taps.
|
||||
// ── Map half: the search bar, and the picker's bar while it is open. Everything
|
||||
// between them belongs to the map instance the shell keeps mounted, so this layer must
|
||||
// not swallow taps.
|
||||
//
|
||||
// While other ways of driving a leg are on offer, their bar takes the stage bar's slot
|
||||
// and the search bar steps away. The picker is modal on the map: its lines are the
|
||||
// A stage bar used to stand over the dock whenever the map was up: the day's
|
||||
// destination with its picture, its stop count, its arrival and its distance. It went
|
||||
// because the day and the distance are already in the shell's stage header, and on a
|
||||
// map that fills the screen a permanent card over the bottom edge costs more than the
|
||||
// three facts it added.
|
||||
//
|
||||
// While other ways of driving a leg are on offer, their bar stands in that now empty
|
||||
// slot and the search bar steps away. The picker is modal on the map: its lines are the
|
||||
// question on screen, a corridor search started under it would draw its pins over
|
||||
// them, and the band it frees at the top is room the leg is framed into.
|
||||
if (shell.rtView === 'map') {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-20">
|
||||
{!alts.open && searchBar}
|
||||
{/* Just above the dock, the same gap everything else on this shell keeps from
|
||||
it. Twenty pixels higher left a band of map between the two that read as a
|
||||
gap rather than as breathing room, and pushed the map's own buttons into
|
||||
the bar's top edge. */}
|
||||
<div className="pointer-events-auto absolute left-4 right-4 bottom-[calc(var(--bottom-nav-h,84px)+4px)]">
|
||||
{/* `rt` is handed down rather than looked up again: useMRoadtrip owns a
|
||||
30s interval and a network subscription, and a second call would run a
|
||||
second pair of them for the same screen. */}
|
||||
{alts.open
|
||||
? <MRtAlternativesBar planner={planner} alts={alts} />
|
||||
: <StageBar planner={planner} rt={rt} onOpen={openStop} />}
|
||||
</div>
|
||||
{/* Just above the dock, the same gap everything else on this shell keeps from it.
|
||||
Mounted only while the picker is open, so nothing invisible lies over the map's
|
||||
own buttons the rest of the time. */}
|
||||
{alts.open && (
|
||||
<div className="pointer-events-auto absolute left-4 right-4 bottom-[calc(var(--bottom-nav-h,84px)+4px)]">
|
||||
<MRtAlternativesBar planner={planner} alts={alts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -256,107 +255,6 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage bar over the map: which day, where to, what it costs.
|
||||
*
|
||||
* One line rather than a sheet that can be dragged open. A drag sheet brings a panel
|
||||
* height the map fit has to account for and lifts the locate and style buttons with
|
||||
* it, which is a bigger change than this half of the screen needs to be useful.
|
||||
*
|
||||
* It reads as a place, so a tap opens that place's stop, the same sheet its row in the
|
||||
* chain opens. It used to switch to the chain instead, which broke the promise its name
|
||||
* makes; the header's list switch is the way back to the chain. Name, picture, clock and
|
||||
* tap all come off one row (see stageEnd), so the bar can never name one stop and open
|
||||
* another.
|
||||
*
|
||||
* It leads with a picture of that place rather than a coloured dot, because a place is
|
||||
* recognised by how it looks long before its name is read. The facts beside it are badges
|
||||
* rather than one line joined with dots: each one is a figure on its own, and a pill edge
|
||||
* separates them at a glance where a middle dot has to be found first. Every badge is
|
||||
* neutral. The whole bar is one button, and a filled pill inside it would read as a
|
||||
* second control that does something else.
|
||||
*
|
||||
* Its height is a contract: 38px picture, a 39px text column, 10px padding and the border
|
||||
* make the 61px the map area lifts its round buttons over (--m-stage-lift in MMapArea).
|
||||
* Growing the picture or a badge moves those buttons into the bar's top edge.
|
||||
*/
|
||||
function StageBar({ planner, rt, onOpen }: {
|
||||
planner: MTripTabPanelProps['planner']
|
||||
rt: ReturnType<typeof useMRoadtrip>
|
||||
onOpen: (row: StopRow) => void
|
||||
}) {
|
||||
const { t } = planner
|
||||
const unit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId)
|
||||
const stage = rt.stage
|
||||
|
||||
if (!stage) {
|
||||
// No stage means the day filter is off, which on the map is the whole drive. Its total
|
||||
// is left out while nothing is measured, rather than printed as a drive of 0 m.
|
||||
const total = distanceBadge(planner.roadtripRoutes.totalDistance, unit)
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t('roadtrip.title')}</span>
|
||||
{total && <MBadge size="sm" caps={false}>{total}</MBadge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tint = dayColorsOn ? dayColor(stage.dayNumber) : null
|
||||
const end = rt.end
|
||||
const name = end?.stop.name ?? t('roadtrip.stop.none')
|
||||
const stopCount = t('roadtrip.day.stopCount', { count: rt.stops })
|
||||
const distance = distanceBadge(stage.distance, unit)
|
||||
const pending = t('roadtrip.leg.pending')
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* Hidden from the name, which the title already carries: the photo's alt text is the
|
||||
place name a second time. The day colour rings the picture when the day colours
|
||||
are on, because the ring is what ties this bar to its own line on the map. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center overflow-hidden rounded-full border-2 border-[color:var(--m-avbr)] bg-[color:var(--m-ic)] text-m-muted"
|
||||
// theme-lint-disable: map paint, see dayColors.ts.
|
||||
style={tint ? { borderColor: tint.line } : undefined}
|
||||
>
|
||||
{/* Keyed by place: the avatar takes its photo from the first place it is handed and
|
||||
keeps it while a new one is still loading or has none, so without a remount a
|
||||
swipe to the next day would still picture the previous day's destination. */}
|
||||
{rt.endPlace ? (
|
||||
<PlaceAvatar key={rt.endPlace.id} place={rt.endPlace} category={rt.endCategory} size={34} />
|
||||
) : (
|
||||
<MapPin size={16} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold leading-[18px] text-m-ink">{name}</span>
|
||||
<span className="mt-[3px] flex min-w-0 items-center gap-[5px] overflow-hidden">
|
||||
<MBadge>{stopCount}</MBadge>
|
||||
{/* dir=ltr for the reason the rows carry it: a clock reads the same way round in an RTL locale. */}
|
||||
{end?.time && <MBadge caps={false}><span dir="ltr">{end.time}</span></MBadge>}
|
||||
</span>
|
||||
</span>
|
||||
{distance ? <MBadge size="sm" caps={false}>{distance}</MBadge> : <MBadge size="sm">{pending}</MBadge>}
|
||||
</>
|
||||
)
|
||||
const box = 'flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[11px] py-[10px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]'
|
||||
|
||||
// A stage without a single stop has nothing for a tap to open, so a plain block rather
|
||||
// than a dead button, the rule the whole-drive line above follows too.
|
||||
if (!end) return <div className={box}>{inner}</div>
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(end)}
|
||||
aria-label={badgeLabel([name, stopCount, end.time, distance ?? pending])}
|
||||
className={box}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What is coming up, measured against the clock rather than the plan.
|
||||
*
|
||||
|
||||
@@ -12,10 +12,10 @@ export const RT_ALT_BAR_HEIGHT = 174
|
||||
/**
|
||||
* How far the map floor rises while the alternatives bar stands over the dock, in pixels.
|
||||
*
|
||||
* The bar's height plus the same 15px the 76px stage lift adds to the 61px stage bar: the
|
||||
* gap between the top of the dock and the foot of a bar standing over it. So the floor
|
||||
* lands on this bar's top edge as it lands on that one's, and the credit and the round
|
||||
* controls clear this bar the way they clear that one.
|
||||
* The bar's height plus the 15px gap between the top of the dock and the foot of a bar
|
||||
* standing over it. So the floor lands on this bar's top edge and the round controls
|
||||
* clear it rather than sitting in it. It is the only bar left in that slot: the stage bar
|
||||
* that used to hold it whenever the map was up is gone.
|
||||
*
|
||||
* A number rather than a CSS length because the map's fit padding is added up from it.
|
||||
* Kept in this module rather than in the bar's, so the map area reads it without pulling
|
||||
|
||||
@@ -202,26 +202,29 @@ describe('MMapArea', () => {
|
||||
expect(compassBand(container)?.className).not.toContain('left-3')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-003: the map layer floats those controls one credit row above the dock', () => {
|
||||
it('FE-MOB-MAPAREA-003: the map layer stands those controls on the dock, with no credit row under them', () => {
|
||||
const { container } = renderArea()
|
||||
const layer = container.firstElementChild as HTMLElement
|
||||
|
||||
// The floor is the dock's top edge: 62px tall at safe-bottom + 12, raised by the stage lift.
|
||||
expect(layer.className)
|
||||
.toContain('[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]')
|
||||
// The band sits one credit row (a 30px button plus an 8px gap) above that floor, so the
|
||||
// corner under it is the credit's; the controls add their own 12 on top.
|
||||
expect(layer.className).toContain('[--bottom-nav-h:calc(var(--m-map-floor)+38px)]')
|
||||
// The band sits on that floor and the controls add their own 12 on top. It used to float
|
||||
// a further 38px up to leave the corner under it to the credit; the phone map shows none,
|
||||
// so that row would only be a gap over the dock.
|
||||
expect(layer.className).toContain('[--bottom-nav-h:var(--m-map-floor)]')
|
||||
expect(layer.className).not.toContain('38px')
|
||||
// No lift off the stage: the plan tab has no bar in that band.
|
||||
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-013: on the stage the map floor clears the stage bar by one gap', () => {
|
||||
it('FE-MOB-MAPAREA-013: on the stage nothing stands over the dock, so the floor stays on it', () => {
|
||||
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
|
||||
|
||||
// The bar is 61px tall and keeps a gap on both sides, so the floor rises above it rather
|
||||
// than stopping on its top edge, and the credit and the round controls rise with it.
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('76px')
|
||||
// A 61px stage bar used to hold this slot whenever the map was up, and the floor rose
|
||||
// 76px to clear it. It is gone, so the road trip tab lifts nothing by itself; only the
|
||||
// picker's bar still raises the floor (FE-MOB-MAPAREA-030).
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-014: behind the chain there is no bar to clear, so nothing lifts', () => {
|
||||
@@ -535,19 +538,39 @@ describe('MMapArea', () => {
|
||||
expect(mocks.props.focusPoints).toBe(shown)
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-026: the map layer is the credit corner on both tabs, and on the stage it rides the lift', () => {
|
||||
it('FE-MOB-MAPAREA-036: the phone map draws no night pause, on either tab', () => {
|
||||
// A night pause is the moon pill beside a day boundary. On the desktop it is a drag
|
||||
// handle with a hint; a phone has neither hover nor room to drag, and the markers come
|
||||
// from the whole drive rather than the stage, so day 1 showed day 2's pill too.
|
||||
const nightPause = { day: 2, atPlace: false }
|
||||
const vias = [
|
||||
{ lat: 53.55, lng: 9.99, tone: 'default' as const, nightPause },
|
||||
{ lat: 53.87, lng: 10.69, tone: 'default' as const, label: 'Rest stop' },
|
||||
]
|
||||
|
||||
const stage = renderArea(
|
||||
{ trTab: 'roadtrip', mapFront: true },
|
||||
{ ...stagePlanner(3), roadtripMapVias: vias } as unknown as Partial<TripPlanner>,
|
||||
)
|
||||
expect(mocks.props.routeVias).toEqual([vias[1]])
|
||||
stage.unmount()
|
||||
|
||||
// The plan tab's own vias carry night pauses too, so they go through the same filter.
|
||||
renderArea({ trTab: 'plan' }, { routeVias: vias })
|
||||
expect(mocks.props.routeVias).toEqual([vias[1]])
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-026: the map layer carries no credit corner on either tab', () => {
|
||||
const plan = renderArea()
|
||||
const planLayer = plan.container.firstElementChild as HTMLElement
|
||||
// mobile.css places the credit off this class, in both GL engines and in Leaflet.
|
||||
expect(planLayer.classList.contains('m-credit-corner')).toBe(true)
|
||||
// The phone map shows no credit at all now (mobile.css), so the class that used to
|
||||
// place one is gone rather than left behind pointing at nothing.
|
||||
expect(planLayer.classList.contains('m-credit-corner')).toBe(false)
|
||||
plan.unmount()
|
||||
|
||||
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
|
||||
const stageLayer = container.firstElementChild as HTMLElement
|
||||
expect(stageLayer.classList.contains('m-credit-corner')).toBe(true)
|
||||
// The floor takes the same 76px the band does, so the credit lands a gap above the
|
||||
// stage bar rather than on it.
|
||||
expect(stageLayer.style.getPropertyValue('--m-stage-lift')).toBe('76px')
|
||||
expect(stageLayer.classList.contains('m-credit-corner')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-028: on the stage the map draws the offered roads, and a tap on one only lights it', () => {
|
||||
@@ -605,8 +628,8 @@ describe('MMapArea', () => {
|
||||
const answered = { ...asking, ...withPicker(OFFERS) } as TripPlanner
|
||||
rerender(<MMapArea planner={answered} shell={shell} />)
|
||||
// Top: 12 of --m-safe-top, the rail's 50 offset and 42 height, a 12 gap. Bottom: the
|
||||
// dock's 74, the bar's lift and the 38 credit row. The sides keep the phone's 20.
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT + 38, left: 20 })
|
||||
// dock's 74 and the bar's lift. The sides keep the phone's 20.
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT, left: 20 })
|
||||
const first = mocks.props.fitPadding
|
||||
|
||||
// Kept between renders, for the reason the stage data is.
|
||||
@@ -622,13 +645,13 @@ describe('MMapArea', () => {
|
||||
const style = notchedPhone('59px', '34px')
|
||||
renderArea({ trTab: 'roadtrip', rtView: 'map' }, withPicker(OFFERS))
|
||||
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 301, left: 20 })
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 74 + RT_ALT_BAR_LIFT, left: 20 })
|
||||
|
||||
// Turned sideways the insets go to the edges, and the frame lets go of them.
|
||||
style.mockRestore()
|
||||
notchedPhone('0px', '21px')
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 301, left: 20 })
|
||||
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 74 + RT_ALT_BAR_LIFT, left: 20 })
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => {
|
||||
|
||||
@@ -498,7 +498,7 @@ describe('MRoadtripTab', () => {
|
||||
describe('map half', () => {
|
||||
const mapShell = () => buildShell({ rtView: 'map' })
|
||||
|
||||
it('FE-MOB-RTTAB-022: renders the stage bar and nothing that would cover the map', () => {
|
||||
it('FE-MOB-RTTAB-022: leaves the map to itself, with no bar of its own over it', () => {
|
||||
const { container } = renderTab(planner(), mapShell())
|
||||
|
||||
expect((container.firstChild as HTMLElement).className).toContain('pointer-events-none')
|
||||
@@ -506,201 +506,21 @@ describe('MRoadtripTab', () => {
|
||||
expect(screen.queryByText('Shell Ebina')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.window.stop')).toBeNull()
|
||||
|
||||
// A stage bar used to stand over the dock here, naming the day's destination with its
|
||||
// picture, stop count, arrival and distance. The day and the distance are in the
|
||||
// shell's stage header already, and on a map that fills the screen a permanent card
|
||||
// over the bottom edge cost more than the three facts it added. Nothing is left of it:
|
||||
// not the destination, not its badges, and not an empty wrapper over the map's own
|
||||
// buttons. The picker's bar still uses that slot (FE-MOB-RTTAB-048).
|
||||
expect(screen.queryByText('Kyoto Station')).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.day.stopCount:2')).toBeNull()
|
||||
expect(screen.queryByText('412 km')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Kyoto Station/ })).toBeNull()
|
||||
// The search bar is still up top; what is gone is the wrapper down at the dock.
|
||||
expect(container.querySelector('[class*="bottom-[calc(var(--bottom-nav-h"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-023: names the last stop of the day, and badges its count, that stop\'s own arrival and the distance', () => {
|
||||
renderTab(planner(), mapShell())
|
||||
|
||||
// The badges run their texts together in a computed name, so the bar spells it out.
|
||||
const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km' })
|
||||
expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument()
|
||||
// One badge per figure, and no middle dot left between them.
|
||||
const badges = ['roadtrip.day.stopCount:2', '12:40', '412 km'].map(text => within(bar).getByText(text).closest('.rounded-full'))
|
||||
expect(badges).not.toContain(null)
|
||||
expect(new Set(badges).size).toBe(3)
|
||||
expect(bar.textContent).not.toContain('·')
|
||||
// 12:40 is when Kyoto Station is reached. The bar used to print 22:00, the automatic
|
||||
// day end after it, which is where the window closed and not a clock of the stop named.
|
||||
expect(within(bar).queryByText(/22:00/)).toBeNull()
|
||||
// Words in caps, the unit in its own case. And neutral: the whole bar is the button,
|
||||
// and a filled pill inside it would read as a second one.
|
||||
expect(within(bar).getByText('roadtrip.day.stopCount:2').className).toContain('uppercase')
|
||||
const distance = within(bar).getByText('412 km')
|
||||
expect(distance.className).not.toContain('uppercase')
|
||||
expect(distance.className).not.toContain('bg-m-act')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-024: drops the arrival from the bar when the day has none, and the pending distance with it', () => {
|
||||
const untimed = stage({
|
||||
distance: 0,
|
||||
schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }), mapShell())
|
||||
|
||||
// The name leaves the missing clock out too, rather than reading an empty part or a
|
||||
// placeholder between the count and the distance.
|
||||
const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, roadtrip.leg.pending' })
|
||||
expect(within(bar).getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
|
||||
expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-025: opens the stop the bar names, the sheet its row in the chain opens', () => {
|
||||
const { shell } = renderTab(planner(), mapShell())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Kyoto Station/ }))
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 503 })
|
||||
// The bar reads as a place; the header's list switch is the way back to the chain.
|
||||
expect(shell.toggleRtView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-033: a stage drawn with nothing but its night marker names no stop and is not a button', () => {
|
||||
const nightOnly = stage({
|
||||
stops: [stage().stops[3]],
|
||||
legs: [],
|
||||
legVias: [],
|
||||
spills: [],
|
||||
dryPoints: [],
|
||||
driveWarnings: [],
|
||||
schedule: { entries: [{ arrival: '22:00', departure: null, anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [nightOnly] }) }), mapShell())
|
||||
|
||||
const title = screen.getByText('roadtrip.stop.none')
|
||||
// Nothing for a tap to open, so a plain block rather than a dead button, and the night
|
||||
// marker's clock is not a stop's arrival either.
|
||||
expect(title.closest('button')).toBeNull()
|
||||
expect(screen.getByText('roadtrip.day.stopCount:0')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/22:00/)).toBeNull()
|
||||
fireEvent.click(title)
|
||||
expect(shell.openSheet).not.toHaveBeenCalled()
|
||||
expect(shell.toggleRtView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-034: a stage that ends on a stop reached after midnight opens it on the day it is stored on', () => {
|
||||
// Only the spilled stop is left on the card: it is drawn here and stored on day 1.
|
||||
const spilledOnly = stage({
|
||||
stops: [stage().stops[0]],
|
||||
legs: [],
|
||||
legVias: [],
|
||||
dryPoints: [],
|
||||
driveWarnings: [],
|
||||
schedule: { entries: [{ arrival: '00:40', departure: '01:25', anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [spilledOnly] }) }), mapShell())
|
||||
|
||||
const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ })
|
||||
expect(within(bar).getByText('roadtrip.day.stopCount:1')).toBeInTheDocument()
|
||||
expect(within(bar).getByText('00:40')).toBeInTheDocument()
|
||||
fireEvent.click(bar)
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-026: falls back to the whole drive when no day is picked, and offers nothing to tap', () => {
|
||||
renderTab(planner({ selectedDayId: null }), mapShell())
|
||||
|
||||
expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('980 km')).toBeInTheDocument()
|
||||
// Not a dead button: with no stage there is nothing for a tap to open, so the
|
||||
// whole-drive line is a plain block. The search bar above it stays a button.
|
||||
expect(screen.getByText('roadtrip.title').closest('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-035: pictures the place the day ends at, from the trip store and not the filtered day lists', () => {
|
||||
seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] })
|
||||
// planner.places is the list the phone takes hidden service stops out of, so the
|
||||
// picture must not depend on it: a day can end at a hidden campsite.
|
||||
renderTab(planner({ places: [] }), mapShell())
|
||||
|
||||
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
|
||||
const img = bar.querySelector('img') as HTMLImageElement
|
||||
expect(img).toHaveAttribute('src', 'https://img.test/kyoto.jpg')
|
||||
// Out of the name: the photo's alt text is the title a second time.
|
||||
expect(img.closest('[aria-hidden="true"]')).not.toBeNull()
|
||||
expect(bar).toHaveAccessibleName('Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km')
|
||||
expect(bar.querySelector('.lucide-map-pin')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-036: shows a neutral pin when no place row stands behind the stop', () => {
|
||||
seedStore(useTripStore, { places: [] })
|
||||
renderTab(planner(), mapShell())
|
||||
|
||||
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
|
||||
expect(bar.querySelector('img')).toBeNull()
|
||||
expect(bar.firstElementChild?.querySelector('.lucide-map-pin')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-037: a place without a photo is pictured by its category', () => {
|
||||
seedStore(useTripStore, { places: [{ ...PLACES[1], category_id: 5 }] })
|
||||
const categories = [{ id: 5, name: 'Station', color: '#123456', icon: 'Train' }]
|
||||
renderTab(planner({ categories } as unknown as Partial<TripPlanner>), mapShell())
|
||||
|
||||
const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
|
||||
expect(ring.querySelector('img')).toBeNull()
|
||||
expect(ring.firstElementChild).toHaveStyle({ backgroundColor: '#123456' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-038: rings the picture in the day colour only once the day colours are on', () => {
|
||||
const plain = renderTab(planner(), mapShell())
|
||||
const plainRing = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
|
||||
expect(plainRing).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(plainRing.style.borderColor).toBe('')
|
||||
plain.unmount()
|
||||
|
||||
mocks.prefs = { roadtrip_day_colors: true }
|
||||
renderTab(planner(), mapShell())
|
||||
const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
|
||||
// dayColor(2).line, the second of the eight, see dayColors.ts.
|
||||
expect(ring).toHaveStyle({ borderColor: '#ff9f0a' })
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-039: a new stage never keeps the picture of the one before', () => {
|
||||
seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] })
|
||||
const shell = mapShell()
|
||||
const view = renderTab(planner(), shell)
|
||||
expect(screen.getByRole('button', { name: /Kyoto Station/ }).querySelector('img')).not.toBeNull()
|
||||
|
||||
// The next stage ends at Fuji Viewpoint, which has no photo of its own. The avatar
|
||||
// keeps the photo it was first handed until it remounts, which is what its key is for.
|
||||
const fujiOnly = stage({
|
||||
stops: [stage().stops[0]],
|
||||
legs: [],
|
||||
legVias: [],
|
||||
dryPoints: [],
|
||||
driveWarnings: [],
|
||||
schedule: { entries: [{ arrival: '00:40', departure: '01:25', anchored: false, dayOffset: 0 }], warnings: [] },
|
||||
} as unknown as Partial<RoadtripDay>)
|
||||
view.rerender(<MRoadtripTab planner={planner({ roadtripRoutes: routes({ days: [fujiOnly] }) })} shell={shell} tab="roadtrip" />)
|
||||
|
||||
const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ })
|
||||
expect(bar.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-040: the head card and the stage bar agree on when the day arrives', () => {
|
||||
const list = renderTab()
|
||||
const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
|
||||
expect(within(arrive).getByText('12:40')).toBeInTheDocument()
|
||||
list.unmount()
|
||||
|
||||
renderTab(planner(), mapShell())
|
||||
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
|
||||
// Same clock, and it keeps its digit order in an RTL locale like the head card's.
|
||||
expect(within(bar).getByText('12:40')).toHaveAttribute('dir', 'ltr')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-041: the whole-drive bar badges a measured total and leaves a total of nothing out', () => {
|
||||
const measured = renderTab(planner({ selectedDayId: null }), mapShell())
|
||||
const total = screen.getByText('980 km')
|
||||
expect(total.className).toContain('rounded-full')
|
||||
expect(total.className).not.toContain('uppercase')
|
||||
measured.unmount()
|
||||
|
||||
renderTab(planner({ selectedDayId: null, roadtripRoutes: routes({ totalDistance: 0 }) }), mapShell())
|
||||
// Nothing routed yet is not a drive of 0 m.
|
||||
expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
|
||||
expect(screen.queryByText('0 m')).toBeNull()
|
||||
expect(screen.getByText('roadtrip.title').parentElement?.querySelector('.rounded-full')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the corridor search', () => {
|
||||
@@ -819,21 +639,22 @@ describe('MRoadtripTab', () => {
|
||||
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-048: on the map the picker takes the stage bar\'s slot and the search bar steps away', () => {
|
||||
it('FE-MOB-RTTAB-048: on the map the picker stands over the dock and the search bar steps away', () => {
|
||||
const shell = buildShell({ rtView: 'map' })
|
||||
const open = renderTab(withPicker({}), shell)
|
||||
|
||||
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
|
||||
expect(within(bar).getByText('roadtrip.alt.loading')).toBeInTheDocument()
|
||||
// In the very wrapper the stage bar sits in, just above the dock.
|
||||
// Just above the dock, in the slot the stage bar used to hold the whole time.
|
||||
expect((bar.parentElement as HTMLElement).className).toContain('bottom-[calc(var(--bottom-nav-h,84px)+4px)]')
|
||||
expect(screen.queryByRole('button', { name: /Kyoto Station/ })).toBeNull()
|
||||
expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
|
||||
open.unmount()
|
||||
|
||||
renderTab(planner(), shell)
|
||||
// Closed, that slot is empty and the map has it back: the wrapper is mounted with the
|
||||
// bar rather than left lying over the map's own buttons.
|
||||
const { container } = renderTab(planner(), shell)
|
||||
expect(screen.queryByRole('region', { name: 'roadtrip.alt.title' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /Kyoto Station/ })).toBeInTheDocument()
|
||||
expect(container.querySelector('[class*="bottom-[calc(var(--bottom-nav-h"]')).toBeNull()
|
||||
expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -195,8 +195,9 @@ describe('useMRtAlternatives', () => {
|
||||
expect(viewer.result.current.canAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap the stage bar keeps', () => {
|
||||
// 61px stage bar plus 15 is the 76px stage lift; the same 15 between the dock and this bar.
|
||||
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap it keeps from the dock', () => {
|
||||
// The same 15px a bar standing over the dock keeps from it. This is the only such bar
|
||||
// left: the stage bar that used to hold the slot the rest of the time is gone.
|
||||
expect(RT_ALT_BAR_HEIGHT).toBe(174)
|
||||
expect(RT_ALT_BAR_LIFT).toBe(189)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user