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
This commit is contained in:
Maurice
2026-11-11 21:26:15 +00:00
parent 001253c39b
commit 520562ef86
2 changed files with 69 additions and 4 deletions
@@ -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(
<DayPlanSidebar {...makeDefaultProps({ days: [day], places: stops, assignments, onReorder })} />
);
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);
});
});
@@ -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<AbortController | null>(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 */}
<div className={`scroll-container flex flex-col gap-2 p-2${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
<div className={`scroll-container flex flex-col gap-2 p-2${hasDragged ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => 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