Skip to content

[DateRangeCalendar] Use Pointer Events for drag editing#22279

Open
LukasTy wants to merge 12 commits intomui:masterfrom
LukasTy:claude/kind-hofstadter-ba1396
Open

[DateRangeCalendar] Use Pointer Events for drag editing#22279
LukasTy wants to merge 12 commits intomui:masterfrom
LukasTy:claude/kind-hofstadter-ba1396

Conversation

@LukasTy
Copy link
Copy Markdown
Member

@LukasTy LukasTy commented Apr 30, 2026

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 dragstart requires a ~500ms long-press to distinguish from scroll, and that delay is fixed by the platform — no CSS or setData tweak 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

  • pointerdown on a range-endpoint day starts the drag immediately and releases the implicit touch pointer-capture, so subsequent pointerover events fire on the cells the finger crosses (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 stops the page from scrolling while the drag is in flight.
  • A capture-phase one-shot click suppressor prevents the synthesized post-pointerup click from re-entering the day's normal selection logic and undoing the drop.
  • A no-op onDragStart cancels 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); keep cursor: grab, touch-action: none, and user-select: none.
  • DateRangeCalendar.test.tsx — drag tests fire pointer events instead of drag events; MockedDataTransfer is no longer needed.
  • test/utils/pickers/calendar.tsexecuteDateDrag and executeDateDragWithoutDrop now drive the cells with pointerDown/pointerOver/pointerUp and no dataTransfer. Public test-helper API unchanged.

The internal hook signature changed (returns onPointerDown + onPointerOver + a no-op onDragStart, no more onDragStart/onDragEnter/onDrop etc.). The hook is internal so this is a non-breaking change for consumers.

Test plan

  • `pnpm typescript` — clean
  • `pnpm eslint` — clean
  • `pnpm test:unit --project x-date-pickers-pro` — same 5 pre-existing local-timezone flakes as master, no new failures, all surviving drag tests pass
  • iOS Safari (real device): tap-and-drag a range endpoint, verify range updates immediately, range flip works, focus moves to the dropped cell
  • Android Chrome (real device): same scenarios

🤖 Generated with Claude Code

…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>
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented Apr 30, 2026

Deploy preview

https://deploy-preview-22279--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro ▼-317B(-0.10%) ▼-157B(-0.20%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@LukasTy LukasTy added plan: Pro Impact at least one Pro user. scope: pickers Changes related to the date/time pickers. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Apr 30, 2026
@LukasTy LukasTy self-assigned this Apr 30, 2026
LukasTy and others added 5 commits April 30, 2026 18:03
…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>
@LukasTy LukasTy changed the title [DateRangeCalendar] Replace touch handlers with mobile-friendly drag events [DateRangeCalendar] Use Pointer Events for drag editing May 1, 2026
`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>
@LukasTy LukasTy marked this pull request as ready for review May 4, 2026 12:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useDragRange to drive range endpoint dragging via pointerdown/pointerover with document-level pointerup commit 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.

Comment thread packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
LukasTy and others added 5 commits May 4, 2026 17:22
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

plan: Pro Impact at least one Pro user. scope: pickers Changes related to the date/time pickers. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants