From 520562ef8646c2da484dbf4e9a729af968bfa3b8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 11 Nov 2026 21:26:15 +0000 Subject: [PATCH] fix(planner): keep day plan drags alive in Firefox Firefox fires dragstart while it handles the mouse move that starts the drag and delivers that move to the page afterwards, button still held. The listener that clears a drag whose dragend never arrived took that move as the end of the drag and dropped the drag data at pick-up, so a stop could only be reordered with its arrows. The same held-button moves come from the touch polyfill on hybrid laptops throughout a drag. Only a move with no button held now counts as the drag being over. The day list also switched its stagger animation back on whenever a drag ended, which replayed the entrance of every day card ("weird reload"). It now plays once on mount and stays off after the first drag. Fixes #3041 --- .../Planner/DayPlanSidebar.dragData.test.tsx | 54 +++++++++++++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 19 +++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.dragData.test.tsx b/client/src/components/Planner/DayPlanSidebar.dragData.test.tsx index a8c076b5b..fa738312f 100644 --- a/client/src/components/Planner/DayPlanSidebar.dragData.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.dragData.test.tsx @@ -254,3 +254,57 @@ describe('DayPlanSidebar timed booking drop', () => { expect(onReorder).toHaveBeenCalledWith(10, [11, 12, 13, 14]); }); }); + +// #3041: Firefox sends dragstart while it handles the mouse move that starts the +// drag, and delivers that move to the page right after. Taken as "the drag is over", +// it dropped the drag data at pick-up, so a stop could only be moved by its arrows, +// and the day list played its enter animation again. +describe('DayPlanSidebar drag in Firefox', () => { + const renderDay = () => { + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }); + const stops = [buildPlace({ id: 1, name: 'Breakfast' }), buildPlace({ id: 2, name: 'Museum' })]; + const assignments = { + '10': stops.map((place, i) => buildAssignment({ id: 11 + i, day_id: 10, order_index: i, place })), + }; + const onReorder = vi.fn(async () => undefined); + const view = render( + + ); + const row = (text: string) => screen.getByText(text).closest('[draggable="true"]') as HTMLElement; + const list = view.container.querySelector('.scroll-container') as HTMLElement; + return { onReorder, row, list }; + }; + const dataTransfer = () => ({ setData: vi.fn(), effectAllowed: '', getData: vi.fn(() => '') }); + + it('FE-PLANNER-DAYPLAN-239: the mouse move that starts a drag, with the button still held, keeps the drag', async () => { + const { onReorder, row } = renderDay(); + fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() }); + // Firefox order: dragstart first, then the move that triggered it. The touch + // polyfill on hybrid laptops sends the same held-button moves throughout a drag. + fireEvent.mouseMove(document, { buttons: 1 }); + expect(row('Museum').style.opacity).toBe('0.4'); + fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() }); + expect(row('Breakfast').style.borderTop).toContain('2px'); + fireEvent.drop(row('Breakfast'), { dataTransfer: dataTransfer() }); + await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, [12, 11])); + }); + + it('FE-PLANNER-DAYPLAN-240: a plain mouse move after a drag that never ended still clears it', () => { + const { row } = renderDay(); + fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() }); + fireEvent.dragOver(row('Breakfast'), { dataTransfer: dataTransfer() }); + fireEvent.mouseMove(document, { buttons: 0 }); + expect(row('Museum').style.opacity).toBe('1'); + expect(row('Breakfast').style.borderTop).toBe(''); + }); + + it('FE-PLANNER-DAYPLAN-241: the day list plays its enter animation once, not again when a drag ends', () => { + const { row, list } = renderDay(); + expect(list.classList.contains('trek-stagger')).toBe(true); + fireEvent.dragStart(row('Museum'), { dataTransfer: dataTransfer() }); + expect(list.classList.contains('trek-stagger')).toBe(false); + fireEvent.dragEnd(row('Museum')); + expect(row('Museum').style.opacity).toBe('1'); + expect(list.classList.contains('trek-stagger')).toBe(false); + }); +}); diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 53e085c4a..7a821ef21 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -246,7 +246,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // distances refresh instead of showing stale cached text (#1300). const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const legsAbortRef = useRef(null) - const [draggingId, setDraggingId] = useState(null) + const [draggingId, _setDraggingId] = useState(null) + // The day list fades in once, on mount. Switching its stagger back on when a + // drag ended played that entrance again on every drop, so the first drag turns + // it off for good (#3041). + const [hasDragged, setHasDragged] = useState(false) + const setDraggingId = (id) => { if (id != null) setHasDragged(true); _setDraggingId(id) } const [lockedIds, setLockedIds] = useState(new Set()) const [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) @@ -379,10 +384,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // A drag whose source row re-rendered away never sends its dragend to the // document, and the drop line then stayed on a row until the next reload. The // browser sends no mousemove during a drag, so the first plain mouse move or - // press after one means it is over. + // press after one means it is over. Plain means no button held: Firefox fires + // dragstart while it handles the move that starts the drag and hands that move + // to the page afterwards, button still down. Read as the end, it threw the drag + // away at pick-up and left only the arrows to reorder a day (#3041). useEffect(() => { if (!draggingId && !dropTargetKey && !dragOverDayId) return - const settle = () => { + const settle = (e: MouseEvent | PointerEvent) => { + if (e.type === 'mousemove' && e.buttons !== 0) return setDraggingId(null) setDropTargetKey(null) setDragOverDayId(null) @@ -1148,6 +1157,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { hotelLegs, draggingId, setDraggingId, + hasDragged, lockedIds, dropTargetKey, dropTargetRef, @@ -1316,6 +1326,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP hotelLegs, draggingId, setDraggingId, + hasDragged, lockedIds, dropTargetKey, dropTargetRef, @@ -1476,7 +1487,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP /> {/* Tagesliste */} -
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> +
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> {days.map((day, index) => { const isSelected = selectedDayId === day.id // Shared by the click and the key handler so the two can never drift