mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mobile): swipe the day details to step through days (#2059)
* @ feat(mobile): swipe the day details to step through days The phone shell hangs the days off a chip rail pinned to the top of the screen, which on a large phone is a thin target a thumb cannot reach one handed. The day details fill the rest of the screen and did nothing. They now take a horizontal swipe: left for the next day, right for the previous one, with the panel following the finger, rubber banding at the first and last day and springing back when the swipe falls short. The chip rail is untouched and still the accessible path; a committed swipe announces the day it reached through a live region, and the active chip scrolls itself back into view once the rail overflows. The gesture never calls preventDefault or stops propagation on a touch event and sets no touch-action, so the browser keeps sole ownership of the card scroll and of the header chip strip, and the long press reorder bridge keeps its own 10px self cancel. In edit mode the swipe also refuses to claim a press held past the bridge window. @ * @ fix(mobile): line the map controls up and widen the POI bar The compass sat on its own under the POI bar, centre top, while the locate button floated 42px above the dock on the other end of the screen. Both are one handed controls, so they now share the band just above the dock: compass bottom left, locate bottom right, off the same --bottom-nav-h the map already reads, a dock gap lower than before. That frees the row under the chip rail, so the nearby places bar takes the full width between the screen margins and its segments end up the size of everything else the thumb aims at there. Desktop keeps the floating, content width bar. @
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Phone coverage for #2051: the day details area steps days under a horizontal
|
||||
// swipe, so the trip is navigable one-handed instead of only from the chip rail
|
||||
// pinned to the top of the screen.
|
||||
//
|
||||
// jsdom can drive the handlers but not the browser around them: whether the
|
||||
// gesture survives real touch dispatch, whether the browser hands the vertical
|
||||
// axis to its own scroller first, and whether a swipe at the first day stays put
|
||||
// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the
|
||||
// events are the ones a finger produces — not synthesised TouchEvent objects.
|
||||
//
|
||||
// Chromium, because CDP is where real touch injection lives. The file-level
|
||||
// device sets a phone viewport with a coarse pointer, which is what puts the
|
||||
// mobile shell on screen at all.
|
||||
test.use({ ...devices['Pixel 7'] })
|
||||
|
||||
/** One finger, dispatched through the browser's own input pipeline. */
|
||||
async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) {
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [{
|
||||
x: from.x + ((to.x - from.x) * i) / steps,
|
||||
y: from.y + ((to.y - from.y) * i) / steps,
|
||||
}],
|
||||
})
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await cdp.detach()
|
||||
// The out leg is 150ms and the in leg 220ms.
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
|
||||
|
||||
test('#2051 phone: the day details area swipes between days', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// Without a phone viewport and a coarse pointer the mobile shell never mounts,
|
||||
// and everything below would be proving nothing.
|
||||
const env = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
}))
|
||||
expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768)
|
||||
expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true)
|
||||
|
||||
const stamp = Date.now()
|
||||
const created = await page.request.post('/api/trips', {
|
||||
data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' },
|
||||
})
|
||||
expect(created.ok(), 'seed trip').toBeTruthy()
|
||||
const tripId = (await created.json()).trip?.id ?? (await created.json()).id
|
||||
|
||||
await page.goto(`/trips/${tripId}`)
|
||||
await expect(activeChip(page)).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const chips = page.locator('button[aria-current], button[aria-current="true"]')
|
||||
const rail = activeChip(page).locator('xpath=..')
|
||||
expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5)
|
||||
expect(await chips.count()).toBeGreaterThan(0)
|
||||
|
||||
// The swipe zone: below the chip rail, above the dock, across the middle.
|
||||
const mid = Math.round(env.width / 2)
|
||||
const y = 460
|
||||
|
||||
const day1 = (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// 1. A swipe left steps forward.
|
||||
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
|
||||
const day2 = (await activeChip(page).textContent())!.trim()
|
||||
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
|
||||
|
||||
// 2. A swipe right steps back to where it started.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
|
||||
// 3. The first day has nowhere to go back to — it must stay put, and the
|
||||
// browser must not have been handed a page-back either.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
expect(page.url()).toContain(`/trips/${tripId}`)
|
||||
|
||||
// 4. A vertical drag belongs to the card's own scroller, never to the day.
|
||||
await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
|
||||
// 5. And the rail itself still works, so the gesture added a path rather than
|
||||
// replacing one.
|
||||
await rail.locator('button').nth(2).click()
|
||||
await expect(activeChip(page)).not.toHaveText(day1)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-006
|
||||
// FE-COMP-POIPILL-001 to FE-COMP-POIPILL-008
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -57,4 +57,22 @@ describe('PoiCategoryPill', () => {
|
||||
fireEvent.click(screen.getByText('Search this area'))
|
||||
expect(onSearchArea).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The phone map hands the bar the whole width between the screen margins, so
|
||||
// its segments end up the size of everything else the thumb aims at there.
|
||||
it('FE-COMP-POIPILL-007: fullWidth stretches the bar and spreads the segments', () => {
|
||||
pill({ fullWidth: true })
|
||||
const button = screen.getAllByRole('button')[0]
|
||||
expect(button.style.flexGrow).toBe('1')
|
||||
expect(button.style.width).toBe('auto')
|
||||
expect((button.parentElement as HTMLElement).style.display).toBe('flex')
|
||||
})
|
||||
|
||||
it('FE-COMP-POIPILL-008: without it the bar stays content-width, as the desktop map floats it', () => {
|
||||
pill()
|
||||
const button = screen.getAllByRole('button')[0]
|
||||
expect(button.style.flexGrow).toBe('')
|
||||
expect(button.style.width).toBe('34px')
|
||||
expect((button.parentElement as HTMLElement).style.display).toBe('inline-flex')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,12 +12,16 @@ interface Props {
|
||||
/** true when the map moved since the last search → offer "search this area" */
|
||||
moved?: boolean
|
||||
onSearchArea?: () => void
|
||||
/** Stretch the bar across its container and spread the segments evenly.
|
||||
* The phone map gives it the full width between the screen margins; on
|
||||
* desktop it floats, so it stays content-width there. */
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea, fullWidth }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||
|
||||
@@ -29,8 +33,12 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, width: fullWidth ? '100%' : undefined }}>
|
||||
<div style={{
|
||||
display: fullWidth ? 'flex' : 'inline-flex',
|
||||
alignSelf: fullWidth ? 'stretch' : undefined,
|
||||
alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted,
|
||||
}}>
|
||||
{POI_CATEGORIES.map(cat => {
|
||||
const on = active.has(cat.key)
|
||||
// Only an active category can be loading — a deselected one whose fetch
|
||||
@@ -47,7 +55,11 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKe
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
flexGrow: fullWidth ? 1 : undefined,
|
||||
flexShrink: fullWidth ? 1 : undefined,
|
||||
flexBasis: fullWidth ? 0 : undefined,
|
||||
minWidth: fullWidth ? 0 : undefined,
|
||||
width: fullWidth ? 'auto' : 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
color: on ? '#fff' : undefined,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
|
||||
@@ -217,6 +217,27 @@ export default function MTripShell({
|
||||
planner.tripActions.setSelectedDay(findFocusDayId(days) ?? days[0].id)
|
||||
}, [planner.selectedDayId, days, planner.tripActions])
|
||||
|
||||
// Swiping the day panel (#2051) can move the day well past the chips on
|
||||
// screen — the rail overflows from roughly six days on — so the active chip
|
||||
// pulls itself back into view. Only when it is really clipped, so tapping a
|
||||
// chip you can already see never shifts the rail under your thumb.
|
||||
const chipRefs = useRef(new Map<number, HTMLButtonElement>())
|
||||
useEffect(() => {
|
||||
const chip = planner.selectedDayId != null ? chipRefs.current.get(planner.selectedDayId) : undefined
|
||||
const rail = chip?.parentElement
|
||||
if (!chip || !rail) return
|
||||
const c = chip.getBoundingClientRect()
|
||||
const r = rail.getBoundingClientRect()
|
||||
if (c.left >= r.left - 1 && c.right <= r.right + 1) return
|
||||
// block:'nearest' is not optional: the shell root is `fixed inset-0`, and
|
||||
// 'center' would scroll the document behind it instead.
|
||||
chip.scrollIntoView({
|
||||
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [planner.selectedDayId, days])
|
||||
|
||||
const trTab = planner.activeTab
|
||||
|
||||
const setTrTab = (tabId: string) => {
|
||||
@@ -333,6 +354,7 @@ export default function MTripShell({
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
ref={el => { if (el) chipRefs.current.set(day.id, el); else chipRefs.current.delete(day.id) }}
|
||||
type="button"
|
||||
onClick={() => onDayChipTap(day.id)}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
|
||||
@@ -16,9 +16,10 @@ import type { MMapAreaProps } from '../MTripShell'
|
||||
* setting) with the full desktop feature set: clusters, photo/icon markers,
|
||||
* day-order badges, dashed day route, transport overlays per booking, POI
|
||||
* explore markers and long-press → add place. Only the floating chrome is
|
||||
* mobile: the POI/compass cluster sits centre-top below the day-chip rail, and
|
||||
* the map's built-in three-state locate button is lifted above the dock via
|
||||
* the --bottom-nav-h contract it already reads.
|
||||
* mobile: the POI bar spans the full width below the day-chip rail, and the two
|
||||
* round controls share the band just above the dock — the compass on the left,
|
||||
* the map's built-in three-state locate button on the right, both riding the
|
||||
* --bottom-nav-h contract the map already reads so they cannot drift apart.
|
||||
*
|
||||
* Marker data honours the shared places category filter (#1541) because
|
||||
* planner.mapPlaces is derived from tripStore's placesCategoryFilter — the
|
||||
@@ -35,9 +36,10 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
// `isolate` keeps the map's internal z-indexes (Leaflet panes, the z-1000
|
||||
// locate button) inside this layer so they can never paint over the plan
|
||||
// timeline (z-10) or the browse/tab overlays (z-30) above it.
|
||||
// The dock is 62px tall at safe-bottom + 12, so a 104px --bottom-nav-h
|
||||
// floats the built-in locate button at the spec's 116px band above it.
|
||||
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+104px)]">
|
||||
// The dock is 62px tall at safe-bottom + 12, so a 74px --bottom-nav-h puts
|
||||
// the round controls (which add their own 12px) a dock's gap above it —
|
||||
// close enough to the thumb to reach one-handed, clear of the dock itself.
|
||||
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]">
|
||||
<MapViewAuto
|
||||
tripId={planner.tripId}
|
||||
places={planner.mapPlaces}
|
||||
@@ -74,22 +76,32 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{/* Floating map chrome — only while the map view is front-most. Centre-top,
|
||||
below the day-chip rail (safe-top + 50px + ~42px chip height). The
|
||||
compass renders on GL maps only (Leaflet can't rotate). */}
|
||||
{mapActive && (poiPillEnabled || glMap) && (
|
||||
{/* Floating map chrome — only while the map view is front-most. The POI bar
|
||||
sits below the day-chip rail (safe-top + 50px + ~42px chip height) and
|
||||
takes the full width between the screen margins, so its segments are
|
||||
the same size as everything else the thumb aims at on this screen. */}
|
||||
{mapActive && poiPillEnabled && (
|
||||
<div className="pointer-events-none absolute left-4 right-4 z-[25] flex flex-col items-center gap-2 top-[calc(var(--m-safe-top,12px)+96px)]">
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill
|
||||
active={poi.active}
|
||||
onToggle={poi.toggle}
|
||||
loadingKeys={poi.loadingKeys}
|
||||
errorKeys={poi.errorKeys}
|
||||
moved={poi.moved}
|
||||
onSearchArea={poi.searchArea}
|
||||
/>
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} />}
|
||||
<PoiCategoryPill
|
||||
fullWidth
|
||||
active={poi.active}
|
||||
onToggle={poi.toggle}
|
||||
loadingKeys={poi.loadingKeys}
|
||||
errorKeys={poi.errorKeys}
|
||||
moved={poi.moved}
|
||||
onSearchArea={poi.searchArea}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compass — GL maps only (Leaflet can't rotate). Bottom-left, mirroring
|
||||
the locate button's own `right: 12` off the same --bottom-nav-h, so the
|
||||
two round controls always sit on one line. Leaflet has no compass but
|
||||
puts its base-layer switcher in the same corner, so the band reads the
|
||||
same either way. */}
|
||||
{mapActive && glMap && (
|
||||
<div className="pointer-events-none absolute left-3 z-[25]" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 12px)' }}>
|
||||
<MapCompassPill map={glMap} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type MouseEvent } from 'react'
|
||||
import { useRef, useState, type MouseEvent } from 'react'
|
||||
import {
|
||||
ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
|
||||
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
|
||||
@@ -24,6 +24,8 @@ import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { Assignment } from '../../../../types'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
|
||||
import { isRtlLanguage } from '../../../../i18n'
|
||||
import { useMPlanDaySwipe } from './useMPlanDaySwipe'
|
||||
|
||||
/**
|
||||
* Plan-tab timeline of the mobile trip screen: the UP-NEXT card in go mode,
|
||||
@@ -85,6 +87,25 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
onMove: tl.moveRowTo,
|
||||
enabled: editing,
|
||||
})
|
||||
// Swipe the whole panel left/right to step days (#2051) — the one-handed way
|
||||
// to the next day, since the chip rail is pinned to the top of the screen.
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const daySwipe = useMPlanDaySwipe({
|
||||
days: planner.days,
|
||||
selectedDayId: planner.selectedDayId,
|
||||
// skipFit, exactly like the chip tap in plan view: the map underneath stays
|
||||
// mounted, and re-framing it here would move it somewhere nobody asked for.
|
||||
onSelectDay: dayId => planner.handleSelectDay(dayId, true),
|
||||
panelRef,
|
||||
cardRef,
|
||||
editing,
|
||||
dragging: dnd.draggingKey != null,
|
||||
menuOpen: legMenu.menu != null,
|
||||
rtl: isRtlLanguage(planner.language),
|
||||
describeDay: (i, n) => t('mobileTrip.dayAnnounce', { current: i + 1, total: n }),
|
||||
})
|
||||
|
||||
const dragFor = (row: PlanRow): RowDrag | undefined => {
|
||||
const props = dnd.dragPropsFor(row)
|
||||
if (!props) return undefined
|
||||
@@ -107,8 +128,12 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
const chrome = { editing, t, language: tl.language, timeFormat: tl.timeFormat }
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
<div ref={panelRef} className="absolute inset-0" {...daySwipe.handlers}>
|
||||
<ContextMenu menu={legMenu.menu} onClose={legMenu.close} />
|
||||
{/* Swipe-committed day changes only — a chip tap already speaks its own
|
||||
button label plus the aria-current flip, so announcing there would say
|
||||
the day twice. */}
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{daySwipe.announcement}</span>
|
||||
{!editing && <UpNextCard tl={tl} t={t} onOpen={openPlace} />}
|
||||
{editing && <EditHeader tl={tl} planner={planner} shell={shell} />}
|
||||
|
||||
@@ -116,6 +141,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
collapses that reserved space up to the day chips when the day has no
|
||||
up-next (no places), so an empty day shows no gap. */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-touch-drag={editing ? '' : undefined}
|
||||
className="absolute left-4 right-4 overflow-y-auto overscroll-contain rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-2 pt-1 backdrop-blur-[24px] backdrop-saturate-[1.6] bottom-[calc(env(safe-area-inset-bottom,0px)+90px)]"
|
||||
style={{ top: `calc(var(--m-safe-top, 12px) + ${editing ? 140 : tl.upNext ? 216 : 102}px)` }}
|
||||
@@ -377,7 +403,10 @@ function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
|
||||
const WeatherIcon = weatherIconFor(tl.weather?.main)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-[color:var(--m-rowbr)] px-0.5 py-[9px]">
|
||||
<span className="flex min-w-0 items-center gap-1.5 overflow-x-auto">
|
||||
{/* The one horizontal scroller inside the swipe zone, marked so a swipe
|
||||
that starts here is handed back to it — but only while it really
|
||||
overflows, so a short day title still swipes. */}
|
||||
<span data-hswipe-ignore className="flex min-w-0 items-center gap-1.5 overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDay}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useMPlanDaySwipe, type MPlanDaySwipeArgs } from './useMPlanDaySwipe'
|
||||
import type { Day } from '../../../../types'
|
||||
|
||||
// FE-MOB-DAYSWIPE-001 to FE-MOB-DAYSWIPE-031
|
||||
|
||||
const DAYS = [
|
||||
{ id: 11, day_number: 1 }, { id: 12, day_number: 2 }, { id: 13, day_number: 3 },
|
||||
] as unknown as Day[]
|
||||
|
||||
const onSelectDay = vi.fn()
|
||||
const onRowClick = vi.fn()
|
||||
|
||||
/** Every scroll offset the sentinel reads. jsdom does no layout, so scrollTop
|
||||
* is not settable — the harness serves it from here instead. */
|
||||
const scroll = { top: 0, left: 0 }
|
||||
|
||||
function Harness(props: Partial<MPlanDaySwipeArgs>) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const swipe = useMPlanDaySwipe({
|
||||
days: DAYS, selectedDayId: 12, onSelectDay, panelRef, cardRef,
|
||||
editing: false, dragging: false, menuOpen: false, rtl: false,
|
||||
describeDay: (i, n) => `dayAnnounce:${i + 1},${n}`,
|
||||
...props,
|
||||
})
|
||||
return (
|
||||
<div ref={panelRef} data-testid="panel" {...swipe.handlers}>
|
||||
<span data-testid="live">{swipe.announcement}</span>
|
||||
<span data-hswipe-ignore data-testid="strip" />
|
||||
<div ref={cardRef} data-testid="card">
|
||||
<button data-testid="row" onClick={onRowClick}>row</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Feeds the committed day straight back in, the way the planner does — the only
|
||||
* way to show a burst of swipes stepping day by day rather than repeating one. */
|
||||
function StatefulHarness() {
|
||||
const [dayId, setDayId] = useState(11)
|
||||
return <Harness selectedDayId={dayId} onSelectDay={id => { onSelectDay(id); setDayId(id) }} />
|
||||
}
|
||||
|
||||
const panel = () => screen.getByTestId('panel')
|
||||
const card = () => screen.getByTestId('card')
|
||||
const live = () => screen.getByTestId('live')
|
||||
|
||||
function mount(props: Partial<MPlanDaySwipeArgs> = {}) {
|
||||
return prepare(render(<Harness {...props} />))
|
||||
}
|
||||
|
||||
function mountStateful() {
|
||||
return prepare(render(<StatefulHarness />))
|
||||
}
|
||||
|
||||
function prepare<T>(view: T): T {
|
||||
const el = card()
|
||||
Object.defineProperty(el, 'scrollTop', { get: () => scroll.top, configurable: true })
|
||||
Object.defineProperty(el, 'scrollLeft', { get: () => scroll.left, configurable: true })
|
||||
// jsdom has no Element.prototype.scrollTo, which is why the hook calls it
|
||||
// optionally — the spy is what lets the reset be asserted at all.
|
||||
el.scrollTo = vi.fn()
|
||||
return view
|
||||
}
|
||||
|
||||
function overflow(el: HTMLElement, scrollWidth: number, clientWidth: number) {
|
||||
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true })
|
||||
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true })
|
||||
}
|
||||
|
||||
interface Point { x: number; y?: number; dt?: number }
|
||||
|
||||
/** Plays a touch path. The first point is the press, the rest are moves; the
|
||||
* press itself is never a move, so a path of one point is a plain tap. */
|
||||
function drag(target: HTMLElement, path: Point[]): Point {
|
||||
const [start, ...moves] = path
|
||||
fireEvent.touchStart(target, { touches: [{ clientX: start.x, clientY: start.y ?? 300 }] })
|
||||
let last = start
|
||||
for (const p of moves) {
|
||||
if (p.dt) act(() => { vi.advanceTimersByTime(p.dt!) })
|
||||
fireEvent.touchMove(target, { touches: [{ clientX: p.x, clientY: p.y ?? 300 }] })
|
||||
last = p
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
function release(target: HTMLElement, at: Point) {
|
||||
fireEvent.touchEnd(target, { touches: [], changedTouches: [{ clientX: at.x, clientY: at.y ?? 300 }] })
|
||||
}
|
||||
|
||||
/** Runs both animation legs plus the spring, so the DOM lands in its rest state. */
|
||||
function settle() {
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
}
|
||||
|
||||
/** A committing left swipe: 100px, fast enough to be a flick as well. */
|
||||
function swipeLeft(target = panel(), from = 300) {
|
||||
const last = drag(target, [{ x: from }, { x: from + 3 }, { x: from - 20, dt: 30 }, { x: from - 100, dt: 30 }])
|
||||
release(target, last)
|
||||
settle()
|
||||
}
|
||||
|
||||
function swipeRight(target = panel(), from = 300) {
|
||||
const last = drag(target, [{ x: from }, { x: from - 3 }, { x: from + 20, dt: 30 }, { x: from + 100, dt: 30 }])
|
||||
release(target, last)
|
||||
settle()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onSelectDay.mockClear()
|
||||
onRowClick.mockClear()
|
||||
scroll.top = 0
|
||||
scroll.left = 0
|
||||
vi.useFakeTimers()
|
||||
// Both animation legs hang off rAF; running it inline keeps the timers the
|
||||
// only clock the tests have to drive.
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
document.documentElement.removeAttribute('data-reduce-motion')
|
||||
})
|
||||
|
||||
describe('useMPlanDaySwipe', () => {
|
||||
describe('committing', () => {
|
||||
it('FE-MOB-DAYSWIPE-001: a swipe left steps to the next day', () => {
|
||||
mount()
|
||||
swipeLeft()
|
||||
expect(onSelectDay).toHaveBeenCalledTimes(1)
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-002: a swipe right steps to the previous day', () => {
|
||||
mount()
|
||||
swipeRight()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(11)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-003: a short, slow drag springs back to rest', () => {
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 300 }, { x: 270, dt: 300 }])
|
||||
release(panel(), last)
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
settle()
|
||||
expect(panel().style.transform).toBe('')
|
||||
expect(panel().style.transition).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-004: a flick commits short of the distance threshold', () => {
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 288, dt: 15 }, { x: 270, dt: 15 }])
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-005: a fast but tiny move is not a flick', () => {
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 288, dt: 10 }, { x: 280, dt: 10 }])
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('yielding to the gestures that were already there', () => {
|
||||
it('FE-MOB-DAYSWIPE-006: a vertical drag never touches the panel', () => {
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 302, y: 320, dt: 20 }, { x: 304, y: 420, dt: 20 }])
|
||||
expect(panel().style.transform).toBe('')
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-007: a diagonal drag inside the bias goes to the scroller', () => {
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 340, y: 338, dt: 20 }, { x: 400, y: 400, dt: 20 }])
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-008: a card that has already scrolled vertically kills the gesture', () => {
|
||||
mount()
|
||||
fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
|
||||
scroll.top = 12
|
||||
fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
|
||||
release(panel(), { x: 180 })
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
expect(panel().style.transform).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-009: a card that has already scrolled sideways kills it too', () => {
|
||||
mount()
|
||||
fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
|
||||
scroll.left = 9
|
||||
fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
|
||||
release(panel(), { x: 180 })
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-017: a swipe started on an overflowing horizontal strip is handed back', () => {
|
||||
mount()
|
||||
const strip = screen.getByTestId('strip')
|
||||
overflow(strip, 300, 200)
|
||||
const last = drag(strip, [{ x: 300 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
release(strip, last)
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-018: the same strip swipes normally while it fits', () => {
|
||||
mount()
|
||||
const strip = screen.getByTestId('strip')
|
||||
overflow(strip, 200, 200)
|
||||
const last = drag(strip, [{ x: 300 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
release(strip, last)
|
||||
settle()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-019: no touch event is ever cancelled or stopped', () => {
|
||||
mount()
|
||||
const seen: { type: string; prevented: boolean }[] = []
|
||||
const spy = (e: Event) => seen.push({ type: e.type, prevented: e.defaultPrevented })
|
||||
for (const type of ['touchstart', 'touchmove', 'touchend']) document.addEventListener(type, spy)
|
||||
try {
|
||||
swipeLeft()
|
||||
} finally {
|
||||
for (const type of ['touchstart', 'touchmove', 'touchend']) document.removeEventListener(type, spy)
|
||||
}
|
||||
// Reaching document at all proves propagation was never stopped; the drag
|
||||
// bridge's own 10px self-cancel depends on both halves of this.
|
||||
expect(seen.map(s => s.type)).toContain('touchstart')
|
||||
expect(seen.map(s => s.type)).toContain('touchmove')
|
||||
expect(seen.map(s => s.type)).toContain('touchend')
|
||||
expect(seen.every(s => !s.prevented)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refusing to arm', () => {
|
||||
it('FE-MOB-DAYSWIPE-010: the last day resists instead of wrapping', () => {
|
||||
mount({ selectedDayId: 13 })
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 280, dt: 20 }, { x: 100, dt: 20 }])
|
||||
const pulled = Math.abs(parseFloat(panel().style.transform.replace(/[^-\d.]/g, '')))
|
||||
expect(pulled).toBeLessThanOrEqual(48)
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-011: the first day resists the other way', () => {
|
||||
mount({ selectedDayId: 11 })
|
||||
swipeRight()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-012: a single-day trip never arms', () => {
|
||||
mount({ days: [DAYS[0]], selectedDayId: 11 })
|
||||
swipeLeft()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
expect(panel().style.transform).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-013: a two-finger touch never arms', () => {
|
||||
mount()
|
||||
fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }, { clientX: 340, clientY: 300 }] })
|
||||
fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
|
||||
release(panel(), { x: 180 })
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-014: a row already being dragged owns the finger', () => {
|
||||
mount({ dragging: true })
|
||||
swipeLeft()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-015: an open leg menu owns the finger', () => {
|
||||
mount({ menuOpen: true })
|
||||
swipeLeft()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-016: the screen edges are left to the browser', () => {
|
||||
mount()
|
||||
swipeLeft(panel(), 8)
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
swipeRight(panel(), window.innerWidth - 8)
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit mode', () => {
|
||||
it('FE-MOB-DAYSWIPE-028: a press held past the drag window stops being a swipe', () => {
|
||||
mount({ editing: true })
|
||||
fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
|
||||
release(panel(), { x: 180 })
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-029: go mode has no drag bridge, so a slow swipe still counts', () => {
|
||||
mount({ editing: false })
|
||||
fireEvent.touchStart(panel(), { touches: [{ clientX: 300, clientY: 300 }] })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
fireEvent.touchMove(panel(), { touches: [{ clientX: 180, clientY: 300 }] })
|
||||
release(panel(), { x: 180 })
|
||||
settle()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('right to left', () => {
|
||||
it('FE-MOB-DAYSWIPE-020: an RTL locale mirrors both directions', () => {
|
||||
mount({ rtl: true })
|
||||
swipeLeft()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(11)
|
||||
onSelectDay.mockClear()
|
||||
swipeRight()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
})
|
||||
})
|
||||
|
||||
describe('taps and clicks', () => {
|
||||
it('FE-MOB-DAYSWIPE-021: the click a swipe leaves behind never reaches a row', () => {
|
||||
mount()
|
||||
swipeLeft()
|
||||
fireEvent.click(screen.getByTestId('row'))
|
||||
expect(onRowClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-022: only that one click is swallowed', () => {
|
||||
mount()
|
||||
swipeLeft()
|
||||
fireEvent.click(screen.getByTestId('row'))
|
||||
fireEvent.click(screen.getByTestId('row'))
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-023: a plain tap opens its row, and a stray touchend does not throw', () => {
|
||||
mount()
|
||||
release(panel(), { x: 300 })
|
||||
drag(panel(), [{ x: 300 }])
|
||||
release(panel(), { x: 300 })
|
||||
fireEvent.click(screen.getByTestId('row'))
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-024: a cancelled gesture springs back and changes nothing', () => {
|
||||
mount()
|
||||
drag(panel(), [{ x: 300 }, { x: 280, dt: 20 }, { x: 200, dt: 20 }])
|
||||
fireEvent.touchCancel(panel(), { touches: [], changedTouches: [{ clientX: 200, clientY: 300 }] })
|
||||
settle()
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
expect(panel().style.transform).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('after the day changes', () => {
|
||||
it('FE-MOB-DAYSWIPE-025: the new day starts at the top of the card', () => {
|
||||
mount()
|
||||
swipeLeft()
|
||||
expect(card().scrollTo).toHaveBeenCalledWith({ top: 0 })
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-026: the live region names the day that was reached', () => {
|
||||
mount()
|
||||
swipeLeft()
|
||||
expect(live()).toHaveTextContent('dayAnnounce:3,3')
|
||||
})
|
||||
|
||||
it('FE-MOB-DAYSWIPE-027: mounting announces nothing', () => {
|
||||
mount()
|
||||
expect(live()).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduced motion', () => {
|
||||
it('FE-MOB-DAYSWIPE-030: the day still changes, the panel just never moves', () => {
|
||||
document.documentElement.setAttribute('data-reduce-motion', '')
|
||||
mount()
|
||||
const last = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
expect(panel().style.transform).toBe('')
|
||||
release(panel(), last)
|
||||
settle()
|
||||
expect(onSelectDay).toHaveBeenCalledWith(13)
|
||||
expect(live()).toHaveTextContent('dayAnnounce:3,3')
|
||||
expect(panel().style.transform).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('interruption', () => {
|
||||
it('FE-MOB-DAYSWIPE-031: a burst of swipes steps day by day, and unmounting mid-animation is quiet', () => {
|
||||
const stateful = mountStateful()
|
||||
// The second swipe starts before the first one's out-leg has landed, so
|
||||
// the interrupt has to commit day 1 → 2 before day 2 → 3 can resolve.
|
||||
const first = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
release(panel(), first)
|
||||
const second = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
release(panel(), second)
|
||||
settle()
|
||||
expect(onSelectDay.mock.calls.map(c => c[0])).toEqual([12, 13])
|
||||
stateful.unmount()
|
||||
|
||||
onSelectDay.mockClear()
|
||||
const view = mount()
|
||||
const third = drag(panel(), [{ x: 300 }, { x: 303 }, { x: 280, dt: 30 }, { x: 200, dt: 30 }])
|
||||
release(panel(), third)
|
||||
view.unmount()
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
expect(onSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import type { MouseEvent as ReactMouseEvent, RefObject, TouchEvent as ReactTouchEvent } from 'react'
|
||||
import type { Day } from '../../../../types'
|
||||
|
||||
/**
|
||||
* Swipe the mobile day panel left/right to step through the trip's days (#2051).
|
||||
*
|
||||
* The phone shell puts the days on a chip rail pinned to the top of the screen,
|
||||
* which on a large phone is a thin target a thumb cannot reach one-handed. The
|
||||
* day details fill the rest of the screen and did nothing, so this hands that
|
||||
* whole surface the same job the rail has.
|
||||
*
|
||||
* The gesture is purely observational: it never calls preventDefault and never
|
||||
* stops propagation on a touch event, and it sets no touch-action. Two reasons.
|
||||
* The browser keeps sole ownership of the card's vertical scroll, so the worst a
|
||||
* misread can do is translate the panel and spring it back. And the long-press
|
||||
* reorder bridge (utils/touchDragBridge) listens on `document`, below React's
|
||||
* root — swallowing a touchmove would starve its own 10px self-cancel and leave
|
||||
* it arming a drag on a row the finger had already left.
|
||||
*/
|
||||
|
||||
/** Movement before the gesture is classified. Same figure as SystemNoticeModal. */
|
||||
const CLASSIFY_PX = 8
|
||||
/** |dx| must beat |dy| by this to claim horizontal. Scrolling is the gesture
|
||||
* this card already had, so it wins the ties. */
|
||||
const AXIS_BIAS = 1.2
|
||||
/** Travel that commits on release. */
|
||||
const COMMIT_PX = 56
|
||||
/** A flick commits short of COMMIT_PX. 0.5 px/ms sits under MSheet's 0.6 dismiss
|
||||
* — turning a page is a smaller motion than throwing a sheet off screen. */
|
||||
const FLICK_V = 0.5
|
||||
const FLICK_MIN_PX = 24
|
||||
/** Velocity is measured over the trailing window, not the last frame: the final
|
||||
* move before lift is often a pixel or two and reads as a standstill. */
|
||||
const SAMPLE_MS = 100
|
||||
/** Rubber band: 1:1 up to FREE, then heavy, then a hard stop. */
|
||||
const RUBBER_FREE_PX = 72
|
||||
const RUBBER_FACTOR = 0.35
|
||||
const RUBBER_MAX_PX = 132
|
||||
/** On the first and last day there is nowhere to go, so the pull is far stiffer. */
|
||||
const EDGE_FACTOR = 0.18
|
||||
const EDGE_MAX_PX = 48
|
||||
/** iOS' interactive back/forward strip cannot be turned off, only avoided. The
|
||||
* card's own left edge is at 16px, so this costs its outermost 8px as a start. */
|
||||
const EDGE_GUTTER_PX = 24
|
||||
/** Kept in step with touchDragBridge's LONG_PRESS_MS. Past this window the press
|
||||
* belongs to the reorder drag. Edit mode only — go mode never installs the
|
||||
* bridge, so a leisurely one-handed swipe still counts there. */
|
||||
const PRESS_WINDOW_MS = 320
|
||||
const OUT_MS = 150
|
||||
const IN_MS = 220
|
||||
const REDUCED_MS = 120
|
||||
/** Percent of the panel width the outgoing / incoming day travels. */
|
||||
const OUT_OFFSET = 22
|
||||
const IN_OFFSET = 14
|
||||
const EASE = 'var(--ease-drawer)'
|
||||
const SPRING = 'cubic-bezier(0.34,1.56,0.64,1)'
|
||||
|
||||
type Lock = null | 'h' | 'dead'
|
||||
|
||||
interface Gesture {
|
||||
x0: number
|
||||
y0: number
|
||||
t0: number
|
||||
scrollTop0: number
|
||||
scrollLeft0: number
|
||||
lock: Lock
|
||||
samples: { x: number; t: number }[]
|
||||
}
|
||||
|
||||
function applyX(el: HTMLElement | null, px: string | null): void {
|
||||
if (!el) return
|
||||
el.style.transform = px == null ? '' : `translateX(${px})`
|
||||
}
|
||||
|
||||
function band(dx: number, free: number, factor: number, max: number): number {
|
||||
const a = Math.abs(dx)
|
||||
return Math.sign(dx) * Math.min(a <= free ? a : free + (a - free) * factor, max)
|
||||
}
|
||||
|
||||
/** The media query plus the app's own appearance toggle, which writes the same
|
||||
* attribute PluginFrame reads. */
|
||||
function reducedMotion(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (document.documentElement.hasAttribute('data-reduce-motion')) return true
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||
}
|
||||
|
||||
export interface MPlanDaySwipeArgs {
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
/** Always called with skipFit — see the call site in MPlanTimeline for why. */
|
||||
onSelectDay: (dayId: number) => void
|
||||
/** The element that gets translated: MPlanTimeline's `absolute inset-0` root. */
|
||||
panelRef: RefObject<HTMLElement | null>
|
||||
/** The frosted timeline card. Its scroll offsets are how we ask the browser
|
||||
* whether it has already claimed the gesture. */
|
||||
cardRef: RefObject<HTMLElement | null>
|
||||
/** Edit mode: the long-press reorder bridge is installed and competes. */
|
||||
editing: boolean
|
||||
/** True once the bridge has armed and fired its dragstart. */
|
||||
dragging: boolean
|
||||
/** True while the per-leg travel-mode menu is open. */
|
||||
menuOpen: boolean
|
||||
/** Flips next/prev, same rule the notice pager uses. */
|
||||
rtl: boolean
|
||||
/** Live-region text for a committed swipe. */
|
||||
describeDay: (index: number, total: number) => string
|
||||
}
|
||||
|
||||
export interface MPlanDaySwipe {
|
||||
/** Announced to screen readers after a swipe commits — empty until then. */
|
||||
announcement: string
|
||||
handlers: {
|
||||
onTouchStart: (e: ReactTouchEvent) => void
|
||||
onTouchMove: (e: ReactTouchEvent) => void
|
||||
onTouchEnd: (e: ReactTouchEvent) => void
|
||||
onTouchCancel: () => void
|
||||
onClickCapture: (e: ReactMouseEvent) => void
|
||||
}
|
||||
}
|
||||
|
||||
export function useMPlanDaySwipe({
|
||||
days, selectedDayId, onSelectDay, panelRef, cardRef,
|
||||
editing, dragging, menuOpen, rtl, describeDay,
|
||||
}: MPlanDaySwipeArgs): MPlanDaySwipe {
|
||||
const gesture = useRef<Gesture | null>(null)
|
||||
const swallowClick = useRef(false)
|
||||
/** The one scheduled animation step. Never more than one is outstanding. */
|
||||
const pending = useRef<{ timer: number; run: () => void } | null>(null)
|
||||
const alive = useRef(true)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// The handlers are stable callbacks reading per-render values, so a ref mirror
|
||||
// keeps them current. `dragging` in particular has to be read this way: the
|
||||
// reorder hook's setDraggingKey runs inside the dragstart the bridge fires, so
|
||||
// a render-scoped read lags by a commit — exactly the window we bail in.
|
||||
const latest = useRef({ days, selectedDayId, onSelectDay, editing, dragging, menuOpen, rtl, describeDay })
|
||||
latest.current = { days, selectedDayId, onSelectDay, editing, dragging, menuOpen, rtl, describeDay }
|
||||
|
||||
const cancelPending = useCallback(() => {
|
||||
const p = pending.current
|
||||
pending.current = null
|
||||
if (p) window.clearTimeout(p.timer)
|
||||
}, [])
|
||||
|
||||
/** Terminal state: byte-identical to the idle DOM. Never queues another step —
|
||||
* a settle that re-enters the queue is what makes a spring-back recurse. */
|
||||
const settle = useCallback(() => {
|
||||
// A live drag owns the panel: a stale timer left over from the previous
|
||||
// animation must not wipe the transform out from under the finger.
|
||||
if (gesture.current?.lock === 'h') return
|
||||
const el = panelRef.current
|
||||
if (!el) return
|
||||
el.style.transition = ''
|
||||
el.style.transform = ''
|
||||
}, [panelRef])
|
||||
|
||||
const schedule = useCallback((ms: number, run: () => void) => {
|
||||
cancelPending()
|
||||
const timer = window.setTimeout(() => { pending.current = null; run() }, ms)
|
||||
pending.current = { timer, run }
|
||||
}, [cancelPending])
|
||||
|
||||
/** Runs the outstanding step now. Interruption only — never on unmount, where
|
||||
* it would land in a tree that is already tearing down. */
|
||||
const finishPending = useCallback(() => {
|
||||
const p = pending.current
|
||||
pending.current = null
|
||||
if (!p) return
|
||||
window.clearTimeout(p.timer)
|
||||
p.run()
|
||||
}, [])
|
||||
|
||||
/** Resolved at release from the live days array, by index — never from an index
|
||||
* captured at touchstart, and never day_number ± 1: day_number is optional and
|
||||
* a delete leaves gaps. */
|
||||
const targetFor = useCallback((dx: number): number | null => {
|
||||
const s = latest.current
|
||||
// Finger to the left means forward in LTR; RTL mirrors it.
|
||||
const step = (dx < 0) === !s.rtl ? 1 : -1
|
||||
const i = s.days.findIndex(d => d.id === s.selectedDayId)
|
||||
// Dangling selection: a remote day:deleted drops the day but leaves
|
||||
// selectedDayId pointing at it, which shows a blank timeline. A swipe is a
|
||||
// cheap way back out of that.
|
||||
if (i < 0) return s.days[0]?.id ?? null
|
||||
return s.days[i + step]?.id ?? null
|
||||
}, [])
|
||||
|
||||
/** Commits the day and parks the panel where the incoming leg starts. */
|
||||
const handoff = useCallback((dayId: number, fromPct: number | null) => {
|
||||
if (!alive.current) return
|
||||
const el = panelRef.current
|
||||
if (el && fromPct != null) {
|
||||
el.style.transition = 'none'
|
||||
applyX(el, `${fromPct}%`)
|
||||
}
|
||||
const s = latest.current
|
||||
const idx = s.days.findIndex(d => d.id === dayId)
|
||||
const apply = () => {
|
||||
s.onSelectDay(dayId)
|
||||
if (idx >= 0) setAnnouncement(s.describeDay(idx, s.days.length))
|
||||
}
|
||||
// Flushed so the new day is in the DOM before the incoming leg is armed in
|
||||
// the same frame; otherwise one frame paints the old day off-centre.
|
||||
if (fromPct == null) apply()
|
||||
else flushSync(apply)
|
||||
// The new day starts at the top.
|
||||
cardRef.current?.scrollTo?.({ top: 0 })
|
||||
|
||||
if (!el || fromPct == null) { settle(); return }
|
||||
requestAnimationFrame(() => {
|
||||
if (!alive.current) return
|
||||
el.style.transition = `transform ${IN_MS}ms ${EASE}`
|
||||
applyX(el, '0px')
|
||||
// transitionend does not fire in a backgrounded tab, under a display:none
|
||||
// ancestor, or in jsdom. The timer is both the production net and what the
|
||||
// tests drive.
|
||||
schedule(IN_MS + 40, settle)
|
||||
})
|
||||
}, [cardRef, panelRef, schedule, settle])
|
||||
|
||||
const commit = useCallback((dx: number): boolean => {
|
||||
const target = targetFor(dx)
|
||||
if (target == null) return false
|
||||
const el = panelRef.current
|
||||
|
||||
if (!el || reducedMotion()) {
|
||||
handoff(target, null)
|
||||
// Gentler, not zero — the same call mobile.css makes for its own reduced
|
||||
// path. The fade goes on the card, never on the panel: an ancestor with
|
||||
// opacity < 1 becomes a backdrop root and cancels the card's own blur.
|
||||
const card = cardRef.current
|
||||
if (card) {
|
||||
card.style.transition = 'none'
|
||||
card.style.opacity = '0'
|
||||
requestAnimationFrame(() => {
|
||||
if (!alive.current) return
|
||||
card.style.transition = `opacity ${REDUCED_MS}ms ease-out`
|
||||
card.style.opacity = ''
|
||||
window.setTimeout(() => { card.style.transition = '' }, REDUCED_MS + 20)
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const forward = dx < 0
|
||||
el.style.transition = `transform ${OUT_MS}ms ease-out`
|
||||
applyX(el, `${forward ? -OUT_OFFSET : OUT_OFFSET}%`)
|
||||
schedule(OUT_MS, () => handoff(target, forward ? IN_OFFSET : -IN_OFFSET))
|
||||
return true
|
||||
}, [cardRef, handoff, panelRef, schedule, targetFor])
|
||||
|
||||
const springBack = useCallback(() => {
|
||||
const el = panelRef.current
|
||||
if (!el) return
|
||||
if (reducedMotion()) { settle(); return }
|
||||
el.style.transition = `transform 300ms ${SPRING}`
|
||||
applyX(el, '0px')
|
||||
schedule(340, settle)
|
||||
}, [panelRef, schedule, settle])
|
||||
|
||||
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
||||
// Interruption lands the outstanding commit rather than dropping it, so
|
||||
// three swipes in a burst move three days.
|
||||
finishPending()
|
||||
swallowClick.current = false
|
||||
gesture.current = null
|
||||
const s = latest.current
|
||||
if (e.touches.length !== 1) return
|
||||
if (s.days.length < 2 || s.dragging || s.menuOpen) return
|
||||
const touch = e.touches[0]
|
||||
if (touch.clientX < EDGE_GUTTER_PX || touch.clientX > window.innerWidth - EDGE_GUTTER_PX) return
|
||||
// The header chip strip owns its own pan, but only while it really overflows
|
||||
// — a short day title with no hotel chips still swipes.
|
||||
const strip = (e.target as Element | null)?.closest?.('[data-hswipe-ignore]')
|
||||
if (strip && strip.scrollWidth > strip.clientWidth + 1) return
|
||||
const now = Date.now()
|
||||
gesture.current = {
|
||||
x0: touch.clientX, y0: touch.clientY, t0: now,
|
||||
scrollTop0: cardRef.current?.scrollTop ?? 0,
|
||||
scrollLeft0: cardRef.current?.scrollLeft ?? 0,
|
||||
lock: null,
|
||||
samples: [{ x: touch.clientX, t: now }],
|
||||
}
|
||||
}, [cardRef, finishPending])
|
||||
|
||||
const onTouchMove = useCallback((e: ReactTouchEvent) => {
|
||||
const g = gesture.current
|
||||
if (!g || g.lock === 'dead' || e.touches.length !== 1) return
|
||||
const touch = e.touches[0]
|
||||
const now = Date.now()
|
||||
const dx = touch.clientX - g.x0
|
||||
const dy = touch.clientY - g.y0
|
||||
g.samples.push({ x: touch.clientX, t: now })
|
||||
while (g.samples.length > 2 && now - g.samples[0].t > SAMPLE_MS) g.samples.shift()
|
||||
|
||||
if (g.lock === null) {
|
||||
const card = cardRef.current
|
||||
// The browser has already committed this gesture to scrolling, so obey it.
|
||||
// scrollLeft counts too: overflow-y-auto on its own leaves overflow-x
|
||||
// computing to auto, and a wide markdown table in a day note fills it.
|
||||
if (card && (card.scrollTop !== g.scrollTop0 || card.scrollLeft !== g.scrollLeft0)) {
|
||||
g.lock = 'dead'
|
||||
return
|
||||
}
|
||||
if (Math.abs(dx) <= CLASSIFY_PX && Math.abs(dy) <= CLASSIFY_PX) return
|
||||
const s = latest.current
|
||||
// Edit mode only: past the bridge's press window this finger belongs to a
|
||||
// reorder drag. Go mode has no bridge, so a slow swipe still counts.
|
||||
if (s.dragging || (s.editing && now - g.t0 > PRESS_WINDOW_MS)) { g.lock = 'dead'; return }
|
||||
g.lock = Math.abs(dx) >= Math.abs(dy) * AXIS_BIAS ? 'h' : 'dead'
|
||||
if (g.lock === 'dead') return
|
||||
}
|
||||
|
||||
if (reducedMotion()) return // classify and commit, but never follow the finger
|
||||
const el = panelRef.current
|
||||
if (!el) return
|
||||
el.style.transition = 'none'
|
||||
applyX(el, `${targetFor(dx) == null
|
||||
? band(dx, 0, EDGE_FACTOR, EDGE_MAX_PX)
|
||||
: band(dx, RUBBER_FREE_PX, RUBBER_FACTOR, RUBBER_MAX_PX)}px`)
|
||||
}, [cardRef, panelRef, targetFor])
|
||||
|
||||
const onTouchEnd = useCallback((e: ReactTouchEvent) => {
|
||||
const g = gesture.current
|
||||
gesture.current = null
|
||||
if (!g || g.lock !== 'h') return
|
||||
// The finger travelled far enough sideways to be a swipe rather than a tap,
|
||||
// so the click the browser may still send is not one the rows should see.
|
||||
swallowClick.current = true
|
||||
const dx = (e.changedTouches[0]?.clientX ?? g.x0) - g.x0
|
||||
const first = g.samples[0]
|
||||
const last = g.samples[g.samples.length - 1]
|
||||
const v = (last.x - first.x) / Math.max(1, last.t - first.t)
|
||||
const far = Math.abs(dx) > COMMIT_PX
|
||||
const flick = Math.abs(v) > FLICK_V && Math.abs(dx) > FLICK_MIN_PX && Math.sign(v) === Math.sign(dx)
|
||||
if ((far || flick) && commit(dx)) return
|
||||
springBack()
|
||||
}, [commit, springBack])
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
const g = gesture.current
|
||||
gesture.current = null
|
||||
if (g?.lock === 'h') springBack()
|
||||
}, [springBack])
|
||||
|
||||
/** Root-scoped on purpose, unlike the drag bridge's one-shot document listener:
|
||||
* that one eats an innocent click 400ms later whenever the expected click never
|
||||
* arrives, and it would also swallow the document click the leg menu closes on.
|
||||
* The flag is cleared on every fresh touchstart, so it cannot go stale. */
|
||||
const onClickCapture = useCallback((e: ReactMouseEvent) => {
|
||||
if (!swallowClick.current) return
|
||||
swallowClick.current = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
return () => { alive.current = false; cancelPending() }
|
||||
}, [cancelPending])
|
||||
|
||||
return {
|
||||
announcement,
|
||||
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, onClickCapture },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
|
||||
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-007
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
poi: {} as Record<string, unknown>,
|
||||
/** Handed to onMapReady, standing in for a GL map that can rotate. */
|
||||
glMap: null as CompassMap | null,
|
||||
/** Last props the area handed the renderer, so its callbacks can be fired. */
|
||||
props: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
// The real renderer boots Leaflet/MapLibre; the area only ever hands it props.
|
||||
vi.mock('../../../../src/components/Map/MapViewAuto', () => ({
|
||||
MapViewAuto: (props: Record<string, unknown>) => {
|
||||
mocks.props = props
|
||||
;(props.onMapReady as ((map: CompassMap | null) => void) | undefined)?.(mocks.glMap)
|
||||
return <div data-testid="map-renderer" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../../src/components/Map/usePoiExplore', () => ({
|
||||
usePoiExplore: () => mocks.poi,
|
||||
}))
|
||||
|
||||
import MMapArea from '../../../../src/mobile/screens/trip/map/MMapArea'
|
||||
|
||||
const COMPASS: CompassMap = {
|
||||
getBearing: () => 0,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
easeTo: vi.fn(),
|
||||
}
|
||||
|
||||
function renderArea(shellOver: Partial<MTripShellApi> = {}, plannerOver: Partial<TripPlanner> = {}) {
|
||||
const planner = buildPlanner(plannerOver)
|
||||
const shell = buildShell({ view: 'map', ...shellOver })
|
||||
return { planner, shell, ...render(<MMapArea planner={planner} shell={shell} />) }
|
||||
}
|
||||
|
||||
/** The compass wrapper is the only element carrying an inline bottom offset. */
|
||||
const compassBand = (container: HTMLElement) =>
|
||||
container.querySelector('[style*="--bottom-nav-h"]') as HTMLElement | null
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.glMap = COMPASS
|
||||
mocks.poi = {
|
||||
active: new Set<string>(), pois: [], loadingKeys: new Set<string>(), errorKeys: new Set<string>(),
|
||||
moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
|
||||
}
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: true } }))
|
||||
})
|
||||
|
||||
describe('MMapArea', () => {
|
||||
it('FE-MOB-MAPAREA-001: the POI bar takes the full width between the screen margins', () => {
|
||||
renderArea()
|
||||
|
||||
const segment = screen.getAllByRole('button')[0]
|
||||
expect(segment.style.flexGrow).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-002: the compass rides the same bottom offset as the locate button', () => {
|
||||
const { container } = renderArea()
|
||||
|
||||
// LocationButton hard-codes `right: 12` off the same variable, so matching
|
||||
// the offset here is what keeps the two round controls on one line.
|
||||
expect(compassBand(container)?.style.bottom).toBe('calc(var(--bottom-nav-h, 84px) + 12px)')
|
||||
expect(compassBand(container)?.className).toContain('left-3')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-003: the map layer floats those controls a dock gap above the dock', () => {
|
||||
const { container } = renderArea()
|
||||
|
||||
// The dock is 62px tall at safe-bottom + 12; the controls add their own 12.
|
||||
expect((container.firstElementChild as HTMLElement).className)
|
||||
.toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]')
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-004: a renderer that cannot rotate gets no compass', () => {
|
||||
mocks.glMap = null
|
||||
const { container } = renderArea()
|
||||
|
||||
expect(compassBand(container)).toBeNull()
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-005: turning the POI bar off leaves the compass alone', () => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: false } }))
|
||||
const { container } = renderArea()
|
||||
|
||||
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
||||
expect(compassBand(container)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-006: no floating chrome while the timeline covers the map', () => {
|
||||
const { container } = renderArea({ view: 'plan' })
|
||||
|
||||
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
|
||||
expect(compassBand(container)).toBeNull()
|
||||
// The renderer itself stays mounted so tiles and markers keep their warmth.
|
||||
expect(screen.getByTestId('map-renderer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MAPAREA-007: a transport overlay tap opens the mobile transport sheet', () => {
|
||||
const { shell } = renderArea()
|
||||
|
||||
;(mocks.props.onReservationClick as (id: number) => void)(7)
|
||||
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, within } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { PluginDaySchedule } from '../../../../src/components/Plugins/PluginDaySchedule'
|
||||
import type { MPlanTimelineController } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
|
||||
@@ -9,7 +9,7 @@ import type { MergedItem } from '../../../../src/utils/dayMerge'
|
||||
import type { Assignment, Day, DayNote, Place, RouteSegment } from '../../../../src/types'
|
||||
import MPlanTimeline from '../../../../src/mobile/screens/trip/plan/MPlanTimeline'
|
||||
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-038
|
||||
// FE-MOB-PLTL-001 to FE-MOB-PLTL-045
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
tl: {} as Record<string, unknown>,
|
||||
@@ -572,4 +572,82 @@ describe('MPlanTimeline', () => {
|
||||
expect(mocks.tl.openTransitJourney).toHaveBeenCalledWith(TRANSIT_RES)
|
||||
})
|
||||
})
|
||||
describe('day swipe (#2051)', () => {
|
||||
const DAYS = [
|
||||
{ id: 1, trip_id: 1, day_number: 1 }, DAY, { id: 3, trip_id: 1, day_number: 3 },
|
||||
] as unknown as Day[]
|
||||
|
||||
const panel = (container: HTMLElement) => container.firstElementChild as HTMLElement
|
||||
|
||||
/** A committing left swipe across the whole panel. */
|
||||
function swipe(el: HTMLElement, from = 300, to = 180) {
|
||||
fireEvent.touchStart(el, { touches: [{ clientX: from, clientY: 300 }] })
|
||||
fireEvent.touchMove(el, { touches: [{ clientX: from - 20, clientY: 300 }] })
|
||||
fireEvent.touchMove(el, { touches: [{ clientX: to, clientY: 300 }] })
|
||||
fireEvent.touchEnd(el, { touches: [], changedTouches: [{ clientX: to, clientY: 300 }] })
|
||||
}
|
||||
|
||||
it('FE-MOB-PLTL-039: swiping the panel selects the next day without re-framing the map', async () => {
|
||||
const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
swipe(panel(container))
|
||||
|
||||
// skipFit must be true: the map stays mounted under the timeline, and
|
||||
// re-fitting it here would move it somewhere nobody asked for.
|
||||
await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-040: the header chip strip is excluded from the swipe zone', () => {
|
||||
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
expect(container.querySelector('[data-hswipe-ignore]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-041: the live region is present and silent until a swipe lands', () => {
|
||||
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
const live = container.querySelector('[role="status"]') as HTMLElement
|
||||
expect(live).toHaveAttribute('aria-live', 'polite')
|
||||
expect(live).toHaveAttribute('aria-atomic', 'true')
|
||||
expect(live).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-042: a committed swipe announces the day it reached', async () => {
|
||||
const { container } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
swipe(panel(container))
|
||||
|
||||
await waitFor(() => expect(container.querySelector('[role="status"]'))
|
||||
.toHaveTextContent('mobileTrip.dayAnnounce:3,3'))
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-043: edit mode keeps both the swipe and the long-press reorder', async () => {
|
||||
const { container, planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 }, { mode: 'edit' })
|
||||
|
||||
swipe(panel(container))
|
||||
|
||||
await waitFor(() => expect(planner.handleSelectDay).toHaveBeenCalledWith(3, true))
|
||||
expect(card(container)).toHaveAttribute('data-touch-drag')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-044: a plain tap on a place row still opens it', () => {
|
||||
const { planner } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
fireEvent.click(screen.getByText('Ueno Park'))
|
||||
|
||||
expect(planner.handlePlaceClick).toHaveBeenCalledWith(102, 12)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-045: nothing narrows touch-action, and the excluded strip stays tappable', () => {
|
||||
const { container, shell } = renderTimeline({}, { days: DAYS, selectedDayId: 2 })
|
||||
|
||||
// touch-action intersects down the tree, so a pan-y here would take the
|
||||
// header strip's own horizontal pan with it.
|
||||
expect(panel(container).style.touchAction).toBe('')
|
||||
expect(card(container).style.touchAction).toBe('')
|
||||
|
||||
fireEvent.click(screen.getByLabelText('day.overview'))
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '../../../helpers/render'
|
||||
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-034
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-046
|
||||
|
||||
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
|
||||
|
||||
@@ -461,6 +461,49 @@ describe('MTripShell', () => {
|
||||
act(() => { shellApi.openSheet('note', { dayId: 12 }) })
|
||||
expect(shellApi.sheet).toEqual({ id: 'note', payload: { dayId: 12 } })
|
||||
})
|
||||
|
||||
// The chip rail overflows from roughly six days on, and swiping the day panel
|
||||
// (#2051) can move the day well past what is on screen.
|
||||
describe('chip rail auto-scroll', () => {
|
||||
const RAIL = { left: 0, right: 300 } as DOMRect
|
||||
/** Rects are all zero without layout, so the two boxes are stubbed by role. */
|
||||
function stubRects(chip: Partial<DOMRect>) {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
return (this.tagName === 'BUTTON' ? { ...RAIL, ...chip } : RAIL) as DOMRect
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('FE-MOB-SHELL-044: a clipped active chip pulls itself into the middle of the rail', () => {
|
||||
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
|
||||
scrollIntoView.mockClear()
|
||||
stubRects({ left: 480, right: 540 })
|
||||
renderShell({ selectedDayId: 12 } as Partial<TripPlanner>)
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-045: a chip already in view never shifts the rail under the thumb', () => {
|
||||
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
|
||||
scrollIntoView.mockClear()
|
||||
stubRects({ left: 40, right: 100 })
|
||||
renderShell({ selectedDayId: 12 } as Partial<TripPlanner>)
|
||||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-046: reduced motion jumps instead of gliding', () => {
|
||||
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView)
|
||||
scrollIntoView.mockClear()
|
||||
stubRects({ left: 480, right: 540 })
|
||||
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
|
||||
matches: query.includes('reduced-motion'), media: query, onchange: null,
|
||||
addListener: vi.fn(), removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
|
||||
}) as unknown as MediaQueryList)
|
||||
renderShell({ selectedDayId: 12 } as Partial<TripPlanner>)
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildShell fixture', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'لا توجد حجوزات بعد',
|
||||
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
|
||||
'mobileTrip.compactView': 'عرض مضغوط',
|
||||
'mobileTrip.dayAnnounce': 'اليوم {current} من {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'عنوان اليوم',
|
||||
'mobileTrip.export': 'تصدير',
|
||||
'mobileTrip.filesEmpty': 'لا توجد ملفات بعد',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Nenhuma reserva ainda',
|
||||
'mobileTrip.collabFeatureDisabled': 'Esse recurso está desativado para esta viagem',
|
||||
'mobileTrip.compactView': 'Visualização compacta',
|
||||
'mobileTrip.dayAnnounce': 'Dia {current} de {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Título do dia',
|
||||
'mobileTrip.export': 'Exportar',
|
||||
'mobileTrip.filesEmpty': 'Nenhum arquivo ainda',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Encara no hi ha reserves',
|
||||
'mobileTrip.collabFeatureDisabled': 'Aquesta funció està desactivada per a aquest viatge',
|
||||
'mobileTrip.compactView': 'Vista compacta',
|
||||
'mobileTrip.dayAnnounce': 'Dia {current} de {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Títol del dia',
|
||||
'mobileTrip.export': 'Exporta',
|
||||
'mobileTrip.filesEmpty': 'Encara no hi ha fitxers',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Zatím žádné rezervace',
|
||||
'mobileTrip.collabFeatureDisabled': 'Tato funkce je pro tento výlet vypnuta',
|
||||
'mobileTrip.compactView': 'Kompaktní zobrazení',
|
||||
'mobileTrip.dayAnnounce': 'Den {current} z {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Název dne',
|
||||
'mobileTrip.export': 'Exportovat',
|
||||
'mobileTrip.filesEmpty': 'Zatím žádné soubory',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Noch keine Buchungen',
|
||||
'mobileTrip.collabFeatureDisabled': 'Diese Funktion ist für diese Reise deaktiviert',
|
||||
'mobileTrip.compactView': 'Kompakte Ansicht',
|
||||
'mobileTrip.dayAnnounce': 'Tag {current} von {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tagestitel',
|
||||
'mobileTrip.export': 'Exportieren',
|
||||
'mobileTrip.filesEmpty': 'Keine Dateien vorhanden',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'No bookings yet',
|
||||
'mobileTrip.collabFeatureDisabled': 'This feature is disabled for this trip',
|
||||
'mobileTrip.compactView': 'Compact view',
|
||||
'mobileTrip.dayAnnounce': 'Day {current} of {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Day title',
|
||||
'mobileTrip.export': 'Export',
|
||||
'mobileTrip.filesEmpty': 'No files yet',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Aún no hay reservas',
|
||||
'mobileTrip.collabFeatureDisabled': 'Esta función está desactivada para este viaje',
|
||||
'mobileTrip.compactView': 'Vista compacta',
|
||||
'mobileTrip.dayAnnounce': 'Día {current} de {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Título del día',
|
||||
'mobileTrip.export': 'Exportar',
|
||||
'mobileTrip.filesEmpty': 'Aún no hay archivos',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Aucune réservation',
|
||||
'mobileTrip.collabFeatureDisabled': 'Cette fonctionnalité est désactivée pour ce voyage',
|
||||
'mobileTrip.compactView': 'Vue compacte',
|
||||
'mobileTrip.dayAnnounce': 'Jour {current} sur {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titre du jour',
|
||||
'mobileTrip.export': 'Exporter',
|
||||
'mobileTrip.filesEmpty': 'Aucun fichier',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Δεν υπάρχουν κρατήσεις ακόμη',
|
||||
'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι',
|
||||
'mobileTrip.compactView': 'Συμπαγής προβολή',
|
||||
'mobileTrip.dayAnnounce': 'Ημέρα {current} από {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Τίτλος ημέρας',
|
||||
'mobileTrip.export': 'Εξαγωγή',
|
||||
'mobileTrip.filesEmpty': 'Δεν υπάρχουν αρχεία ακόμη',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Még nincsenek foglalások',
|
||||
'mobileTrip.collabFeatureDisabled': 'Ez a funkció ki van kapcsolva ennél az utazásnál',
|
||||
'mobileTrip.compactView': 'Kompakt nézet',
|
||||
'mobileTrip.dayAnnounce': '{current}. nap / {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Nap címe',
|
||||
'mobileTrip.export': 'Exportálás',
|
||||
'mobileTrip.filesEmpty': 'Még nincsenek fájlok',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Belum ada reservasi',
|
||||
'mobileTrip.collabFeatureDisabled': 'Fitur ini dinonaktifkan untuk perjalanan ini',
|
||||
'mobileTrip.compactView': 'Tampilan ringkas',
|
||||
'mobileTrip.dayAnnounce': 'Hari {current} dari {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Judul hari',
|
||||
'mobileTrip.export': 'Ekspor',
|
||||
'mobileTrip.filesEmpty': 'Belum ada file',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Ancora nessuna prenotazione',
|
||||
'mobileTrip.collabFeatureDisabled': 'Questa funzione è disattivata per questo viaggio',
|
||||
'mobileTrip.compactView': 'Vista compatta',
|
||||
'mobileTrip.dayAnnounce': 'Giorno {current} di {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titolo del giorno',
|
||||
'mobileTrip.export': 'Esporta',
|
||||
'mobileTrip.filesEmpty': 'Ancora nessun file',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': '予約はまだありません',
|
||||
'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています',
|
||||
'mobileTrip.compactView': 'コンパクト表示',
|
||||
'mobileTrip.dayAnnounce': '{current}日目 / 全{total}日',
|
||||
'mobileTrip.dayTitlePlaceholder': '日のタイトル',
|
||||
'mobileTrip.export': '書き出し',
|
||||
'mobileTrip.filesEmpty': 'ファイルはまだありません',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': '아직 예약이 없습니다',
|
||||
'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다',
|
||||
'mobileTrip.compactView': '간략히 보기',
|
||||
'mobileTrip.dayAnnounce': '{total}일 중 {current}일째',
|
||||
'mobileTrip.dayTitlePlaceholder': '일차 제목',
|
||||
'mobileTrip.export': '내보내기',
|
||||
'mobileTrip.filesEmpty': '아직 파일이 없습니다',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Nog geen boekingen',
|
||||
'mobileTrip.collabFeatureDisabled': 'Deze functie is uitgeschakeld voor deze reis',
|
||||
'mobileTrip.compactView': 'Compacte weergave',
|
||||
'mobileTrip.dayAnnounce': 'Dag {current} van {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titel van de dag',
|
||||
'mobileTrip.export': 'Exporteren',
|
||||
'mobileTrip.filesEmpty': 'Nog geen bestanden',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Brak rezerwacji',
|
||||
'mobileTrip.collabFeatureDisabled': 'Ta funkcja jest wyłączona dla tej podróży',
|
||||
'mobileTrip.compactView': 'Widok kompaktowy',
|
||||
'mobileTrip.dayAnnounce': 'Dzień {current} z {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tytuł dnia',
|
||||
'mobileTrip.export': 'Eksportuj',
|
||||
'mobileTrip.filesEmpty': 'Brak plików',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Пока нет бронирований',
|
||||
'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки',
|
||||
'mobileTrip.compactView': 'Компактный вид',
|
||||
'mobileTrip.dayAnnounce': 'День {current} из {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Название дня',
|
||||
'mobileTrip.export': 'Экспорт',
|
||||
'mobileTrip.filesEmpty': 'Файлов пока нет',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Inga bokningar än',
|
||||
'mobileTrip.collabFeatureDisabled': 'Den här funktionen är inaktiverad för den här resan',
|
||||
'mobileTrip.compactView': 'Kompakt vy',
|
||||
'mobileTrip.dayAnnounce': 'Dag {current} av {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Dagens titel',
|
||||
'mobileTrip.export': 'Exportera',
|
||||
'mobileTrip.filesEmpty': 'Inga filer ännu',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Henüz rezervasyon yok',
|
||||
'mobileTrip.collabFeatureDisabled': 'Bu özellik bu seyahat için devre dışı',
|
||||
'mobileTrip.compactView': 'Kompakt görünüm',
|
||||
'mobileTrip.dayAnnounce': 'Gün {current} / {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Gün başlığı',
|
||||
'mobileTrip.export': 'Dışa aktar',
|
||||
'mobileTrip.filesEmpty': 'Henüz dosya yok',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Поки немає бронювань',
|
||||
'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі',
|
||||
'mobileTrip.compactView': 'Компактний вигляд',
|
||||
'mobileTrip.dayAnnounce': 'День {current} з {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Назва дня',
|
||||
'mobileTrip.export': 'Експорт',
|
||||
'mobileTrip.filesEmpty': 'Файлів поки немає',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': 'Chưa có đặt chỗ nào',
|
||||
'mobileTrip.collabFeatureDisabled': 'Tính năng này bị tắt cho chuyến đi này',
|
||||
'mobileTrip.compactView': 'Chế độ xem gọn',
|
||||
'mobileTrip.dayAnnounce': 'Ngày {current} trong {total}',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tiêu đề ngày',
|
||||
'mobileTrip.export': 'Xuất',
|
||||
'mobileTrip.filesEmpty': 'Chưa có tập tin nào',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': '暫無預訂',
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用',
|
||||
'mobileTrip.compactView': '精簡檢視',
|
||||
'mobileTrip.dayAnnounce': '第 {current} 天,共 {total} 天',
|
||||
'mobileTrip.dayTitlePlaceholder': '天數標題',
|
||||
'mobileTrip.export': '匯出',
|
||||
'mobileTrip.filesEmpty': '暫無檔案',
|
||||
|
||||
@@ -10,6 +10,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.bookingsEmpty': '暂无预订',
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用',
|
||||
'mobileTrip.compactView': '紧凑视图',
|
||||
'mobileTrip.dayAnnounce': '第 {current} 天,共 {total} 天',
|
||||
'mobileTrip.dayTitlePlaceholder': '天数标题',
|
||||
'mobileTrip.export': '导出',
|
||||
'mobileTrip.filesEmpty': '暂无文件',
|
||||
|
||||
Reference in New Issue
Block a user