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