diff --git a/packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx b/packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx
index 57a55053f7208..685bea57252d7 100644
--- a/packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx
+++ b/packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx
@@ -1,20 +1,12 @@
import * as React from 'react';
import { spy } from 'sinon';
-import {
- screen,
- fireEvent,
- createEvent,
- within,
- fireTouchChangedEvent,
- waitFor,
-} from '@mui/internal-test-utils';
+import { screen, fireEvent, within, waitFor } from '@mui/internal-test-utils';
import {
adapterToUse,
- buildPickerDragInteractions,
- rangeCalendarDayTouches,
+ executeDateDrag,
+ executeDateDragWithoutDrop,
createPickerRenderer,
} from 'test/utils/pickers';
-import { MockedDataTransfer } from 'test/utils/dragAndDrop';
import {
DateRangeCalendar,
dateRangeCalendarClasses as classes,
@@ -128,43 +120,6 @@ describe('', () => {
});
describe('dragging behavior', () => {
- let dataTransfer: DataTransfer | null;
-
- const { executeDateDragWithoutDrop, executeDateDrag } = buildPickerDragInteractions(
- () => dataTransfer,
- );
-
- type TouchTarget = Pick;
-
- const fireTouchEvent = (
- type: 'touchstart' | 'touchmove' | 'touchend',
- target: Element,
- touch: TouchTarget,
- ) => {
- fireTouchChangedEvent(target, type, { changedTouches: [touch] });
- };
-
- const executeDateTouchDragWithoutEnd = (target: Element, ...touchTargets: TouchTarget[]) => {
- fireTouchEvent('touchstart', target, touchTargets[0]);
- touchTargets.slice(0, touchTargets.length - 1).forEach((touch) => {
- fireTouchEvent('touchmove', target, touch);
- });
- };
-
- const executeDateTouchDrag = (target: Element, ...touchTargets: TouchTarget[]) => {
- const endTouchTarget = touchTargets[touchTargets.length - 1];
- executeDateTouchDragWithoutEnd(target, ...touchTargets);
- fireTouchEvent('touchend', target, endTouchTarget);
- };
-
- beforeEach(() => {
- dataTransfer = new MockedDataTransfer();
- });
-
- afterEach(() => {
- dataTransfer = null;
- });
-
it('should not emit "onChange" when dragging is ended where it was started', () => {
const onChange = spy();
render(
@@ -183,31 +138,6 @@ describe('', () => {
expect(onChange.callCount).to.equal(0);
});
- it.skipIf(!document.elementFromPoint)(
- 'should not emit "onChange" when touch dragging is ended where it was started',
- () => {
- const onChange = spy();
- render(
- ,
- );
-
- const startDay = screen.getByRole('gridcell', { name: '1', selected: true });
- expect(onChange.callCount).to.equal(0);
-
- executeDateTouchDrag(
- startDay,
- rangeCalendarDayTouches['2018-01-01'],
- rangeCalendarDayTouches['2018-01-02'],
- rangeCalendarDayTouches['2018-01-01'],
- );
-
- expect(onChange.callCount).to.equal(0);
- },
- );
-
it('should emit "onChange" when dragging end date', () => {
const onChange = spy();
const initialValue: [any, any] = [
@@ -251,51 +181,6 @@ describe('', () => {
expect(document.activeElement).toHaveAccessibleName('2');
});
- it.skipIf(!document.elementFromPoint)(
- 'should emit "onChange" when touch dragging end date',
- () => {
- const onChange = spy();
- const initialValue: [any, any] = [
- adapterToUse.date('2018-01-02'),
- adapterToUse.date('2018-01-11'),
- ];
- render();
-
- // test range reduction
- executeDateTouchDrag(
- getPickerDay('11'),
- rangeCalendarDayTouches['2018-01-11'],
- rangeCalendarDayTouches['2018-01-10'],
- );
-
- expect(onChange.callCount).to.equal(1);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[0]);
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(new Date(2018, 0, 10));
-
- // test range expansion
- executeDateTouchDrag(
- getPickerDay('10'),
- rangeCalendarDayTouches['2018-01-10'],
- rangeCalendarDayTouches['2018-01-11'],
- );
-
- expect(onChange.callCount).to.equal(2);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[0]);
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(initialValue[1]);
-
- // test range flip
- executeDateTouchDrag(
- getPickerDay('11'),
- rangeCalendarDayTouches['2018-01-11'],
- rangeCalendarDayTouches['2018-01-01'],
- );
-
- expect(onChange.callCount).to.equal(3);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(new Date(2018, 0, 1));
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(initialValue[0]);
- },
- );
-
it('should emit "onChange" when dragging start date', () => {
const onChange = spy();
const initialValue: [any, any] = [
@@ -329,51 +214,6 @@ describe('', () => {
expect(document.activeElement).toHaveAccessibleName('22');
});
- it.skipIf(!document.elementFromPoint)(
- 'should emit "onChange" when touch dragging start date',
- () => {
- const onChange = spy();
- const initialValue: [any, any] = [
- adapterToUse.date('2018-01-01'),
- adapterToUse.date('2018-01-10'),
- ];
- render();
-
- // test range reduction
- executeDateTouchDrag(
- getPickerDay('1'),
- rangeCalendarDayTouches['2018-01-01'],
- rangeCalendarDayTouches['2018-01-02'],
- );
-
- expect(onChange.callCount).to.equal(1);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(new Date(2018, 0, 2));
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(initialValue[1]);
-
- // test range expansion
- executeDateTouchDrag(
- getPickerDay('2'),
- rangeCalendarDayTouches['2018-01-02'],
- rangeCalendarDayTouches['2018-01-01'],
- );
-
- expect(onChange.callCount).to.equal(2);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[0]);
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(initialValue[1]);
-
- // test range flip
- executeDateTouchDrag(
- getPickerDay('1'),
- rangeCalendarDayTouches['2018-01-01'],
- rangeCalendarDayTouches['2018-01-11'],
- );
-
- expect(onChange.callCount).to.equal(3);
- expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[1]);
- expect(onChange.lastCall.args[0][1]).toEqualDateTime(new Date(2018, 0, 11));
- },
- );
-
it('should dynamically update "shouldDisableDate" when flip dragging', () => {
const initialValue: [any, any] = [
adapterToUse.date('2018-01-01'),
@@ -404,44 +244,10 @@ describe('', () => {
).to.have.lengthOf(10);
});
- it.skipIf(!document.elementFromPoint)(
- 'should dynamically update "shouldDisableDate" when flip touch dragging',
- () => {
- const initialValue: [any, any] = [
- adapterToUse.date('2018-01-01'),
- adapterToUse.date('2018-01-07'),
- ];
- render(
- ,
- );
-
- expect(screen.getByRole('gridcell', { name: '5' })).to.have.attribute('disabled');
- expect(
- screen.getAllByRole('gridcell').filter((c) => c.disabled),
- ).to.have.lengthOf(6);
- // flip date range
- executeDateTouchDragWithoutEnd(
- screen.getByRole('gridcell', { name: '1' }),
- rangeCalendarDayTouches['2018-01-01'],
- rangeCalendarDayTouches['2018-01-09'],
- rangeCalendarDayTouches['2018-01-10'],
- );
-
- expect(screen.getByRole('gridcell', { name: '9' })).to.have.attribute('disabled');
- expect(
- screen.getAllByRole('gridcell').filter((c) => c.disabled),
- ).to.have.lengthOf(10);
- },
- );
-
- it('should handle drag events targeting child elements inside the day button', () => {
- // This test validates the fix for when drag events target child elements (e.g., text spans)
- // inside the day button, rather than the button itself. The fix uses .closest() to find
- // the ancestor with the data-timestamp attribute.
+ it('should handle pointer events targeting child elements inside the day button', () => {
+ // Real browsers can route pointer events to child nodes (text span, ripple)
+ // when the user touches inside the day button. The handler must walk up to
+ // the button to read its data attributes — exercise that path explicitly.
const onChange = spy();
const initialValue: [PickerValidDate, PickerValidDate] = [
adapterToUse.date('2018-01-10'),
@@ -452,31 +258,14 @@ describe('', () => {
const startDayButton = screen.getByRole('gridcell', { name: '31', selected: true });
const endDayButton = screen.getByRole('gridcell', { name: '29' });
- // Create synthetic child elements inside the buttons to simulate the real browser scenario
- // where drag events can target child elements (e.g., text spans, TouchRipple).
- // This ensures the `.closest()` fallback path is exercised.
const startDayChild = document.createElement('span');
startDayButton.appendChild(startDayChild);
const endDayChild = document.createElement('span');
endDayButton.appendChild(endDayChild);
- // Execute drag using child elements as targets
- // This simulates a user clicking on the day number text or ripple effect
- const createDragEventOnChild = (
- type: 'dragStart' | 'dragEnter' | 'dragOver' | 'drop' | 'dragEnd' | 'dragLeave',
- target: Element,
- ) => {
- const createdEvent = createEvent[type](target);
- Object.defineProperty(createdEvent, 'dataTransfer', { value: dataTransfer });
- return createdEvent;
- };
-
- fireEvent(startDayChild, createDragEventOnChild('dragStart', startDayChild));
- fireEvent(startDayChild, createDragEventOnChild('dragLeave', startDayChild));
- fireEvent(endDayChild, createDragEventOnChild('dragEnter', endDayChild));
- fireEvent(endDayChild, createDragEventOnChild('dragOver', endDayChild));
- fireEvent(endDayChild, createDragEventOnChild('drop', endDayChild));
- fireEvent(endDayChild, createDragEventOnChild('dragEnd', endDayChild));
+ fireEvent.pointerDown(startDayChild, { pointerId: 1, button: 0, isPrimary: true });
+ fireEvent.pointerOver(endDayChild, { pointerId: 1 });
+ fireEvent.pointerUp(document, { pointerId: 1 });
expect(onChange.callCount).to.equal(1);
expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[0]);
diff --git a/packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts b/packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
index bb5e0e1be34e8..ad15cce37be67 100644
--- a/packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
+++ b/packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
@@ -1,23 +1,17 @@
'use client';
import * as React from 'react';
import useEventCallback from '@mui/utils/useEventCallback';
-import { getTarget, isHTMLElement } from '@mui/x-internals/domUtils';
+import { isHTMLElement } from '@mui/x-internals/domUtils';
import { MuiPickersAdapter, PickersTimezone, PickerValidDate } from '@mui/x-date-pickers/models';
import { PickerRangeValue } from '@mui/x-date-pickers/internals';
import { RangePosition } from '../models';
import { isEndOfRange, isStartOfRange } from '../internals/utils/date-utils';
-const isEnabledButtonElement = (element: Element | null): element is HTMLButtonElement =>
- isHTMLElement(element) &&
- element.tagName === 'BUTTON' &&
- !(element as HTMLButtonElement).disabled;
-
interface UseDragRangeParams {
disableDragEditing?: boolean;
adapter: MuiPickersAdapter;
setRangeDragDay: (value: PickerValidDate | null) => void;
setIsDragging: (value: boolean) => void;
- isDragging: boolean;
onDatePositionChange: (position: RangePosition) => void;
onDrop: (newDate: PickerValidDate) => void;
dateRange: PickerRangeValue;
@@ -25,15 +19,8 @@ interface UseDragRangeParams {
}
interface UseDragRangeEvents {
- onDragStart?: React.DragEventHandler;
- onDragEnter?: React.DragEventHandler;
- onDragLeave?: React.DragEventHandler;
- onDragOver?: React.DragEventHandler;
- onDragEnd?: React.DragEventHandler;
- onDrop?: React.DragEventHandler;
- onTouchStart?: React.TouchEventHandler;
- onTouchMove?: React.TouchEventHandler;
- onTouchEnd?: React.TouchEventHandler;
+ onPointerDown?: React.PointerEventHandler;
+ onPointerOver?: React.PointerEventHandler;
}
interface UseDragRangeResponse extends UseDragRangeEvents {
@@ -43,14 +30,9 @@ interface UseDragRangeResponse extends UseDragRangeEvents {
}
/**
- * Finds the closest ancestor element (or the element itself) that has the specified data attribute.
- * This is needed because drag/touch events can target child elements (e.g., text spans)
- * inside the button, which don't have the data attributes directly.
- *
- * @param element The element to start searching from.
- * @param dataAttribute The data attribute name — must be a single lowercase word
- * (e.g., 'timestamp', 'position') because `dataset[attr]` uses camelCase
- * while `.closest()` uses kebab-case, and these only align for single-word names.
+ * Returns the element (or its closest ancestor) carrying `data-{attr}`.
+ * Single-word `attr` only — `dataset[attr]` (camelCase) and `.closest()`
+ * (kebab-case) only agree for single-word names.
*/
const getClosestElementWithDataAttribute = (
element: HTMLElement | null,
@@ -83,96 +65,23 @@ const resolveDateFromTarget = (
return adapter.date(new Date(timestamp).toISOString(), timezone);
};
-const isSameAsDraggingDate = (event: React.DragEvent) => {
- const target = getTarget(event.nativeEvent);
- if (!isHTMLElement(target)) {
- return false;
- }
- const element = getClosestElementWithDataAttribute(target, 'timestamp');
- return element?.dataset.timestamp === event.dataTransfer.getData('draggingDate');
-};
-
-/**
- * Resolves a button element from a given element.
- * Searches both upward (ancestors) and downward (children) since:
- * - Touch events may target child elements inside the button (e.g., TouchRipple)
- * - `elementFromPoint` may return wrapper divs containing the button
- */
-const resolveButtonElement = (element: Element | null): HTMLButtonElement | null => {
- if (!element) {
- return null;
- }
-
- // Check if element itself is a valid button
- if (isEnabledButtonElement(element)) {
- return element;
- }
-
- // Search upward - element could be a child of the button (e.g., text span, TouchRipple)
- const closestButton = element.closest('button');
- if (isEnabledButtonElement(closestButton)) {
- return closestButton;
- }
-
- // Search downward (breadth-first, max 3 levels) - element could be a wrapper containing the button.
- // Day cells have shallow DOM, so a small depth limit keeps this efficient.
- const queue: Array<{ el: Element; depth: number }> = Array.from(element.children).map((el) => ({
- el,
- depth: 1,
- }));
- const maxDepth = 3;
- while (queue.length > 0) {
- const { el: current, depth } = queue.shift()!;
- if (isEnabledButtonElement(current)) {
- return current;
- }
- if (depth < maxDepth) {
- queue.push(...Array.from(current.children).map((el) => ({ el, depth: depth + 1 })));
- }
- }
-
- return null;
-};
-
-const resolveElementFromTouch = (
- event: React.TouchEvent,
- ignoreTouchTarget?: boolean,
-) => {
- // don't parse multi-touch result
- if (event.changedTouches?.length === 1 && event.touches.length <= 1) {
- const element = document.elementFromPoint(
- event.changedTouches[0].clientX,
- event.changedTouches[0].clientY,
- );
- // `elementFromPoint` could have resolved preview div or wrapping div
- // might need to recursively find the nested button
- const buttonElement = resolveButtonElement(element);
- if (ignoreTouchTarget && buttonElement === event.changedTouches[0].target) {
- return null;
- }
- return buttonElement;
- }
- return null;
-};
-
const useDragRangeEvents = ({
adapter,
setRangeDragDay,
setIsDragging,
- isDragging,
onDatePositionChange,
onDrop,
disableDragEditing,
dateRange,
timezone,
}: UseDragRangeParams): UseDragRangeEvents => {
- const emptyDragImgRef = React.useRef(null);
- React.useEffect(() => {
- // Preload the image - required for Safari support: https://stackoverflow.com/a/40923520/3303436
- emptyDragImgRef.current = document.createElement('img');
- emptyDragImgRef.current.src =
- 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
- }, []);
+ const isDraggingRef = React.useRef(false);
+ const pointerIdRef = React.useRef(null);
+ const sourceDateRef = React.useRef(null);
+ const sourcePositionRef = React.useRef(null);
+ const didMoveRef = React.useRef(false);
+ const pendingDropRef = React.useRef<{ date: PickerValidDate; target: HTMLElement } | null>(null);
+ const cleanupListenersRef = React.useRef<(() => void) | null>(null);
const isElementDraggable = (day: PickerValidDate | null): day is PickerValidDate => {
if (day == null) {
@@ -186,166 +95,174 @@ const useDragRangeEvents = ({
return shouldInitDragging && (isSelectedStartDate || isSelectedEndDate);
};
- const handleDragStart = useEventCallback((event: React.DragEvent) => {
- const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
- if (!isElementDraggable(newDate)) {
- return;
+ const cleanup = useEventCallback(() => {
+ const wasActive = didMoveRef.current;
+ isDraggingRef.current = false;
+ pointerIdRef.current = null;
+ sourceDateRef.current = null;
+ sourcePositionRef.current = null;
+ didMoveRef.current = false;
+ pendingDropRef.current = null;
+ // A press without movement never activated drag UI, so skip the re-render.
+ if (wasActive) {
+ setIsDragging(false);
+ setRangeDragDay(null);
}
+ cleanupListenersRef.current?.();
+ cleanupListenersRef.current = null;
+ });
- event.stopPropagation();
- if (emptyDragImgRef.current) {
- event.dataTransfer.setDragImage(emptyDragImgRef.current, 0, 0);
- }
- setRangeDragDay(newDate);
- event.dataTransfer.effectAllowed = 'move';
- setIsDragging(true);
- // Use currentTarget (the element the handler is attached to) rather than target
- // because we need the button's dataset, not a potential child element's dataset.
- const element = getClosestElementWithDataAttribute(event.currentTarget, 'timestamp');
- const buttonDataset = element?.dataset;
- if (buttonDataset?.timestamp) {
- event.dataTransfer.setData('draggingDate', buttonDataset.timestamp);
- }
- if (buttonDataset?.position) {
- onDatePositionChange(buttonDataset.position as RangePosition);
+ const handlePointerDown = useEventCallback((event: React.PointerEvent) => {
+ // Ignore secondary mouse buttons. `> 0` (not `!== 0`) so an undefined
+ // `button` (jsdom) is treated as primary.
+ if (event.button > 0) {
+ return;
}
- });
- const handleTouchStart = useEventCallback((event: React.TouchEvent) => {
- const target = resolveElementFromTouch(event);
- if (!target) {
+ // Drop re-entrant pointerdowns: a second pointer (multi-touch, pen+touch)
+ // arriving mid-gesture would overwrite our state and leak listeners. The
+ // `pointerIdRef` check also covers pen+touch (each pointer type has its
+ // own primary) and recovery from a lost `pointerup`.
+ if (pointerIdRef.current != null || event.isPrimary === false) {
return;
}
- const newDate = resolveDateFromTarget(target, adapter, timezone);
+ const newDate = resolveDateFromTarget(event.currentTarget, adapter, timezone);
if (!isElementDraggable(newDate)) {
return;
}
- setRangeDragDay(newDate);
- });
-
- const handleDragEnter = useEventCallback((event: React.DragEvent) => {
- if (!isDragging) {
- return;
+ // Touch implicitly captures the pointer on `pointerdown`, pinning all
+ // subsequent events to the source. Release so sibling cells receive their
+ // own `pointerover` (jsdom lacks the capture API — guard the call).
+ if (
+ typeof event.currentTarget.hasPointerCapture === 'function' &&
+ event.currentTarget.hasPointerCapture(event.pointerId)
+ ) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
}
- event.preventDefault();
event.stopPropagation();
- event.dataTransfer.dropEffect = 'move';
- setRangeDragDay(resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone));
- });
- const handleTouchMove = useEventCallback((event: React.TouchEvent) => {
- const target = resolveElementFromTouch(event);
- if (!target) {
- return;
- }
+ pointerIdRef.current = event.pointerId;
+ isDraggingRef.current = true;
+ sourceDateRef.current = newDate;
+ didMoveRef.current = false;
+ pendingDropRef.current = { date: newDate, target: event.currentTarget };
- const newDate = resolveDateFromTarget(target, adapter, timezone);
- if (newDate) {
- setRangeDragDay(newDate);
- }
+ const { position } = event.currentTarget.dataset;
+ sourcePositionRef.current = (position as RangePosition | undefined) ?? null;
- // this prevents initiating drag when user starts touchmove outside and then moves over a draggable element
- const targetsAreIdentical = target === event.changedTouches[0].target;
- if (!targetsAreIdentical || !isElementDraggable(newDate)) {
- return;
- }
+ // Drag UI activation is deferred to `handlePointerOver`'s first real
+ // move — a pure tap on an endpoint must leave `rangePosition` alone
+ // so the click handler can advance it normally.
- // on mobile we should only initialize dragging state after move is detected
- setIsDragging(true);
+ const onPointerUp = (pointerEvent: PointerEvent) => {
+ if (pointerEvent.pointerId !== pointerIdRef.current) {
+ return;
+ }
- // Use currentTarget (the element the handler is attached to) rather than target
- // because we need the button's dataset, not a potential child element's dataset.
- const element = getClosestElementWithDataAttribute(event.currentTarget, 'position');
- const buttonDataset = element?.dataset;
- if (buttonDataset?.position) {
- onDatePositionChange(buttonDataset.position as RangePosition);
- }
- });
+ const wasMoved = didMoveRef.current;
+ const dropInfo = pendingDropRef.current;
+ const sourceDate = sourceDateRef.current;
+
+ cleanup();
+
+ if (wasMoved) {
+ // Swallow the click that follows pointerup — it would re-enter the
+ // day's selection logic and undo the drop. If no click fires (drop on
+ // a different cell), tear the listener down on the next macrotask so
+ // it doesn't leak into the next interaction.
+ const suppressClick = (clickEvent: Event) => {
+ clickEvent.preventDefault();
+ clickEvent.stopPropagation();
+ document.removeEventListener('click', suppressClick, { capture: true });
+ };
+ document.addEventListener('click', suppressClick, { capture: true });
+ setTimeout(() => {
+ document.removeEventListener('click', suppressClick, { capture: true });
+ }, 0);
+ }
- const handleDragLeave = useEventCallback((event: React.DragEvent) => {
- if (!isDragging) {
- return;
- }
+ if (wasMoved && dropInfo && sourceDate && !adapter.isEqual(dropInfo.date, sourceDate)) {
+ dropInfo.target.focus();
+ onDrop(dropInfo.date);
+ }
+ };
- event.preventDefault();
- event.stopPropagation();
- });
+ const onPointerCancel = (pointerEvent: PointerEvent) => {
+ if (pointerEvent.pointerId !== pointerIdRef.current) {
+ return;
+ }
+ cleanup();
+ };
+
+ const onTouchMove = (touchEvent: TouchEvent) => {
+ // Suppress page scroll while dragging — `touch-action: none` on the
+ // source cell isn't enough once the finger leaves it.
+ if (isDraggingRef.current) {
+ touchEvent.preventDefault();
+ }
+ };
- const handleDragOver = useEventCallback((event: React.DragEvent) => {
- if (!isDragging) {
- return;
- }
+ document.addEventListener('pointerup', onPointerUp);
+ document.addEventListener('pointercancel', onPointerCancel);
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
- event.preventDefault();
- event.stopPropagation();
- event.dataTransfer.dropEffect = 'move';
+ cleanupListenersRef.current = () => {
+ document.removeEventListener('pointerup', onPointerUp);
+ document.removeEventListener('pointercancel', onPointerCancel);
+ document.removeEventListener('touchmove', onTouchMove);
+ };
});
- const handleTouchEnd = useEventCallback((event: React.TouchEvent) => {
- if (!isDragging) {
+ // Use `pointerover` (bubbles) rather than `pointerenter`: React's
+ // `onPointerEnter` is built on top of over/out, and only bubbling events
+ // round-trip through testing-library's `fireEvent`.
+ const handlePointerOver = useEventCallback((event: React.PointerEvent) => {
+ if (!isDraggingRef.current || event.pointerId !== pointerIdRef.current) {
return;
}
- setRangeDragDay(null);
- setIsDragging(false);
-
- const target = resolveElementFromTouch(event, true);
- if (!target) {
+ if (pendingDropRef.current?.target === event.currentTarget) {
return;
}
- // make sure the focused element is the element where touch ended
- target.focus();
- const newDate = resolveDateFromTarget(target, adapter, timezone);
- if (newDate) {
- onDrop(newDate);
- }
- });
-
- const handleDragEnd = useEventCallback((event: React.DragEvent) => {
- if (!isDragging) {
+ const newDate = resolveDateFromTarget(event.currentTarget, adapter, timezone);
+ if (!newDate) {
return;
}
- event.preventDefault();
- event.stopPropagation();
- setIsDragging(false);
- setRangeDragDay(null);
- });
+ pendingDropRef.current = { date: newDate, target: event.currentTarget };
- const handleDrop = useEventCallback((event: React.DragEvent) => {
- if (!isDragging) {
- return;
- }
+ const isDifferentFromSource =
+ sourceDateRef.current && !adapter.isEqual(newDate, sourceDateRef.current);
- event.preventDefault();
- event.stopPropagation();
- setIsDragging(false);
- setRangeDragDay(null);
- // make sure the focused element is the element where drop ended
- event.currentTarget.focus();
- if (isSameAsDraggingDate(event)) {
- return;
+ if (!didMoveRef.current && isDifferentFromSource) {
+ // First real move: activate drag UI and tell the parent which endpoint
+ // is being dragged so the preview computes against the correct side.
+ didMoveRef.current = true;
+ if (sourcePositionRef.current) {
+ onDatePositionChange(sourcePositionRef.current);
+ }
+ setIsDragging(true);
}
- const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
- if (newDate) {
- onDrop(newDate);
+
+ if (didMoveRef.current) {
+ setRangeDragDay(newDate);
}
});
+ React.useEffect(
+ () => () => {
+ cleanupListenersRef.current?.();
+ },
+ [],
+ );
+
return {
- onDragStart: handleDragStart,
- onDragEnter: handleDragEnter,
- onDragLeave: handleDragLeave,
- onDragOver: handleDragOver,
- onDragEnd: handleDragEnd,
- onDrop: handleDrop,
- onTouchStart: handleTouchStart,
- onTouchMove: handleTouchMove,
- onTouchEnd: handleTouchEnd,
+ onPointerDown: handlePointerDown,
+ onPointerOver: handlePointerOver,
};
};
@@ -356,10 +273,7 @@ export const useDragRange = ({
onDrop,
dateRange,
timezone,
-}: Omit<
- UseDragRangeParams,
- 'setRangeDragDay' | 'setIsDragging' | 'isDragging'
->): UseDragRangeResponse => {
+}: Omit): UseDragRangeResponse => {
const [isDragging, setIsDragging] = React.useState(false);
const [rangeDragDay, setRangeDragDay] = React.useState(null);
@@ -387,7 +301,6 @@ export const useDragRange = ({
onDatePositionChange,
onDrop,
setIsDragging,
- isDragging,
setRangeDragDay: handleRangeDragDayChange,
disableDragEditing,
dateRange,
diff --git a/packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx b/packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx
index 5dc1d73da0534..13cfd6a27e904 100644
--- a/packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx
+++ b/packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx
@@ -222,7 +222,13 @@ const DateRangePickerDayRoot = styled(ButtonBase, {
props: { isDayDraggable: true },
style: {
cursor: 'grab',
+ // Stop the browser from scrolling the page when the user drags a finger
+ // across the cell — the drag is driven by our own Pointer Events handler.
touchAction: 'none',
+ // Prevent the iOS text-selection callout from racing the drag gesture.
+ WebkitTouchCallout: 'none',
+ WebkitUserSelect: 'none',
+ userSelect: 'none',
},
},
{
@@ -524,7 +530,6 @@ const DateRangePickerDayRaw = React.forwardRef(function DateRangePickerDay(
onMouseEnter={(event) => onMouseEnter(event, day)}
onClick={handleClick}
onMouseDown={handleMouseDown}
- draggable={draggable}
{...other}
ownerState={ownerState}
className={clsx(classes.root, className)}
diff --git a/test/utils/pickers/calendar.ts b/test/utils/pickers/calendar.ts
index 93fd8107afe13..2302b03cb919c 100644
--- a/test/utils/pickers/calendar.ts
+++ b/test/utils/pickers/calendar.ts
@@ -1,57 +1,25 @@
-import { fireEvent, createEvent } from '@mui/internal-test-utils';
-import { DragEventTypes } from '../dragAndDrop';
+import { fireEvent } from '@mui/internal-test-utils';
-export const rangeCalendarDayTouches = {
- '2018-01-01': {
- clientX: 85,
- clientY: 125,
- },
- '2018-01-02': {
- clientX: 125,
- clientY: 125,
- },
- '2018-01-09': {
- clientX: 125,
- clientY: 165,
- },
- '2018-01-10': {
- clientX: 165,
- clientY: 165,
- },
- '2018-01-11': {
- clientX: 205,
- clientY: 165,
- },
-} as const;
+const POINTER_ID = 1;
-export const buildPickerDragInteractions = (getDataTransfer: () => DataTransfer | null) => {
- const createDragEvent = (type: DragEventTypes, target: ChildNode) => {
- const createdEvent = createEvent[type](target);
- Object.defineProperty(createdEvent, 'dataTransfer', {
- value: getDataTransfer(),
- });
- return createdEvent;
- };
-
- const executeDateDragWithoutDrop = (startDate: ChildNode, ...otherDates: ChildNode[]) => {
- const endDate = otherDates[otherDates.length - 1];
- fireEvent(startDate, createDragEvent('dragStart', startDate));
- fireEvent(startDate, createDragEvent('dragLeave', startDate));
- otherDates.slice(0, otherDates.length - 1).forEach((date) => {
- fireEvent(date, createDragEvent('dragEnter', date));
- fireEvent(date, createDragEvent('dragOver', date));
- fireEvent(date, createDragEvent('dragLeave', date));
- });
- fireEvent(endDate, createDragEvent('dragEnter', endDate));
- fireEvent(endDate, createDragEvent('dragOver', endDate));
- };
-
- const executeDateDrag = (startDate: ChildNode, ...otherDates: ChildNode[]) => {
- executeDateDragWithoutDrop(startDate, ...otherDates);
- const endDate = otherDates[otherDates.length - 1];
- fireEvent(endDate, createDragEvent('drop', endDate));
- fireEvent(endDate, createDragEvent('dragEnd', endDate));
- };
+/**
+ * Replays a pointer drag across day cells: pointerdown on the source, then
+ * pointerover on each subsequent cell, then pointerup. Used to test the
+ * DateRangeCalendar drag-to-edit interaction in jsdom. We fire `pointerover`
+ * (bubbles) rather than `pointerenter` (doesn't bubble) so React's delegated
+ * listener picks the events up.
+ */
+export const executeDateDragWithoutDrop = (startDate: Element, ...otherDates: Element[]) => {
+ // `isPrimary: true` matches what real browsers produce for a first-finger
+ // touch / mouse press; the production handler short-circuits secondary
+ // multi-touch pointers via `event.isPrimary === false`.
+ fireEvent.pointerDown(startDate, { pointerId: POINTER_ID, button: 0, isPrimary: true });
+ otherDates.forEach((date) => {
+ fireEvent.pointerOver(date, { pointerId: POINTER_ID });
+ });
+};
- return { executeDateDragWithoutDrop, executeDateDrag };
+export const executeDateDrag = (startDate: Element, ...otherDates: Element[]) => {
+ executeDateDragWithoutDrop(startDate, ...otherDates);
+ fireEvent.pointerUp(document, { pointerId: POINTER_ID });
};