Skip to content

[pickers] Commit TimeClock value when the drag ends outside the clock#22877

Merged
LukasTy merged 6 commits into
mui:masterfrom
LukasTy:claude/tender-lewin-4a58ed
Jun 23, 2026
Merged

[pickers] Commit TimeClock value when the drag ends outside the clock#22877
LukasTy merged 6 commits into
mui:masterfrom
LukasTy:claude/tender-lewin-4a58ed

Conversation

@LukasTy

@LukasTy LukasTy commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #22863.

Dragging the analog clock hand and releasing the pointer outside the clock left the value uncommitted: the displayed hand had moved, but onChange never fired the final commit and the value reverted. The mousemove/mouseup (and touch) handlers were bound to the 220x220 clock mask only, so once the pointer left the mask the drag stopped tracking and a release outside never triggered the 'finish' selection state.

Changes

  • Migrate Clock to Pointer Events as a single source of truth for mouse, touch and pen, replacing the separate mouse + touch handlers. This matches the convention established by DateRangeCalendar's useDragRange.
    • onPointerDown on the mask; pointermove/pointerup/pointercancel are attached to the owner document, so a drag keeps tracking and commits the angle wherever it is released (inside or outside the clock).
    • Guards: primary button + isPrimary only, pointerId gating, pointercancel drops the gesture, and listeners are cleaned up on unmount.
  • Selecting a disabled / out-of-range time still bails via isTimeDisabled (unchanged).

Tests

  • Add a shared fireClockPointerEvent helper and convert the clock interaction tests (and the shared timeClockHandler) from touch events to pointer events. They now run in both jsdom and the browser (the old touch tests were browser-only).
  • Add a regression test that drags and releases the pointer on document.body (outside the clock) and asserts the value commits and the view advances.

