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 }); };