[DateRangeCalendar] Use Pointer Events for drag editing#22279
Open
LukasTy wants to merge 12 commits intomui:masterfrom
Open
[DateRangeCalendar] Use Pointer Events for drag editing#22279LukasTy wants to merge 12 commits intomui:masterfrom
LukasTy wants to merge 12 commits intomui:masterfrom
Conversation
…events The hook used to run two parallel paths: HTML5 drag handlers for desktop and a custom touch path for mobile. The touch path predates iOS 15 native drag-and-drop support and is no longer necessary. Rely on drag events alone, with two tweaks lifted from Pragmatic Drag and Drop's element adapter so drag works on touch devices: - Always call `setData` on dragstart (iOS 15 silently swallows subsequent drag events otherwise). - Set both `draggingDate` (custom key, used by the same-date drop guard) and `text/plain` (Android Chrome will not fire `dragover`/`drop` without `text/plain` or `text/uri-list` in the dataTransfer). Drops support for iOS 14 and pre-Chromium Android, both of which never worked reliably with the touch fallback either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploy previewhttps://deploy-preview-22279--material-ui-x.netlify.app/ Bundle size
Check out the code infra dashboard for more information about this PR. |
…press iOS Safari was intercepting the long-press on a draggable day as text- selection intent, never firing `dragstart`. The HTML `draggable="true"` attribute alone doesn't enable in-page drag on iOS — WebKit needs `-webkit-user-drag: element`, plus the text-selection UI suppressed so it doesn't race the drag intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`touch-action: none` was added to suppress browser default touch handling during the custom touch-event drag path. With that path gone, it can prevent iOS WebKit from registering a long-press as drag intent, since the browser uses default touch behavior to detect the gesture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t the drag iOS Safari aborts an in-flight HTML5 drag if the source element's DOM mutates during `dragstart` — and our handler was synchronously calling `setRangeDragDay`, `setIsDragging`, and `onDatePositionChange`, each re-rendering the day grid (toggling `data-position`, recomputing the dragging-range highlight, etc.) right inside the dragstart event. React Aria's `useDrag` waits a frame before flipping its dragging state for the same reason. Mirror that here by deferring the React state updates to `requestAnimationFrame`. To keep the synchronous gate that prevents `dragenter` / `dragover` / `drop` from acting outside an active drag (e.g. a stray external file drag), track an `isDraggingRef` alongside the state — set synchronously, read in the gate, and the React state is just for re-rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it, iOS Safari treats a quick swipe on a draggable day as page scroll instead of waiting for the long-press timer to confirm drag intent. With it, touches on draggable cells are committed to drag (after the native ~500ms long-press) and won't accidentally scroll the page. Touches on non-draggable cells are unaffected — only the isDayDraggable variant gets `touch-action: none`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native HTML5 drag-and-drop on iOS Safari requires a ~500ms long-press gesture before `dragstart` fires (the browser's way of distinguishing drag from scroll). That's a fundamental property of the platform and can't be tweaked with CSS or rAF tricks — it shipped that way and stays that way. React Aria's `useMove` and the per-cell drag in their `useCalendarCell` both bypass native drag entirely on touch by using Pointer Events, which makes drag start as soon as the pointer leaves its initial position. Mirror that pattern here. The new flow: - `pointerdown` on a range-endpoint day starts the drag immediately (no delay, no long-press) and releases the implicit pointer capture so sibling cells can fire `pointerover` as the finger moves across the grid — same trick `usePress` uses. - `pointerover` on a cell during the drag updates the preview range. - A document-level `pointerup` listener commits the drop. - A document-level `touchmove` listener with `preventDefault` keeps the page from scrolling while the drag is in flight. - A capture-phase one-shot `click` suppressor prevents the synthesized click after a moved drag from re-entering the day's selection logic. - A no-op `onDragStart` cancels the browser's native drag so it doesn't draw a ghost on top of our pointer-driven gesture. The hook keeps its existing return shape semantically (`isDragging`, `rangeDragDay`, `draggingDatePosition`, plus event handlers to spread on cells) so `DateRangeCalendar` doesn't change. Tests use Pointer Events via `fireEvent.pointerDown` / `.pointerOver` / `.pointerUp`. `MockedDataTransfer` is no longer needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`handlePointerDown` was synchronously calling `onDatePositionChange` (and flipping `isDragging` / `rangeDragDay`) on every press of a range endpoint. That mutated `rangePosition` even for pure taps, and the click that followed the tap then routed into the wrong side of the range — breaking the e2e flow that taps an existing endpoint twice to collapse the range to a single day. The Android Chrome e2e "should allow re-selecting value to have the same start and end date" caught this. In the original HTML5-drag implementation, `dragstart` only fired after real movement, so taps were invisible to the hook. Mirror that: stash the source date / position / pointerId on `pointerdown`, but wait until `pointerover` reports a *different* cell before activating drag UI and notifying the parent of the source endpoint. A press without movement never touches React state or `rangePosition`, so the click handler runs with the calendar's natural state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR migrates DateRangeCalendar’s drag-to-edit interaction from native HTML5 drag-and-drop (plus a separate touch path) to a unified Pointer Events implementation, improving mobile UX (notably eliminating iOS Safari’s long-press drag delay).
Changes:
- Rewrote
useDragRangeto drive range endpoint dragging viapointerdown/pointeroverwith document-levelpointerupcommit handling and scroll suppression during the gesture. - Updated DateRangePickerDay draggable styling/behavior to align with the new pointer-driven gesture and avoid native side-effects (selection/callouts).
- Updated unit tests and test utilities to replay drags using pointer events instead of drag/touch events.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/utils/pickers/calendar.ts | Replaces drag-event test helpers with pointer-event drag replay helpers. |
| packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx | Adjusts draggable-day CSS to better support pointer dragging (no scrolling/selection callouts). |
| packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts | Core rewrite: pointer-driven drag tracking, preview updates on pointerover, and commit/cancel via document listeners. |
| packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx | Migrates drag tests from DataTransfer-based drag events to pointer events; removes obsolete touch-path tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ters A second pointerdown arriving while a drag is already in flight (multi-touch, pen joining a touch, second finger tap) would overwrite `pointerIdRef` and `cleanupListenersRef`, leaking the first gesture's document listeners and silencing its `pointerup` because the id check in the listener would no longer match. Bail early in that case so the original gesture stays in control. Two guards: `event.isPrimary === false` filters secondary multi-touch pointers up front, and `pointerIdRef.current != null` covers the case where some prior gesture is still considered active (also a recovery path if its pointerup was somehow lost). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The day cell button no longer needs the HTML `draggable="true"` attribute now that the drag is driven entirely by Pointer Events. The attribute was only there so the browser would render the cell as a drag source — which is exactly what we don't want anymore (the native ghost would draw on top of our pointer-driven gesture). The `draggable` prop on `DateRangePickerDay` stays accepted and still drives the `isDayDraggable` ownerState (and therefore the `cursor: grab` + `touch-action: none` + `user-select: none` styling). Only the DOM attribute pass-through is removed. Now that no native drag is initiated, the no-op `onDragStart` handler in `useDragRange` that existed solely to suppress the ghost is gone too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…handlePointerDown` The `event.isPrimary === false` check broke browser-mode tests: real `PointerEvent` constructed via `fireEvent.pointerDown(...)` defaults to `isPrimary: false` (the constructor's default), so the test event was short-circuited as if it were a secondary multi-touch pointer. jsdom doesn't have a real `PointerEvent` constructor so the property stayed `undefined` and the unit test passed — that's why CI caught it but local Vitest did not. The check was redundant: events are dispatched serially on a single JS thread, so by the time a second pointerdown reaches us, the first has already set `pointerIdRef`. The `pointerIdRef.current != null` check alone covers multi-touch, pen+touch, and the "stuck state" recovery scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests The `event.isPrimary === false` short-circuit in `handlePointerDown` is the right semantic guard against secondary multi-touch pointers — it matches what real browsers produce. The earlier removal was a workaround for `fireEvent.pointerDown` defaulting `isPrimary` to `false`, which the tests were silently relying on. The cleaner fix is to make tests mimic native behavior. Pass `isPrimary: true` from the `executeDateDrag` helper and from the inline child-element test, matching what a real first-finger touch / mouse press dispatches. Production keeps both guards: `isPrimary` filters secondary multi-touch up front, and `pointerIdRef.current != null` covers pen+touch (each pointer type has its own primary) and the "stuck state" recovery scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trim the explanatory blocks added during the Pointer Events refactor. Same content, fewer words: the JSDoc on `getClosestElementWithDataAttribute` keeps the camelCase / kebab-case caveat (a real footgun); each handler keeps a one-or-two-line "why" without restating what the code obviously does. Net −22 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This improves the UX (smoothness and avoiding jerkiness) on mobile (both iOS and Android).
Summary
Drag-to-edit on the range start/end day was implemented with native HTML5 drag-and-drop, with a parallel touch-event path layered on top to make it usable on mobile. On iOS Safari, the drag UX was poor: native
dragstartrequires a ~500ms long-press to distinguish from scroll, and that delay is fixed by the platform — no CSS orsetDatatweak shortens it.This PR replaces the entire drag mechanism with Pointer Events, so mouse, touch, and pen all flow through one code path. Tap-and-drag on iOS is now immediate (no long-press), matching the feel of React Aria's useMove / useCalendarCell.
How it works
pointerdownon a range-endpoint day starts the drag immediately and releases the implicit touch pointer-capture, so subsequentpointeroverevents fire on the cells the finger crosses (same trickusePressuses).pointeroveron a cell during the drag updates the preview range.pointeruplistener commits the drop.touchmovelistener withpreventDefaultstops the page from scrolling while the drag is in flight.clicksuppressor prevents the synthesized post-pointerup click from re-entering the day's normal selection logic and undoing the drop.onDragStartcancels the browser's native drag so it doesn't draw a ghost on top of our pointer-driven gesture.What changed
useDragRange.ts— full rewrite around Pointer Events.DateRangePickerDay.tsx— drop the iOS-specific WebKit drag CSS hacks (no longer needed since we don't use HTML5 drag); keepcursor: grab,touch-action: none, anduser-select: none.DateRangeCalendar.test.tsx— drag tests fire pointer events instead of drag events;MockedDataTransferis no longer needed.test/utils/pickers/calendar.ts—executeDateDragandexecuteDateDragWithoutDropnow drive the cells withpointerDown/pointerOver/pointerUpand nodataTransfer. Public test-helper API unchanged.The internal hook signature changed (returns
onPointerDown+onPointerOver+ a no-oponDragStart, no moreonDragStart/onDragEnter/onDropetc.). The hook is internal so this is a non-breaking change for consumers.Test plan
🤖 Generated with Claude Code