mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 16:15:53 +00:00
fix: create exactly the time range the drag selection shows in week and day view
The existing drag test released the pointer far below its last move and expected the release spot to count; it now moves there first, since the release commits the range last drawn.
This commit is contained in:
@@ -82,6 +82,8 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
|
||||
act(() => result.current.handleGridPointerDown(pointer(160), "2026-09-03", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(400)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 780 });
|
||||
act(() => result.current.handleGridPointerMove(pointer(2000)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-09-03", startMinutes: 540, endMinutes: 1200 });
|
||||
act(() => result.current.handleGridPointerUp(pointer(2000)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
@@ -89,6 +91,61 @@ describe("useTimeGridInteractions slot clicks (#435)", () => {
|
||||
expect(end).toEqual(new Date(2026, 8, 3, 20, 0, 0, 0));
|
||||
});
|
||||
|
||||
// The selection said 11:15 – 16:15 and the event came out shorter: the
|
||||
// range drawn, its label and the event created must be the same.
|
||||
describe("drag-to-create creates what the selection shows", () => {
|
||||
const day = new Date(2026, 10, 2);
|
||||
let top = 0;
|
||||
const column = {
|
||||
getBoundingClientRect: () => ({ top }),
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn(),
|
||||
};
|
||||
const pointer = (clientY: number) => ({
|
||||
pointerType: "mouse", button: 0, pointerId: 1, clientY,
|
||||
target: { closest: () => null }, currentTarget: column,
|
||||
}) as unknown as React.PointerEvent<HTMLDivElement>;
|
||||
beforeEach(() => { top = 0; });
|
||||
|
||||
it("commits the range last drawn, not one measured again on release", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 975 });
|
||||
// The release reports a slightly different spot (a trackpad lifting).
|
||||
act(() => result.current.handleGridPointerUp(pointer(948)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
expect(start).toEqual(new Date(2026, 10, 2, 11, 15));
|
||||
expect(end).toEqual(new Date(2026, 10, 2, 16, 15));
|
||||
});
|
||||
|
||||
it("follows the grid when it scrolls during the drag, and creates what it then shows", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
// The wheel scrolls the grid up by an hour under a still pointer.
|
||||
top = -60;
|
||||
act(() => { window.dispatchEvent(new Event("scroll")); });
|
||||
expect(result.current.dragCreate).toEqual({ dayKey: "2026-11-02", startMinutes: 675, endMinutes: 1035 });
|
||||
act(() => result.current.handleGridPointerUp(pointer(975)));
|
||||
|
||||
const [start, end] = onCreateRange.mock.calls[0];
|
||||
expect(start).toEqual(new Date(2026, 10, 2, 11, 15));
|
||||
expect(end).toEqual(new Date(2026, 10, 2, 17, 15));
|
||||
});
|
||||
|
||||
it("creates nothing when the browser takes the pointer over", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
act(() => result.current.handleGridPointerDown(pointer(675), "2026-11-02", day));
|
||||
act(() => result.current.handleGridPointerMove(pointer(975)));
|
||||
act(() => result.current.handleGridPointerCancel());
|
||||
expect(result.current.dragCreate).toBeNull();
|
||||
act(() => result.current.handleGridPointerUp(pointer(975)));
|
||||
expect(onCreateRange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("a 23:00 click ends at midnight of the next day", () => {
|
||||
const { result, onCreateRange } = renderInteractions();
|
||||
const day = new Date(2026, 8, 3);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -72,6 +72,17 @@ interface UseTimeGridInteractionsOptions {
|
||||
onMoveEvent?: MoveEventHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* The range a drag from `anchorMinutes` to `currentMinutes` (both snapped)
|
||||
* selects, or null when it is shorter than a quarter of an hour. The
|
||||
* drawn selection, its label and the event it creates all come from this.
|
||||
*/
|
||||
export function dragCreateRange(anchorMinutes: number, currentMinutes: number): { startMinutes: number; endMinutes: number } | null {
|
||||
const startMinutes = Math.min(anchorMinutes, currentMinutes);
|
||||
const endMinutes = Math.max(anchorMinutes, currentMinutes);
|
||||
return endMinutes - startMinutes >= 15 ? { startMinutes, endMinutes } : null;
|
||||
}
|
||||
|
||||
/** `date` moved by `minutes` on the local clock, not by elapsed time. */
|
||||
function addWallClockMinutes(date: Date, minutes: number): Date {
|
||||
const shifted = new Date(date);
|
||||
@@ -101,6 +112,9 @@ export function useTimeGridInteractions({
|
||||
const wasDragging = useRef(false);
|
||||
|
||||
// --- Drag-to-create ---
|
||||
// The range on screen is the range created: the release commits the
|
||||
// selection last drawn rather than measuring the pointer again, and a
|
||||
// scroll during the drag redraws it under the pointer.
|
||||
const dragRef = useRef<{
|
||||
dayKey: string;
|
||||
dayDate: Date;
|
||||
@@ -108,10 +122,31 @@ export function useTimeGridInteractions({
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
captured: boolean;
|
||||
column: HTMLElement;
|
||||
lastY: number;
|
||||
range: { startMinutes: number; endMinutes: number } | null;
|
||||
stopScrollWatch?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [dragCreate, setDragCreate] = useState<DragCreateState | null>(null);
|
||||
|
||||
const updateDragRange = useCallback((clientY: number) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
drag.lastY = clientY;
|
||||
const range = dragCreateRange(drag.startMinutes, snapToMinutes(clientY, drag.column.getBoundingClientRect().top));
|
||||
drag.range = range;
|
||||
setDragCreate(range ? { dayKey: drag.dayKey, ...range } : null);
|
||||
}, [snapToMinutes]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
drag?.stopScrollWatch?.();
|
||||
setDragCreate(null);
|
||||
return drag;
|
||||
}, []);
|
||||
|
||||
const handleGridPointerDown = useCallback((
|
||||
e: PointerEvent<HTMLDivElement>,
|
||||
dayKey: string,
|
||||
@@ -125,34 +160,34 @@ export function useTimeGridInteractions({
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const minutes = snapToMinutes(e.clientY, rect.top);
|
||||
|
||||
dragRef.current?.stopScrollWatch?.();
|
||||
dragRef.current = {
|
||||
dayKey, dayDate, startMinutes: minutes,
|
||||
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
||||
column: e.currentTarget, lastY: e.clientY, range: null,
|
||||
};
|
||||
}, [snapToMinutes, isMobile]);
|
||||
|
||||
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
if (!dragRef.current.captured) {
|
||||
if (Math.abs(e.clientY - dragRef.current.startY) < 5) return;
|
||||
dragRef.current.captured = true;
|
||||
e.currentTarget.setPointerCapture(dragRef.current.pointerId);
|
||||
if (!drag.captured) {
|
||||
if (Math.abs(e.clientY - drag.startY) < 5) return;
|
||||
drag.captured = true;
|
||||
e.currentTarget.setPointerCapture(drag.pointerId);
|
||||
// The wheel or the browser's autoscroll moves the grid under a
|
||||
// pointer that stands still: follow it.
|
||||
const onScroll = () => updateDragRange(drag.lastY);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
drag.stopScrollWatch = () => window.removeEventListener("scroll", onScroll, true);
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const currentMinutes = snapToMinutes(e.clientY, rect.top);
|
||||
const start = Math.min(dragRef.current.startMinutes, currentMinutes);
|
||||
const end = Math.max(dragRef.current.startMinutes, currentMinutes);
|
||||
|
||||
setDragCreate(end > start ? { dayKey: dragRef.current.dayKey, startMinutes: start, endMinutes: end } : null);
|
||||
}, [snapToMinutes]);
|
||||
updateDragRange(e.clientY);
|
||||
}, [updateDragRange]);
|
||||
|
||||
const handleGridPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
setDragCreate(null);
|
||||
|
||||
const drag = endDrag();
|
||||
if (!drag || !drag.captured) return;
|
||||
|
||||
wasDragging.current = true;
|
||||
@@ -160,19 +195,22 @@ export function useTimeGridInteractions({
|
||||
|
||||
try { e.currentTarget.releasePointerCapture(drag.pointerId); } catch { /* may already be released */ }
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const endMinutes = snapToMinutes(e.clientY, rect.top);
|
||||
const start = Math.min(drag.startMinutes, endMinutes);
|
||||
const end = Math.max(drag.startMinutes, endMinutes);
|
||||
|
||||
if (end - start < 15) return;
|
||||
const range = drag.range;
|
||||
if (!range) return;
|
||||
|
||||
const startDate = new Date(drag.dayDate);
|
||||
startDate.setHours(Math.floor(start / 60), start % 60, 0, 0);
|
||||
startDate.setHours(Math.floor(range.startMinutes / 60), range.startMinutes % 60, 0, 0);
|
||||
const endDate = new Date(drag.dayDate);
|
||||
endDate.setHours(Math.floor(end / 60), end % 60, 0, 0);
|
||||
endDate.setHours(Math.floor(range.endMinutes / 60), range.endMinutes % 60, 0, 0);
|
||||
onCreateRange(startDate, endDate);
|
||||
}, [snapToMinutes, onCreateRange]);
|
||||
}, [endDrag, onCreateRange]);
|
||||
|
||||
useEffect(() => () => dragRef.current?.stopScrollWatch?.(), []);
|
||||
|
||||
/** The browser took the pointer over (a touch scroll, a system gesture): nothing is created. */
|
||||
const handleGridPointerCancel = useCallback(() => {
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
@@ -438,6 +476,7 @@ export function useTimeGridInteractions({
|
||||
handleGridPointerDown,
|
||||
handleGridPointerMove,
|
||||
handleGridPointerUp,
|
||||
handleGridPointerCancel,
|
||||
resizeVisual,
|
||||
handleResizePointerDown,
|
||||
handleResizePointerMove,
|
||||
|
||||
Reference in New Issue
Block a user