feat(mobile): drag to reorder the day plan, and reach the day sheet from any day

Reordering a day on the phone meant two 15px arrows, while the same list on the
web takes a drag. It takes one here too now, via the long-press bridge tablets
already use (#1616): it watches only draggable rows inside an opted-in
container, waits out a press before claiming the gesture — so a swipe stays a
scroll — and replays real drag events, which is why the rows only needed the
receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps
and map pans are untouched and the polyfill retired in #1432 stays retired. The
arrows stay: they are the accessible path.

Both paths go through one mover, so the chronology guard and the undo entry are
the same either way.

The card header was also the only route into the day sheet, and it rendered only
when the day happened to have an accommodation or a weather reading — with the
accommodation chip as the sole tap target. A day with neither had no way in.
A day pill now leads that row unconditionally, the weather reading is a target
too, and the active day chip carries a chevron so the second tap that opens the
sheet is visible in map view as well.

Closes #1997
Closes #2004
This commit is contained in:
Maurice
2026-08-21 12:48:04 +02:00
committed by Maurice
parent 2d6c8c5157
commit 331954c1b0
5 changed files with 349 additions and 25 deletions
@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react'
import {
ArrowRight, ArrowUpRight, BedDouble, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
ArrowRight, BedDouble, CalendarDays, CalendarRange, ChevronRight, Compass, LogIn, LogOut,
MapPin, Pencil, PencilLine, Route, Ticket, TrainFront, Undo2,
Car, Footprints, Zap, RotateCcw,
} from 'lucide-react'
@@ -10,14 +10,20 @@ import { fmtTransitDuration } from '../../../../components/Planner/transitDispla
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
import { cityPillsForDay, weatherIconFor } from './planTimelineModel'
import type { PlanRow } from './planTimelineModel'
import { useMPlanDragReorder } from './useMPlanDragReorder'
import { useTouchDragBridge } from '../../../../hooks/useTouchDragBridge'
import { useIsTouch } from '../../../../hooks/useIsTouch'
import { ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow } from './MPlanTimelineRows'
import type { RowDrag } from './MPlanTimelineRows'
import { usePluginDaySchedule } from '../../../../components/Plugins/PluginDaySchedule'
import { Fragment } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import type { MPlanTimelineProps } from '../MTripShell'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { Assignment } from '../../../../types'
import type { ReactNode } from 'react'
import type { ComponentType, ReactNode } from 'react'
import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon'
/**
* Plan-tab timeline of the mobile trip screen: the UP-NEXT card in go mode,
@@ -48,6 +54,10 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
const daySchedule = usePluginDaySchedule(planner.tripId)
const day = tl.day
const dayId = day?.id
// Mirrors MDaySheet's own label so the pill and the sheet it opens agree.
const dayLabel = day
? day.title || t('planner.dayN', { n: day.day_number || planner.days.indexOf(day) + 1 })
: ''
const dayScheduleFor = (anchor: 'assignment' | 'reservation', id: number) =>
(dayId != null
? (anchor === 'assignment' ? daySchedule.byAssignment[dayId]?.[id] : daySchedule.byReservation[dayId]?.[id])
@@ -60,6 +70,27 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
planner.handlePlaceClick(assignment.place?.id ?? null, assignment.id)
}
// Long-press drag reordering (#1997). Armed only in edit mode, so go-mode taps,
// map pans and plain list scrolling keep the gesture — which is what kept
// #1432/#1440 from coming back when the old document-wide polyfill was dropped.
// Coarse pointers only: a hybrid laptop narrow enough to land in the phone
// shell already loads drag-drop-touch (utils/touchDragPolyfill), and two
// bridges would fight over one gesture. The rows' native drag props serve
// both, so nothing is lost there.
const isTouch = useIsTouch()
useTouchDragBridge(editing && isTouch)
const dnd = useMPlanDragReorder({
merged: tl.merged,
dayId,
onMove: tl.moveRowTo,
enabled: editing,
})
const dragFor = (row: PlanRow): RowDrag | undefined => {
const props = dnd.dragPropsFor(row)
if (!props) return undefined
return { ...props, dragging: dnd.draggingKey === row.key, dropTarget: dnd.dropBeforeKey === row.key }
}
const reorderFor = (item: MergedItem): ReactNode => {
const idx = tl.merged.indexOf(item)
return (
@@ -85,11 +116,19 @@ 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
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)` }}
>
{(tl.hotelChips.length > 0 || tl.weatherTemp != null) && (
<TimelineHeader tl={tl} onOpenDay={() => day && shell.openSheet('day', { dayId: day.id })} />
{day && (
<TimelineHeader
tl={tl}
// Edit mode already names the day in its own header a row above, so
// the pill drops to icon-only there rather than saying it twice.
dayLabel={editing ? '' : dayLabel}
openLabel={t('day.overview')}
onOpenDay={() => shell.openSheet('day', { dayId: day.id })}
/>
)}
{tl.hotelLegs.top && (
@@ -108,6 +147,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
linkedRes={row.linkedRes}
chrome={chrome}
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onOpen={() => openPlace(row.assignment)}
onEdit={() => tl.editAssignment(row.assignment)}
onRemove={() => tl.removeAssignment(row.assignment)}
@@ -123,6 +163,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
dayId={day.id}
chrome={chrome}
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onOpen={() => {
if (editing) tl.editTransport(row.res)
else shell.openSheet('transport', { reservationId: row.res.id })
@@ -141,6 +182,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
open={tl.openTransitKeys.has(row.key)}
chrome={chrome}
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onToggle={() => tl.toggleTransit(row.key)}
onOpenJourney={() => tl.openTransitJourney(row.res)}
/>
@@ -154,6 +196,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
note={row.note}
chrome={chrome}
reorder={reorderFor(row.item)}
drag={dragFor(row)}
onEdit={() => shell.openSheet('note', { dayId: day.id, note: row.note })}
/>
)
@@ -185,7 +228,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
<PlanAction icon={Ticket} label={t('mobileTrip.addBookingShort')} onClick={tl.addBooking} />
<PlanAction icon={TrainFront} label={t('mobileTrip.addTransportShort')} onClick={tl.addTransport} />
<PlanAction icon={Route} label={t('dayplan.optimize')} onClick={() => void tl.optimize()} />
<PlanAction icon={ArrowUpRight} label={t('mobileTrip.googleMaps')} onClick={tl.exportGoogleMaps} />
<PlanAction icon={GoogleMapsIcon} label={t('mobileTrip.googleMaps')} onClick={tl.exportGoogleMaps} />
<PlanAction icon={Compass} label={t('mobileTrip.coMaps')} onClick={tl.exportCoMaps} />
</div>
)}
@@ -316,12 +359,34 @@ function EditHeader({ tl, planner, shell }: {
)
}
/** Card header: accommodation chips (check-out / check-in / stay) + the weather chip. */
function TimelineHeader({ tl, onOpenDay }: { tl: MPlanTimelineController; onOpenDay: () => void }) {
/**
* Card header: the day pill that opens the day sheet, the accommodation chips
* (check-out / check-in / stay) and the weather chip.
*
* The day pill leads unconditionally. It used to be the accommodation chip that
* carried this, which left a day without a stay — and, with no weather either,
* the whole header — with no way into the day sheet at all (#2004).
*/
function TimelineHeader({ tl, dayLabel, openLabel, onOpenDay }: {
tl: MPlanTimelineController
dayLabel: string
openLabel: string
onOpenDay: () => void
}) {
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">
<button
type="button"
onClick={onOpenDay}
aria-label={openLabel}
className="flex flex-none items-center gap-[5px] whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2.5 py-1 font-geist text-[0.6875rem] font-semibold"
>
<CalendarDays size={12} strokeWidth={2.2} className="text-m-muted" />
{dayLabel}
<ChevronRight size={11} strokeWidth={2.4} aria-hidden="true" className="text-m-faint" />
</button>
{tl.hotelChips.map(chip => (
<button
key={chip.key}
@@ -336,10 +401,15 @@ function TimelineHeader({ tl, onOpenDay }: { tl: MPlanTimelineController; onOpen
))}
</span>
{tl.weatherTemp != null && (
<span className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap px-1.5 py-1 text-[0.71875rem] font-semibold">
<button
type="button"
onClick={onOpenDay}
aria-label={openLabel}
className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap px-1.5 py-1 text-[0.71875rem] font-semibold"
>
<WeatherIcon size={13} strokeWidth={2} />
{tl.weatherTemp}°
</span>
</button>
)}
</div>
)
@@ -353,7 +423,12 @@ function HotelChipIcon({ variant }: { variant: 'checkout' | 'checkin' | 'stay' }
}
/** Edit-mode action tile — Place · Note · Booking · Transport · Optimize · Google Maps, three per row. */
function PlanAction({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) {
function PlanAction({ icon: Icon, label, onClick }: {
// Google Maps hands in its own brand mark, which is not a lucide icon (#2005).
icon: LucideIcon | ComponentType<{ size?: number; className?: string }>
label: string
onClick: () => void
}) {
return (
<button
type="button"
@@ -12,6 +12,7 @@ import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge'
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import { transportSubtitle, type TransitMeta, type TransportEntry } from './planTimelineModel'
import { splitNoteTime } from '../lib/dayNotes'
import type { DragRowProps } from './useMPlanDragReorder'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../types'
import { formatScheduleMinutes } from '../../../../components/Plugins/PluginDaySchedule'
@@ -21,7 +22,11 @@ import type { PluginDayScheduleItem } from '../../../../api/client'
* The five row types of the mobile day timeline (place / manual transport /
* auto-transit with collapsible legs / travel-time connector / note), each in
* its go and edit variant. Edit rows swap the left avatar for right-hand action
* circles; reordering is button-based — no drag on touch (#1432).
* circles.
*
* Reordering has two paths: the up/down stack, and — since #1997 — a long-press
* drag carried by `utils/touchDragBridge`. `drag` below is the second one's props;
* it is undefined outside edit mode, which is what keeps a plain scroll a scroll.
*/
interface RowChrome {
@@ -31,6 +36,20 @@ interface RowChrome {
timeFormat: string
}
/** Native drag props from useMPlanDragReorder, plus the row's own drag state. */
export interface RowDrag extends DragRowProps {
dragging: boolean
dropTarget: boolean
}
/** Dims the travelling row and rules a line where the drop would land. */
const dragClass = (d?: RowDrag): string =>
!d ? '' : `${d.dragging ? 'opacity-40' : ''} ${d.dropTarget && !d.dragging ? 'shadow-[inset_0_2px_0_0_var(--m-act)]' : ''}`
/** Only the props a DOM node takes — `dragging`/`dropTarget` are ours. */
const dragProps = (d?: RowDrag) =>
d ? { draggable: d.draggable, onDragStart: d.onDragStart, onDragOver: d.onDragOver, onDrop: d.onDrop, onDragEnd: d.onDragEnd } : {}
const fmtTime = (time: string | null | undefined, c: RowChrome): string =>
time ? formatTime(time.slice(0, 5), c.language, c.timeFormat) : ''
@@ -100,12 +119,13 @@ const TIME_CHIP = 'flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-i
// ── b3) Place row ────────────────────────────────────────────────────────────
export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, onOpen, onEdit, onRemove }: {
export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
assignment: Assignment
fullPlace: Place | undefined
linkedRes: Reservation | null
chrome: RowChrome
reorder: ReactNode
drag?: RowDrag
onOpen: () => void
onEdit: () => void
onRemove: () => void
@@ -122,7 +142,7 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, on
: place?.address || place?.description || ''
return (
<div onClick={onOpen} className="flex cursor-pointer items-center gap-2.5 py-1.5">
<div {...dragProps(drag)} onClick={onOpen} className={`flex cursor-pointer items-center gap-2.5 py-1.5 ${dragClass(drag)}`}>
{!chrome.editing && (
<AvatarRing className="shadow-[0_3px_8px_-3px_rgba(0,0,0,.4)]">
<PlaceAvatar
@@ -175,11 +195,12 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, on
// ── b1) Manual transport / booking row ───────────────────────────────────────
export function TransportRow({ res, dayId, chrome, reorder, onOpen }: {
export function TransportRow({ res, dayId, chrome, reorder, drag, onOpen }: {
res: TransportEntry
dayId: number
chrome: RowChrome
reorder: ReactNode
drag?: RowDrag
onOpen: () => void
}) {
const Icon = RES_ICONS[res.type as keyof typeof RES_ICONS] || Ticket
@@ -192,7 +213,7 @@ export function TransportRow({ res, dayId, chrome, reorder, onOpen }: {
const sub = transportSubtitle(res)
return (
<div onClick={onOpen} className="mt-1.5 flex cursor-pointer items-center gap-2.5">
<div {...dragProps(drag)} onClick={onOpen} className={`mt-1.5 flex cursor-pointer items-center gap-2.5 ${dragClass(drag)}`}>
{!chrome.editing && (
<AvatarRing>
<Icon size={14} strokeWidth={2} />
@@ -313,13 +334,14 @@ function TransitLegRows({ legs, t }: { legs: TransitLegDisplay[]; t: Translation
)
}
export function TransitRow({ res, transit, dayId, open, chrome, reorder, onToggle, onOpenJourney }: {
export function TransitRow({ res, transit, dayId, open, chrome, reorder, drag, onToggle, onOpenJourney }: {
res: TransportEntry
transit: TransitMeta
dayId: number
open: boolean
chrome: RowChrome
reorder: ReactNode
drag?: RowDrag
onToggle: () => void
onOpenJourney: () => void
}) {
@@ -331,7 +353,7 @@ export function TransitRow({ res, transit, dayId, open, chrome, reorder, onToggl
const Chevron = open ? ChevronUp : ChevronDown
return (
<div className="mt-1.5 flex items-start gap-2.5">
<div {...dragProps(drag)} className={`mt-1.5 flex items-start gap-2.5 ${dragClass(drag)}`}>
{!chrome.editing && (
<AvatarRing className="mt-1">
<Icon size={14} strokeWidth={2} />
@@ -458,10 +480,11 @@ export function HotelConnRow({ seg, name, placement }: {
// ── b5) Day-note row ─────────────────────────────────────────────────────────
export function NoteRow({ note, chrome, reorder, onEdit }: {
export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
note: DayNote
chrome: RowChrome
reorder: ReactNode
drag?: RowDrag
onEdit: () => void
}) {
const Icon = getNoteIcon(note.icon)
@@ -474,8 +497,9 @@ export function NoteRow({ note, chrome, reorder, onEdit }: {
return (
<div
{...dragProps(drag)}
onClick={chrome.editing ? onEdit : undefined}
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''}`}
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
>
{!chrome.editing && (
<AvatarRing style={note.color ? { background: skin.iconBackground, borderColor: skin.border } : undefined}>
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useMPlanDragReorder } from './useMPlanDragReorder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { PlanRow } from './planTimelineModel'
const items = [
{ type: 'place', data: { id: 1 } },
{ type: 'place', data: { id: 2 } },
{ type: 'note', data: { id: 3 } },
] as unknown as MergedItem[]
const rowFor = (i: number): PlanRow =>
({ key: `row-${i}`, kind: 'place', item: items[i], assignment: {}, linkedRes: null }) as unknown as PlanRow
const connRow = { key: 'conn-1', kind: 'conn', seg: {} } as unknown as PlanRow
/** A DragEvent stand-in with just the parts the hook touches. */
const dragEvent = () => {
const store = new Map<string, string>()
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
setData: (k: string, v: string) => store.set(k, v),
getData: (k: string) => store.get(k) ?? '',
set effectAllowed(_v: string) { /* noop */ },
},
} as never
}
const setup = (enabled = true) => {
const onMove = vi.fn()
const hook = renderHook(() => useMPlanDragReorder({ merged: items, dayId: 7, onMove, enabled }))
return { ...hook, onMove }
}
describe('useMPlanDragReorder', () => {
it('FE-MOB-DRAG-001: hands out nothing while disabled', () => {
const { result } = setup(false)
expect(result.current.dragPropsFor(rowFor(0))).toBeUndefined()
expect(result.current.draggingKey).toBeNull()
})
it('FE-MOB-DRAG-002: hands out nothing without a day', () => {
const onMove = vi.fn()
const { result } = renderHook(() =>
useMPlanDragReorder({ merged: items, dayId: null, onMove, enabled: true }))
expect(result.current.dragPropsFor(rowFor(0))).toBeUndefined()
})
it('FE-MOB-DRAG-003: leaves connector rows undraggable — they are computed spacing', () => {
const { result } = setup()
expect(result.current.dragPropsFor(connRow)).toBeUndefined()
})
it('FE-MOB-DRAG-004: marks the travelling row and the row it would land in front of', () => {
const { result } = setup()
act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
expect(result.current.draggingKey).toBe('row-0')
act(() => { result.current.dragPropsFor(rowFor(2))!.onDragOver(dragEvent()) })
expect(result.current.dropBeforeKey).toBe('row-2')
})
it('FE-MOB-DRAG-005: moves the source to the target index on drop', () => {
const { result, onMove } = setup()
act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
act(() => { result.current.dragPropsFor(rowFor(2))!.onDrop(dragEvent()) })
expect(onMove).toHaveBeenCalledWith(items[0], 2)
// and the drag state is cleared again
expect(result.current.draggingKey).toBeNull()
expect(result.current.dropBeforeKey).toBeNull()
})
it('FE-MOB-DRAG-006: a drop on the row itself is not a move', () => {
const { result, onMove } = setup()
act(() => { result.current.dragPropsFor(rowFor(1))!.onDragStart(dragEvent()) })
act(() => { result.current.dragPropsFor(rowFor(1))!.onDrop(dragEvent()) })
expect(onMove).not.toHaveBeenCalled()
})
it('FE-MOB-DRAG-007: a drop without a drag start is ignored', () => {
const { result, onMove } = setup()
act(() => { result.current.dragPropsFor(rowFor(1))!.onDrop(dragEvent()) })
expect(onMove).not.toHaveBeenCalled()
})
it('FE-MOB-DRAG-008: dragOver accepts the drop so the touch bridge can read it', () => {
const { result } = setup()
act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
const e = dragEvent() as unknown as { preventDefault: ReturnType<typeof vi.fn> }
act(() => { result.current.dragPropsFor(rowFor(1))!.onDragOver(e as never) })
expect(e.preventDefault).toHaveBeenCalled()
})
it('FE-MOB-DRAG-009: dragEnd clears the state without moving anything', () => {
const { result, onMove } = setup()
act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(dragEvent()) })
act(() => { result.current.dragPropsFor(rowFor(0))!.onDragEnd() })
expect(result.current.draggingKey).toBeNull()
expect(onMove).not.toHaveBeenCalled()
})
it('FE-MOB-DRAG-010: survives a DataTransfer shim that refuses setData', () => {
const { result } = setup()
const broken = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { setData: () => { throw new Error('nope') } },
} as never
expect(() => act(() => { result.current.dragPropsFor(rowFor(0))!.onDragStart(broken) })).not.toThrow()
expect(result.current.draggingKey).toBe('row-0')
})
})
@@ -0,0 +1,105 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { PlanRow } from './planTimelineModel'
interface DragReorderArgs {
/** The day's items in merged order — the space a reorder is expressed in. */
merged: MergedItem[]
dayId: number | null | undefined
/** Same mover the up/down buttons use, so both paths share every guard. */
onMove: (item: MergedItem, toIndex: number) => void
enabled: boolean
}
export interface DragRowProps {
draggable: boolean
onDragStart: (e: DragEvent) => void
onDragOver: (e: DragEvent) => void
onDrop: (e: DragEvent) => void
onDragEnd: () => void
}
export interface MPlanDragReorder {
/** Key of the row under the finger, so it can be dimmed while it travels. */
draggingKey: string | null
/** Key of the row the drop would land in front of, for the insertion line. */
dropBeforeKey: string | null
dragPropsFor: (row: PlanRow) => DragRowProps | undefined
}
const NO_DRAG: MPlanDragReorder = {
draggingKey: null,
dropBeforeKey: null,
dragPropsFor: () => undefined,
}
/**
* Long-press drag reordering for the mobile day timeline (#1997).
*
* The phone shell shipped with up/down buttons only, on the reasoning that a
* finger cannot start an HTML5 drag (#1265) and that the document-wide
* `drag-drop-touch` polyfill which used to paper over that broke list scrolling
* (#1432) and hijacked map pans (#1440). `utils/touchDragBridge` already solved
* that for tablets (#1616): it watches only `[draggable]` rows inside an opted-in
* `[data-touch-drag]` container, waits out a 320ms press before it claims the
* gesture — so a swipe is still a scroll — and then replays real drag events.
* This hook is the receiving half the mobile timeline was missing.
*
* Rows carry native drag props, which means the same code also works with a
* mouse (tablet with a trackpad, desktop dev tools) at no extra cost.
*
* The up/down buttons stay exactly where they are: they are the accessible path,
* and they are what still works when a drag is not practical.
*/
export function useMPlanDragReorder({ merged, dayId, onMove, enabled }: DragReorderArgs): MPlanDragReorder {
const [draggingKey, setDraggingKey] = useState<string | null>(null)
const [dropBeforeKey, setDropBeforeKey] = useState<string | null>(null)
// The bridge's DataTransfer is a shim on browsers that refuse `new DataTransfer()`,
// so the source also rides along in a ref — same as the desktop sidebar does.
const sourceRef = useRef<MergedItem | null>(null)
const clear = useCallback(() => {
sourceRef.current = null
setDraggingKey(null)
setDropBeforeKey(null)
}, [])
const dragPropsFor = useCallback((row: PlanRow): DragRowProps | undefined => {
// Connector rows are computed spacing between two stops, not items.
if (!enabled || dayId == null || row.kind === 'conn') return undefined
const item = row.item
return {
draggable: true,
onDragStart: (e: DragEvent) => {
sourceRef.current = item
setDraggingKey(row.key)
try {
e.dataTransfer.setData('planRowKey', row.key)
e.dataTransfer.effectAllowed = 'move'
} catch { /* the bridge's shim may not implement every DataTransfer member */ }
},
onDragOver: (e: DragEvent) => {
if (!sourceRef.current) return
// preventDefault is how a drop target says yes — the bridge reads it too.
e.preventDefault()
e.stopPropagation()
setDropBeforeKey(prev => (prev === row.key ? prev : row.key))
},
onDrop: (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
const source = sourceRef.current
clear()
if (!source || source === item) return
const to = merged.indexOf(item)
if (to < 0) return
onMove(source, to)
},
onDragEnd: clear,
}
}, [enabled, dayId, merged, onMove, clear])
if (!enabled) return NO_DRAG
return { draggingKey, dropBeforeKey, dragPropsFor }
}
@@ -208,10 +208,13 @@ describe('MPlanTimeline', () => {
expect(shell.openSheet).toHaveBeenCalledWith('day', { dayId: 2 })
})
it('FE-MOB-PLTL-007: is left out entirely without chips and without weather', () => {
it('FE-MOB-PLTL-007: still renders, with the day pill, without chips or weather (#2004)', () => {
const { container } = renderTimeline()
expect(container.querySelector('.border-b')).toBeNull()
// The pill is the only way into the day sheet, so it may not depend on the
// day happening to have an accommodation or a weather reading.
expect(container.querySelector('.border-b')).not.toBeNull()
expect(screen.getByRole('button', { name: 'day.overview' })).toBeInTheDocument()
})
it('FE-MOB-PLTL-008: draws the hotel bookend legs above and below the rows', () => {
@@ -349,16 +352,15 @@ describe('MPlanTimeline', () => {
})
it('FE-MOB-PLTL-020: read-only members get no tappable connectors', () => {
const { container } = renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
renderTimeline({}, { can: vi.fn(() => false) }, { mode: 'edit' })
expect(container.querySelector('button')).toBeNull()
expect(screen.getByText('7 min').closest('button')).toBeNull()
})
it('FE-MOB-PLTL-020b: go mode keeps the connectors read-only even with edit rights', () => {
const { container } = renderTimeline()
renderTimeline()
expect(screen.getByText('7 min').closest('button')).toBeNull()
expect(container.querySelector('button')).toBeNull()
})
})
@@ -439,6 +441,8 @@ describe('MPlanTimeline', () => {
renderTimeline({ day: { ...DAY, title: null } }, {}, { mode: 'edit' })
expect(screen.getByText('planner.dayN:2')).toBeInTheDocument()
// …and the day pill next to it stays icon-only so the name is not doubled.
expect(screen.getByRole('button', { name: 'day.overview' })).toHaveTextContent('')
})
it('FE-MOB-PLTL-028: the pencil renames the day and Enter commits the change', () => {