fix(planner): let tablets drag places into the day plan (#1616)

Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
This commit is contained in:
Maurice
2026-07-29 18:06:24 +02:00
committed by Maurice
parent e7ccbcdf86
commit edea67ff7e
13 changed files with 260 additions and 54 deletions
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { usePluginStore } from '../../store/pluginStore'
import { installTouchDragBridge } from '../../utils/touchDragBridge'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import {
buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation,
@@ -2953,18 +2954,31 @@ describe('DayPlanSidebar', () => {
expect(document.querySelectorAll('.dp-row')).toHaveLength(0)
})
it('FE-PLANNER-DAYPLAN-155: on a touch device place rows lose their drag handle', () => {
const place = buildPlace({ id: 1, name: 'Touch place' })
it('FE-PLANNER-DAYPLAN-155: below lg place rows lose their drag handle', () => {
const place = buildPlace({ id: 1, name: 'Narrow place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] }, isTouch: true })} />)
const row = screen.getByText('Touch place').closest('.dp-row') as HTMLElement
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] }, isMobile: true })} />)
const row = screen.getByText('Narrow place').closest('.dp-row') as HTMLElement
expect(row.getAttribute('draggable')).toBe('false')
expect(row.querySelector('.dp-grip')).toBeNull()
fireEvent.dragStart(row, { dataTransfer: emptyDataTransfer })
expect(row.style.opacity).toBe('1')
})
// #1616: a tablet is a coarse pointer at a desktop width. It gets the same rows
// the mouse does — the long press in touchDragBridge is what starts the drag.
it('FE-PLANNER-DAYPLAN-155b: at desktop width place rows keep grip and drag', () => {
const place = buildPlace({ id: 1, name: 'Tablet place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] }, isMobile: false })} />)
const row = screen.getByText('Tablet place').closest('.dp-row') as HTMLElement
expect(row.getAttribute('draggable')).toBe('true')
expect(row.querySelector('.dp-grip')).not.toBeNull()
expect(row.closest('[data-touch-drag]')).not.toBeNull()
})
// ── Booking rows ─────────────────────────────────────────────────────────
const withEndpoints = (r: Reservation): Reservation => ({
@@ -3655,10 +3669,10 @@ describe('DayPlanSidebar', () => {
await waitFor(() => expect(updateDayNote).toHaveBeenCalledWith(1, 10, 70, { sort_order: expect.any(Number) }))
})
it('FE-PLANNER-DAYPLAN-188: on a touch device note rows lose their grip and cannot be dragged', () => {
it('FE-PLANNER-DAYPLAN-188: below lg note rows lose their grip and cannot be dragged', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 70, day_id: 10, text: 'A note' })] }
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], isTouch: true })} />)
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], isMobile: true })} />)
const row = cardRow(screen.getByText('A note'))
expect(row.getAttribute('draggable')).toBe('false')
expect(row.querySelector('.dp-grip')).toBeNull()
@@ -3758,3 +3772,68 @@ describe('DayPlanSidebar remaining branches', () => {
expect(body.indexOf('Departing Inn')).toBeLessThan(body.indexOf('Arriving Inn'))
})
})
// #1616 — the reporter's iPad sees both panes but could not pick a row up at all.
// This drives the real bridge rather than a synthetic dragstart, so it fails if the
// long press, the hit test or the opt-in container ever stop lining up.
describe('reordering the day plan with a finger (#1616)', () => {
/** Presses a row for longer than the bridge's long press. */
async function longPress(row: Element) {
fireEvent.touchStart(row, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] })
await new Promise(resolve => setTimeout(resolve, 400))
}
function dragTo(target: Element) {
document.elementFromPoint = () => target as Element
fireEvent.touchMove(document, { touches: [{ identifier: 1, clientX: 20, clientY: 180 }] })
const end = new Event('touchend', { bubbles: true, cancelable: true })
Object.defineProperty(end, 'touches', { value: [] })
fireEvent(document, end)
}
it('FE-PLANNER-DAYPLAN-189: a long press drags one place row onto another and reorders the day', async () => {
const onReorder = vi.fn().mockResolvedValue(undefined)
const place1 = buildPlace({ id: 1, name: 'First Place' })
const place2 = buildPlace({ id: 2, name: 'Second Place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 })
const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: place2 })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place1, place2], assignments: { '10': [a1, a2] }, onReorder,
})} />)
const teardown = installTouchDragBridge()
try {
const from = screen.getByText('First Place').closest('[draggable="true"]')!
const to = screen.getByText('Second Place').closest('[draggable="true"]')!
await longPress(from)
dragTo(to)
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
} finally {
teardown()
}
})
it('FE-PLANNER-DAYPLAN-190: a swipe across the same row scrolls instead of reordering', async () => {
const onReorder = vi.fn().mockResolvedValue(undefined)
const place1 = buildPlace({ id: 1, name: 'First Place' })
const place2 = buildPlace({ id: 2, name: 'Second Place' })
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const a1 = buildAssignment({ id: 11, day_id: 10, order_index: 0, place: place1 })
const a2 = buildAssignment({ id: 12, day_id: 10, order_index: 1, place: place2 })
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place1, place2], assignments: { '10': [a1, a2] }, onReorder,
})} />)
const teardown = installTouchDragBridge()
try {
const from = screen.getByText('First Place').closest('[draggable="true"]')!
fireEvent.touchStart(from, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] })
// The finger travels before the press lands, so the browser keeps the gesture.
const moved = fireEvent.touchMove(document, { touches: [{ identifier: 1, clientX: 20, clientY: 140 }] })
expect(moved).toBe(true)
await new Promise(resolve => setTimeout(resolve, 400))
expect(onReorder).not.toHaveBeenCalled()
} finally {
teardown()
}
})
})
@@ -104,11 +104,6 @@ interface DayPlanSidebarProps {
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
isMobile?: boolean
/** Coarse primary pointer. Drag & drop reorder is disabled here (it hijacks the
* touch-scroll gesture, #1432); the grip handle is hidden and the arrow reorder
* buttons take over instead. Distinct from isMobile, which is width-based — a
* tablet is a wide touch device and needs both. */
isTouch?: boolean
}
/**
@@ -156,9 +151,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onScrollTopChange,
showRouteToolsWhenExpanded = false,
isMobile = false,
isTouch = false,
} = props
const dragDisabled = isMobile || isTouch
// Below lg the plan and the places sit in separate tabs, so there is nothing
// to drag between. A coarse pointer is no longer a reason of its own: tablets
// reach the same drag through a long press (#1616).
const dragDisabled = isMobile
const toast = useToast()
const { t, language, locale } = useTranslation()
const ctxMenu = useContextMenu()
@@ -1254,7 +1251,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
])
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
<div data-touch-drag={dragDisabled ? undefined : ''} style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { placesApi } from '../../api/client';
import { installTouchDragBridge } from '../../utils/touchDragBridge';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
@@ -620,35 +621,37 @@ describe('Google Maps list import', () => {
});
// #1432: a tablet is a touch device at a desktop width. Before the fix, isTouch didn't
// exist and drag was gated on width alone, so on an iPad the rows stayed draggable and a
// scroll swipe started an HTML5 drag, which raised the drop-to-import overlay instead of
// scrolling. These cases pin the desktop-width + coarse-pointer combination.
describe('touch device at desktop width (#1432)', () => {
const touchProps = { ...defaultProps, isMobile: false, isTouch: true };
// #1616: a tablet is a coarse pointer at a desktop width, and it sees both panes, so
// it has somewhere to drag a place to. A coarse pointer used to switch the drag off by
// itself, which left the reporter's iPad selecting text instead of picking up a row.
// Width is the only gate now: below lg the places live in their own tab.
describe('touch device at desktop width (#1616)', () => {
const tabletProps = { ...defaultProps, isMobile: false };
it('FE-PLANNER-SIDEBAR-044: place rows are not draggable', () => {
it('FE-PLANNER-SIDEBAR-044: place rows are draggable and opt into the touch bridge', () => {
const place = buildPlace({ id: 7, name: 'Tablet Place' });
render(<PlacesSidebar {...touchProps} places={[place]} />);
const { container } = render(<PlacesSidebar {...tabletProps} places={[place]} />);
const placeRow = screen.getByText('Tablet Place').closest('div[draggable]')!;
expect(placeRow.getAttribute('draggable')).toBe('false');
});
it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar does not raise the drop-to-import overlay', () => {
const place = buildPlace({ id: 7, name: 'Tablet Place' });
const { container } = render(<PlacesSidebar {...touchProps} places={[place]} />);
fireEvent.dragEnter(container.firstChild as HTMLElement);
expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-046: a mouse-driven desktop keeps drag and the drop-to-import overlay', () => {
const place = buildPlace({ id: 7, name: 'Desktop Place' });
const { container } = render(<PlacesSidebar {...defaultProps} isTouch={false} places={[place]} />);
const placeRow = screen.getByText('Desktop Place').closest('div[draggable]')!;
expect(placeRow.getAttribute('draggable')).toBe('true');
expect((container.firstChild as HTMLElement).hasAttribute('data-touch-drag')).toBe(true);
});
it('FE-PLANNER-SIDEBAR-045: dragging over the sidebar raises the drop-to-import overlay', () => {
const place = buildPlace({ id: 7, name: 'Tablet Place' });
const { container } = render(<PlacesSidebar {...tabletProps} places={[place]} />);
fireEvent.dragEnter(container.firstChild as HTMLElement);
expect(screen.getByText('Drop to import')).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-046: below lg the rows stay undraggable and the bridge stays out', () => {
const place = buildPlace({ id: 7, name: 'Narrow Place' });
const { container } = render(<PlacesSidebar {...defaultProps} isMobile places={[place]} />);
const placeRow = screen.getByText('Narrow Place').closest('div[draggable]')!;
expect(placeRow.getAttribute('draggable')).toBe('false');
expect((container.firstChild as HTMLElement).hasAttribute('data-touch-drag')).toBe(false);
fireEvent.dragEnter(container.firstChild as HTMLElement);
expect(screen.queryByText('Drop to import')).not.toBeInTheDocument();
});
});
// The map draws no legend of its own, so the stroke in the row is the only
@@ -670,3 +673,40 @@ describe('track colour legend (#776)', () => {
expect(strokes).toHaveLength(0);
});
});
// #1616 — the other half of the reporter's gesture: the pickup. A tablet cannot
// start an HTML5 drag with a finger, so the bridge's long press has to do it, and
// the row has to hand over the placeId the day plan reads back on drop.
describe('picking a place up with a finger (#1616)', () => {
it('FE-PLANNER-SIDEBAR-047: a long press on a place row starts a drag carrying its id', async () => {
const place = buildPlace({ id: 42, name: 'Tablet Place' });
render(<PlacesSidebar {...defaultProps} isMobile={false} places={[place]} />);
const teardown = installTouchDragBridge();
try {
const row = screen.getByText('Tablet Place').closest('[draggable="true"]')!;
fireEvent.touchStart(row, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] });
await new Promise(resolve => setTimeout(resolve, 400));
expect(window.__dragData).toEqual({ placeId: '42' });
} finally {
teardown();
window.__dragData = null;
}
});
it('FE-PLANNER-SIDEBAR-048: a swipe down the list scrolls instead of picking the row up', async () => {
const place = buildPlace({ id: 42, name: 'Tablet Place' });
render(<PlacesSidebar {...defaultProps} isMobile={false} places={[place]} />);
const teardown = installTouchDragBridge();
try {
const row = screen.getByText('Tablet Place').closest('[draggable="true"]')!;
fireEvent.touchStart(row, { touches: [{ identifier: 1, clientX: 20, clientY: 40 }] });
const moved = fireEvent.touchMove(document, { touches: [{ identifier: 1, clientX: 20, clientY: 140 }] });
await new Promise(resolve => setTimeout(resolve, 400));
expect(moved).toBe(true);
expect(window.__dragData).toBeFalsy();
} finally {
teardown();
window.__dragData = null;
}
});
});
@@ -17,13 +17,17 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
selectMode, filtered, t, dayPickerPlace, listImportOpen,
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, isTouch, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
collectionsEnabled, saveToListOpen, setSaveToListOpen,
} = S
const dragDisabled = isMobile || isTouch
// Below lg the places sit in their own tab with no plan beside them to drag
// into. A coarse pointer no longer disables the drag on its own — tablets
// reach it through a long press (#1616).
const dragDisabled = isMobile
return (
<div
data-touch-drag={dragDisabled ? undefined : ''}
onDragEnter={dragDisabled ? undefined : handleSidebarDragEnter}
onDragOver={dragDisabled ? undefined : handleSidebarDragOver}
onDragLeave={dragDisabled ? undefined : handleSidebarDragLeave}
@@ -7,7 +7,7 @@ export function PlacesList(S: SidebarState) {
const {
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
isMobile, isTouch, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow, tripId,
} = S
// Plugin-contributed columns/actions for the places view, keyed by place id (#plugins).
const contribFor = usePluginViewContributions('places', tripId)
@@ -43,7 +43,6 @@ export function PlacesList(S: SidebarState) {
selectedDayId={selectedDayId}
canEditPlaces={canEditPlaces}
isMobile={isMobile}
isTouch={Boolean(isTouch)}
t={t}
onPlaceClick={onPlaceClick}
onContextMenu={openContextMenu}
@@ -16,8 +16,6 @@ interface MemoPlaceRowProps {
selectedDayId: number | null
canEditPlaces: boolean
isMobile: boolean
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
isTouch: boolean
t: (key: string, params?: Record<string, any>) => string
onPlaceClick: (id: number | null) => void
onContextMenu: (e: React.MouseEvent, place: Place) => void
@@ -29,11 +27,12 @@ interface MemoPlaceRowProps {
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
place, category: cat, isSelected, isPlanned, inDay, isChecked,
selectMode, selectedDayId, canEditPlaces, isMobile, isTouch, t,
selectMode, selectedDayId, canEditPlaces, isMobile, t,
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
}: MemoPlaceRowProps) {
const hasGeometry = Boolean(place.route_geometry)
const dragDisabled = isMobile || isTouch
// Touch is reached through a long press instead of being locked out (#1616).
const dragDisabled = isMobile
return (
<div
key={place.id}
@@ -31,8 +31,6 @@ export interface PlacesSidebarProps {
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
days: Day[]
isMobile: boolean
/** Primary pointer is coarse — HTML5 drag would swallow the scroll gesture (#1432). */
isTouch?: boolean
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { fireEvent } from '@testing-library/dom'
import { useTouchDragBridge } from './useTouchDragBridge'
describe('useTouchDragBridge (#1616)', () => {
let source: HTMLElement
let started: number
beforeEach(() => {
vi.useFakeTimers()
document.body.innerHTML = '<div data-touch-drag><div id="source" draggable="true">Place</div></div>'
source = document.getElementById('source')!
started = 0
document.addEventListener('dragstart', () => { started++ })
document.elementFromPoint = () => source
})
afterEach(() => {
vi.useRealTimers()
document.body.innerHTML = ''
})
/** Presses the row for longer than the bridge's long press. */
function longPress() {
fireEvent.touchStart(source, { touches: [{ identifier: 1, clientX: 10, clientY: 10 }] })
vi.advanceTimersByTime(400)
}
it('FE-TOUCHDRAGHOOK-001: while enabled a long press starts a drag', () => {
renderHook(() => useTouchDragBridge(true))
longPress()
expect(started).toBe(1)
})
it('FE-TOUCHDRAGHOOK-002: a mouse-driven desktop is left to the browser', () => {
renderHook(() => useTouchDragBridge(false))
longPress()
expect(started).toBe(0)
})
it('FE-TOUCHDRAGHOOK-003: leaving the planner stops the bridge watching', () => {
const { unmount } = renderHook(() => useTouchDragBridge(true))
unmount()
longPress()
expect(started).toBe(0)
})
it('FE-TOUCHDRAGHOOK-004: turning it off mid-session stops the bridge watching', () => {
const { rerender } = renderHook(({ on }) => useTouchDragBridge(on), {
initialProps: { on: true },
})
rerender({ on: false })
longPress()
expect(started).toBe(0)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from 'react'
import { installTouchDragBridge } from '../utils/touchDragBridge'
/**
* Arms the planner's long-press touch drag while `enabled` (#1616).
*
* Deliberately not armed on hybrid laptops: their primary pointer is fine, so
* `drag-drop-touch` is loaded there instead (see utils/touchDragPolyfill) and
* two bridges would fight over the same gesture. The two conditions cannot
* both hold — a pointer is either coarse or fine.
*/
export function useTouchDragBridge(enabled: boolean): void {
useEffect(() => {
if (!enabled) return
return installTouchDragBridge()
}, [enabled])
}
+12
View File
@@ -1165,6 +1165,18 @@ img[alt="TREK"] {
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
}
/* Planner rows a finger can pick up (#1616). A long press is what arms the drag,
and that is the same gesture the browser answers with a text selection and a
callout — "the action is interpreted as a text selection" in the report. Only
on coarse pointers, and only inside the two panes that opt into the bridge. */
@media (pointer: coarse) {
[data-touch-drag] [draggable='true'] {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
}
/* ── TREK range slider — thin filled track + soft round thumb ─────────────── */
.trek-range {
-webkit-appearance: none;
+6 -2
View File
@@ -52,6 +52,7 @@ import { useTripPlanner } from './tripPlanner/useTripPlanner'
import { usePoiExplore } from '../components/Map/usePoiExplore'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useIsPhone } from '../mobile/useIsPhone'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
import MTripShell from '../mobile/screens/trip/MTripShell'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
@@ -227,6 +228,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
mapTileUrl, fontStyle, splashDone,
} = useTripPlanner()
// Tablets run this very layout but cannot start an HTML5 drag with a finger,
// so a long press stands in for one (#1616). Only where the pointer is
// coarse — a hybrid laptop loads drag-drop-touch instead.
useTouchDragBridge(isTouch && !isMobile)
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<CompassMap | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
@@ -379,7 +385,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
}}>
<DayPlanSidebar
isMobile={isMobile}
isTouch={isTouch}
tripId={tripId}
trip={trip}
days={days}
@@ -491,7 +496,6 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
pushUndo={pushUndo}
days={days}
isMobile={false}
isTouch={isTouch}
/>
</div>
</div>
@@ -298,7 +298,7 @@ export function useTripPlanner() {
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
// Layout is width-driven (isMobile); drag affordances are pointer-driven (isTouch).
// Layout is width-driven (isMobile); the drag bridge is pointer-driven (isTouch).
// Conflating them is what left a tablet's places list undraggable-but-unscrollable (#1432).
const isTouch = useIsTouch()
+5 -5
View File
@@ -6,11 +6,11 @@
* dblclick fires the default double-click-zoom, so two quick one-finger pans zoomed
* instead of panning (#1440).
*
* Reorder DnD is disabled wherever the primary pointer is coarse (#1432), so the only
* device class left with a job for the polyfill is the hybrid laptop: a mouse as the
* primary pointer, with a touchscreen also available. Loading it anywhere else — notably
* on tablets, which a width check misclassifies as desktop — re-arms the very gesture
* hijack #1432 removed, and drags the #1440 phantom-dblclick along with it.
* The hybrid laptop is the only device class left with a job for it: a mouse as the
* primary pointer, with a touchscreen also available. Loading it anywhere else re-arms
* the very gesture hijack #1432 removed, and drags the #1440 phantom-dblclick along
* with it. Coarse-pointer devices get touchDragBridge instead, which waits out a long
* press before it claims a gesture and so leaves scrolling alone (#1616).
*/
export function maybeInstallTouchDragPolyfill(): Promise<unknown> | void {
if (typeof window === 'undefined') return