Dragging the analog clock hand and releasing the pointer outside the clock
left the value uncommitted (mui/material-ui#30083): the move/up handlers were
bound to the clock mask only, so a release outside the mask never fired the
'finish' commit and the displayed time reverted.

Migrate the Clock to Pointer Events as a single source of truth for mouse,
touch and pen (matching DateRangeCalendar's useDragRange): pointerdown on the
mask, with pointermove/pointerup/pointercancel on the owner document, so a drag
keeps tracking and commits the angle wherever it is released.

Convert the clock test infra from touch events to pointer events via a shared
fireClockPointerEvent helper; the interaction tests now run in both jsdom and
the browser.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 18, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-22877--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 🔺+426B(+0.21%) 🔺+91B(+0.16%)
@mui/x-date-pickers-pro 🔺+425B(+0.16%) 🔺+86B(+0.11%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


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

@LukasTy LukasTy self-assigned this Jun 18, 2026
@LukasTy LukasTy added 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. component: TimePicker The React component. labels Jun 18, 2026
@LukasTy LukasTy added type: bug It doesn't behave as expected. and removed 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 Jun 18, 2026
@LukasTy
LukasTy marked this pull request as ready for review June 18, 2026 07:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 fixes an issue where dragging the TimeClock hand and releasing the pointer outside the clock mask would fail to commit the final value (no final onChange(..., 'finish')), causing the UI to appear updated while the value reverted.

Changes:

  • Migrates TimeClock/Clock interactions from separate mouse + touch handlers to unified Pointer Events, with pointermove/pointerup/pointercancel listeners attached to the owner document to keep tracking outside the mask.
  • Adds a shared fireClockPointerEvent test helper and converts existing clock interaction tests from touch events to pointer events.
  • Adds a regression test that releases the pointer on document.body (outside the clock) and asserts the value commits and the view advances.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/utils/pickers/viewHandlers.ts Switches the shared time clock view handler from touch events to pointer events via the new helper.
test/utils/pickers/clock.ts Adds fireClockPointerEvent helper that converts clock-relative coordinates into viewport clientX/clientY.
packages/x-date-pickers/src/TimeClock/tests/timezone.TimeClock.test.tsx Converts timezone-related TimeClock interaction tests from touch to pointer events.
packages/x-date-pickers/src/TimeClock/tests/TimeClock.test.tsx Converts interaction tests to pointer events and adds regression coverage for releasing outside the clock.
packages/x-date-pickers/src/TimeClock/Clock.tsx Replaces touch/mouse logic with document-level pointer event tracking to ensure commits occur even when released outside the mask.
packages/x-date-pickers/src/StaticTimePicker/StaticTimePicker.test.tsx Converts StaticTimePicker clock interaction tests from touch to pointer events (no longer touch-support gated).
packages/x-date-pickers/src/MobileTimePicker/tests/MobileTimePicker.test.tsx Updates pointer-based interactions to use viewport coordinates derived from the clock’s live bounding rect.
packages/x-date-pickers/src/MobileTimePicker/tests/describeValue.MobileTimePicker.test.tsx Converts describe-value interaction steps from touch events to pointer events.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/x-date-pickers/src/TimeClock/Clock.tsx
A document-level pointermove/pointerup can fire between React detaching the
mask ref and the cleanup effect removing the listeners (unmount mid-drag),
which would dereference a null ref. Bail out of setTime when the mask is gone.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread packages/x-date-pickers/src/TimeClock/Clock.tsx Outdated
LukasTy and others added 2 commits June 19, 2026 11:15
Use stopTracking() instead of only detaching the document listeners when a new
primary pointerdown supersedes a previous gesture, so isMoving/activePointerId
are reset in one place. Matches useDragRange's "reset before starting" pattern.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@michelengelen michelengelen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review: PR #22877 — [pickers] Commit TimeClock value when the drag ends outside the clock

Summary

Fixes #22863: dragging the analog clock hand and releasing the pointer outside the 220×220 clock mask left the value uncommitted because mouse/touch handlers were bound only to the mask. The PR migrates Clock from separate mouse/touch handlers to Pointer Events, attaching pointermove/pointerup/pointercancel to the owner document so a drag keeps tracking and commits wherever it's released. It also converts the clock test suite from touch events to pointer events so the tests run in both jsdom and the browser.

Correctness — looks solid

  • handlePointerDown bails on disabled/readOnly before any state is touched, matching the existing handleValueChange gate.
  • Plain clicks (no movement) still work: pointerdown fires setTime(..., 'shallow'), and the document pointerup listener (matched on pointerId) calls setTime(..., 'finish') — equivalent to the old unconditional setTime call in handleMouseUp.
  • pointercancel correctly drops the gesture without committing ("UA interrupted, not the user"), which is the right semantic and is not covered by a test (see below).
  • stopTracking() on a fresh pointerdown recovers from a previous gesture whose pointerup was lost — same defensive pattern as useDragRange in x-date-pickers-pro, which this PR explicitly mirrors.
  • The isMoving.current ref is still correctly toggled so handleKeyDown's early-return guard keeps working.
  • Cleanup on unmount (React.useEffect(() => () => removeDragListenersRef.current?.(), [])) avoids leaking document listeners, and setTime's if (!mask) return guards the React-detaches-ref-before-cleanup-runs race during unmount.
  • touchAction: 'none' is already set on ClockSquareMask, so the lack of event.preventDefault() in handlePointerDown (only present in the document move handler) isn't a scroll-hijack regression.

Minor issues / suggestions

  1. event.button !== 0 vs event.button > 0 (Clock.tsx handlePointerDown): the sibling implementation in useDragRange.ts (x-date-pickers-pro) deliberately uses > 0 with the comment "keeps the gesture permissive when event.button is left unset by a synthetic event (some test environments)". Clock.tsx uses the stricter !== 0, which would silently reject a pointerdown if button were undefined. Real browsers set button: 0 for touch/primary-mouse, so this likely isn't a live bug, but it's an inconsistency between two near-identical patterns in the same codebase — worth aligning, or at least leaving a comment on why this one differs.
  2. Missing test for pointercancel: the PR adds handleDocumentPointerCancel (drag interrupted → no commit), but no test exercises it. Given the regression test already added for "release outside the clock," a parallel one for "pointercancel mid-drag → value does not commit" would lock in that behavior and catch regressions.
  3. Missing test for the "lost pointerup" recovery path: stopTracking() is called at the top of handlePointerDown specifically to handle a fresh pointerdown while a previous gesture is still considered active. No test exercises two consecutive pointerdowns without an intervening pointerup.
  4. removeDragListenersRef.current?.() double-duty: stopTracking is called both from handlePointerDown (to clear a stale gesture) and from the document pointerup/pointercancel handlers. This is fine functionally, but since removeDragListenersRef.current is reset to undefined inside its own cleanup closure, double-calling stopTracking() (e.g., cancel firing right after up due to a race) is already safely a no-op — good, just worth noting as intentional rather than incidental.

Test coverage

  • Touch→pointer test conversion looks mechanical and correct; the new fireClockPointerEvent helper in test/utils/pickers/clock.ts is a reasonable, minimal addition.
  • The new regression test (TimeClock.test.tsx, "should keep tracking the drag and commit the value when released outside the clock") directly targets the reported bug and is well-targeted — drags from 13:-- to 19:-- and releases on document.body.
  • Removing it.skipIf(!hasTouchSupport) guards is correct now that these are pointer-based and run in jsdom, and the hasTouchSupport import cleanup in StaticTimePicker.test.tsx/TimeClock.test.tsx is appropriately tidied up.
  • As noted above, pointercancel and the "stale gesture recovery" path are the two behavior branches added by this PR that aren't directly tested.

Style / conventions

  • Comments are well-targeted at non-obvious invariants (the unmount race in setTime, the rationale for resolving coordinates via document-level events rather than offsetX/offsetY), consistent with the project's "why, not what" comment guidance.
  • No public API changes, so no proptypes/docs regen needed.
  • useEventCallback/ownerDocument usage matches the established x-date-pickers-pro drag pattern referenced in the PR description.

Risk

Low — this is an internal interaction-handling change with no public API surface change. The main risk is platform-specific Pointer Events quirks (Safari/older Android), but using pointerId gating and isPrimary checks is the right defensive approach, again mirroring the already-shipped useDragRange implementation.

… tests

- Use `event.button > 0` (not `!== 0`) in handlePointerDown so a synthetic
  pointerdown with an unset button isn't rejected, matching useDragRange.
- Note that stopTracking is idempotent (safe pointercancel/pointerup race).
- Add tests for the two behavior branches that were uncovered: pointercancel
  mid-drag drops the gesture without committing, and a fresh pointerdown
  recovers and commits when a previous gesture's pointerup was lost.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@LukasTy
LukasTy merged commit 08bdc47 into mui:master Jun 23, 2026
21 checks passed
@LukasTy
LukasTy deleted the claude/tender-lewin-4a58ed branch June 23, 2026 11:17
mbrookes pushed a commit to mbrookes/mui-x that referenced this pull request Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: TimePicker The React component. scope: pickers Changes related to the date/time pickers. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Time Picker] onChange is not always called

3 participants