Skip to content

[slider] Use pointer events instead of mouse events#48164

Merged
mj12albert merged 4 commits intomui:masterfrom
mj12albert:slider-fixes
Apr 6, 2026
Merged

[slider] Use pointer events instead of mouse events#48164
mj12albert merged 4 commits intomui:masterfrom
mj12albert:slider-fixes

Conversation

@mj12albert
Copy link
Copy Markdown
Member

@mj12albert mj12albert commented Apr 1, 2026

Minor breaking change

Previously onMouseDown={(event) => event.preventDefault()} will cancel the drag from starting, now can only be achieved with onPointerDown.

This PR changes slider to use pointer events instead of mouse events, which is fully compatible with our browser support today, and fixes a number of issues with iOS

Other fixes:

  • Internal helpers use positional args fn(a, b) instead objects fn({ a, b }) to avoid destructuring
  • Remove fallback for lack of touch-action support, no longer needed today
  • Consistently use the getActiveElement util to check doc.activeElement
  • Improved the types

Fixes #35887
Fixes #32737
Fixes #31869
Fixes #26754

@mj12albert mj12albert added the scope: slider Changes related to the slider. label Apr 1, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Apr 1, 2026

Netlify deploy preview

Bundle size report

Bundle Parsed size Gzip size
@mui/material 🔺+643B(+0.13%) 🔺+187B(+0.13%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against 5a2fa87

@mj12albert mj12albert added the type: bug It doesn't behave as expected. label Apr 1, 2026
@mj12albert mj12albert force-pushed the slider-fixes branch 4 times, most recently from 9e34510 to 4b2a79f Compare April 2, 2026 05:26
@mj12albert mj12albert added the breaking change Introduces changes that are not backward compatible. label Apr 2, 2026
@mj12albert mj12albert changed the title [slider] Fixes [slider] Use pointer events instead of mouse events Apr 2, 2026

const getFingerNewValue = ({
finger,
move = false,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This move param was previously used as part of the logic to determine the active thumb for range sliders

But both pointer(mouse)Down and Move events call this, pointerDown passes move: false, and pointerMove passes move: true which is a bit indirect and hard to understand

Now we use a ref to track the pressed (or closest) thumb on pointerDown

@mj12albert mj12albert marked this pull request as ready for review April 2, 2026 17:11
Comment thread packages/mui-material/src/Slider/Slider.test.js Outdated
Comment on lines 117 to 120
export type UseSliderRootSlotOwnProps = {
onMouseDown: React.MouseEventHandler;
onPointerDown: React.PointerEventHandler;
ref: React.RefCallback<Element> | null;
};
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.

Please check if the type is public, if yes, it should be documented in the breaking change doc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's not, but I documented the minor breaking behavior in the doc (only applicable if using preventDefault to cancel slider drags)

zIndex = 1;
}
} else if (active === index) {
zIndex = 1;
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.

Suggestion: Worth double-checking the interaction between getThumbStyle z-index and findClosest for overlapping range thumbs.

When a range slider's thumbs sit at the same value, getThumbStyle elevates lastUsedThumbIndexRef with zIndex: 1 (visually on top). But findClosest with <= always picks the highest index on a tie. These two can disagree:

  1. User drags thumb 0 onto thumb 1's value, releases → thumb 0 gets zIndex: 1 (on top)
  2. User clicks the overlap → findClosest picks thumb 1 (higher index wins with <=)
  3. Wrong thumb is activated vs. what's visually on top

Might be worth checking if this is noticeable in practice, and if so whether findClosest should use lastUsedThumbIndexRef as a tiebreaker.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Makes sense ~ fixed and added a test in 1704f14

export const Identity = (x: number) => x;

const EMPTY_MARKS: readonly Mark[] = [];
const EMPTY_OBJ: EventHandlers = {};
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.

Nit: EMPTY_OBJ appears unused at runtime — extractEventHandlers() always returns an object ({}), never null/undefined, so the || EMPTY_OBJ fallback in getRootProps, getThumbProps, getHiddenInputProps is unreachable.

Could simplify to just const externalHandlers = extractEventHandlers(externalProps); and drop EMPTY_OBJ.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed, the const still has a use here

@mj12albert mj12albert requested a review from siriwatknp April 6, 2026 13:16
Copy link
Copy Markdown
Member

@siriwatknp siriwatknp left a comment

Choose a reason for hiding this comment

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

👍 Great work

Copy link
Copy Markdown
Member

@LukasTy LukasTy left a comment

Choose a reason for hiding this comment

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

Leaving a few personal nitpicks.
Looks and works great overall. 👍

Claude Opus 4.6 review

Overview

Migrates the Slider component from mouse events to pointer events, fixing several longstanding iOS/touch issues (#35887, #32737, #31869, #26754). Also modernizes internal helpers (positional args instead of objects), removes the obsolete touch-action support check, memoizes derived values, and adds pointer capture for reliable drag tracking.


Positives

  • Solid modernization. Pointer events unify mouse+touch+pen handling and are well-supported in all target browsers. Removing the doesSupportTouchActionNone fallback is the right call.
  • Pointer ID tracking (activePointerIdRef) correctly prevents stray pointerup/pointermove from a second pointer from interfering with the active drag — a common multi-touch edge case.
  • Dual-fire prevention (pointerDownHandledRef) is a clean approach to handling the pointerdown+touchstart double-fire on touch devices without removing the touchstart path entirely.
  • Memoization of values, marks, and marksValues is a welcome performance improvement.
  • Good test coverage. New tests for unmount-during-drag, dual-fire prevention, pointer ID filtering, getThumbStyle z-index, and step="any" handling.
  • Identity typed properly as (x: number) => number instead of (x: any) => x.

Issues & Suggestions

1. findClosest — tie-breaking with <= favors last index (behavioral change)

The change from distance < acc.distance || distance === acc.distance to distance <= acc.distance is semantically identical, but the original also had a subtle special case: when distances are equal, it picked the higher index. The new preferredIndex parameter partially addresses this for range sliders, but for initial presses without a lastUsedThumbIndex, the tie-break still always picks the last thumb. This is the same behavior as before, so it's fine — just worth a comment noting the intent.

2. UseSliderRootSlotOwnProps type is too broad

export type UseSliderRootSlotOwnProps = React.DOMAttributes<Element> & {
  ref: React.RefCallback<Element> | null;
};

React.DOMAttributes<Element> includes every event handler (onClick, onDrag, onScroll, etc.), which makes the type essentially unreadable for consumers who want to know what the slider actually attaches. The previous version explicitly listed onMouseDown. Consider narrowing to just onPointerDown:

export type UseSliderRootSlotOwnProps = {
  onPointerDown: React.PointerEventHandler;
  ref: React.RefCallback<Element> | null;
};

3. Pointer capture try/catch is fine, but the empty catch could swallow real bugs

The try { event.currentTarget.setPointerCapture(...) } catch {} block silently swallows all errors. Consider at minimum a console.warn in dev mode, or at least a comment scoping the expected failure modes more precisely (the comment is good but a narrower catch would be better):

catch (err) {
  // Only expected for synthetic events in tests or already-released pointers
  if (process.env.NODE_ENV !== 'production') {
    console.warn('MUI: setPointerCapture failed', err);
  }
}

4. Event cloning changed subtly

// Before:
const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
// After:
const clonedEvent = new Event(nativeEvent.type, nativeEvent);

Using new Event(...) instead of new nativeEvent.constructor(...) means PointerEvent-specific properties (like pointerId, pointerType, pressure) won't be on the cloned event. If any consumer inspects the event type or pointer-specific fields in their onChange handler, this could be surprising. Consider using new PointerEvent(...) when the native event is a PointerEvent, or document that onChange always receives a plain Event.

5. beforeEach placement in useSlider.test.js

The beforeEach that stubs setPointerCapture/releasePointerCapture/hasPointerCapture is placed after the first describe block (line ~423 in the diff), making it run for all tests in the file but placed in an odd position. Consider moving it to the top of the describe('useSlider', ...) block for clarity.

6. Many tests now skipIf(isJsdom())

A significant number of existing tests now skip in jsdom. This reduces coverage in the fast jsdom test runner. The root cause is that jsdom doesn't support pointer capture. Since you're already stubbing the capture API in beforeEach, is there a reason these tests can't run in jsdom? If the issue is that fireEvent.pointerDown doesn't properly trigger in jsdom, that's worth documenting.

7. Minor: EMPTY_OBJ as default parameter

const EMPTY_OBJ = {};
// Used as:
externalProps: ExternalProps = EMPTY_OBJ as ExternalProps

This is a nice optimization to avoid per-render allocations, but the as ExternalProps cast could mask type errors if ExternalProps has required properties. Low risk since these are spread into handlers, but worth noting.


Breaking Change Assessment

The migration guide entry is clear and appropriate. The only breaking change is that onMouseDown + preventDefault() no longer prevents drag — consumers must switch to onPointerDown. This is well-documented and tested.


Verdict

Well-executed modernization with good test coverage and attention to edge cases (multi-pointer, dual-fire, unmount-during-drag). The main items to address are #2 (overly broad type) and #4 (event cloning losing PointerEvent properties). The rest are minor suggestions.


### Slider

The `Slider` component uses pointer events instead of mouse events. Previously `onMouseDown={(event) => event.preventDefault()}` will cancel a drag from starting, now `onPointerDown` must be used instead.
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.

Suggested change
The `Slider` component uses pointer events instead of mouse events. Previously `onMouseDown={(event) => event.preventDefault()}` will cancel a drag from starting, now `onPointerDown` must be used instead.
The `Slider` component uses pointer events instead of mouse events.
Previously `onMouseDown={(event) => event.preventDefault()}` would cancel a drag from starting, now `onPointerDown` must be used instead.

Comment thread packages/mui-material/src/Slider/useSlider.ts Outdated

if (event.defaultPrevented) {
return;
// On touch devices, the browser fires both pointerdown and touchstart for the
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.

Nitpick: Wouldn't using only pointer events avoid this issue?
Or are there reasons preventing pure usage of PointerEvent?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Wasn't 100% confident in doing this now
Technically only pointer events could work, but dropping the touch fallback could potentially break user code that sometimes expects touch events... maybe v10 😅

@mj12albert mj12albert enabled auto-merge (squash) April 6, 2026 20:55
@mj12albert mj12albert merged commit af86546 into mui:master Apr 6, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Introduces changes that are not backward compatible. scope: slider Changes related to the slider. type: bug It doesn't behave as expected.

Projects

None yet

4 participants