From 1216a2343ce88a87bf52660966b809e43b14dbd3 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 30 Mar 2026 21:37:30 +0800 Subject: [PATCH] Use pointer events --- .../migration/upgrade-to-v9/upgrade-to-v9.md | 4 + docs/translations/api-docs/slider/slider.json | 2 +- packages/mui-material/src/Slider/Slider.d.ts | 2 +- packages/mui-material/src/Slider/Slider.js | 2 +- .../mui-material/src/Slider/Slider.test.js | 229 +++++++-- .../mui-material/src/Slider/useSlider.test.js | 163 ++++++- packages/mui-material/src/Slider/useSlider.ts | 458 ++++++++++-------- .../src/Slider/useSlider.types.ts | 4 +- 8 files changed, 617 insertions(+), 247 deletions(-) diff --git a/docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md b/docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md index 3f0b621ae99209..866ba9e7ba7fc5 100644 --- a/docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md +++ b/docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md @@ -284,6 +284,10 @@ The `StepButton` has: - The `aria-setsize` added. The value is the total number of steps. - The `aria-posinset` added. The value is the index of the step inside the list, 1-based. +### 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. + ### Tabs The `tabindex` attribute for each tab will be changed on Arrow Key or Home / End navigation. Previously, keyboard navigation moved DOM focus without updating `tabindex` on the focused `Tab`. Now, we move DOM focus and also add the `tabindex="0"` to the focused `Tab`. Other tabs will have `tabindex="-1"` to keep only one focusable `Tab` at a time. diff --git a/docs/translations/api-docs/slider/slider.json b/docs/translations/api-docs/slider/slider.json index ea790883ef4efa..c8d7f02d076120 100644 --- a/docs/translations/api-docs/slider/slider.json +++ b/docs/translations/api-docs/slider/slider.json @@ -57,7 +57,7 @@ } }, "onChangeCommitted": { - "description": "Callback function that is fired when the mouseup is triggered.", + "description": "Callback function that is fired when the pointer or touch interaction ends.", "typeDescriptions": { "event": { "name": "event", diff --git a/packages/mui-material/src/Slider/Slider.d.ts b/packages/mui-material/src/Slider/Slider.d.ts index 74d23187c185ce..d94a64da0db26c 100644 --- a/packages/mui-material/src/Slider/Slider.d.ts +++ b/packages/mui-material/src/Slider/Slider.d.ts @@ -216,7 +216,7 @@ export interface SliderOwnProps { */ onChange?: ((event: Event, value: Value, activeThumb: number) => void) | undefined; /** - * Callback function that is fired when the `mouseup` is triggered. + * Callback function that is fired when the pointer or touch interaction ends. * * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. * @param {Value} value The new value. diff --git a/packages/mui-material/src/Slider/Slider.js b/packages/mui-material/src/Slider/Slider.js index 3e836ec1971934..689204c129e500 100644 --- a/packages/mui-material/src/Slider/Slider.js +++ b/packages/mui-material/src/Slider/Slider.js @@ -933,7 +933,7 @@ Slider.propTypes /* remove-proptypes */ = { */ onChange: PropTypes.func, /** - * Callback function that is fired when the `mouseup` is triggered. + * Callback function that is fired when the pointer or touch interaction ends. * * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. * @param {Value} value The new value. diff --git a/packages/mui-material/src/Slider/Slider.test.js b/packages/mui-material/src/Slider/Slider.test.js index 10581ece353799..c2f01f65599225 100644 --- a/packages/mui-material/src/Slider/Slider.test.js +++ b/packages/mui-material/src/Slider/Slider.test.js @@ -30,6 +30,19 @@ function createTouches(touches) { describe.skipIf(!supportsTouch())('', () => { const { render } = createRenderer(); + beforeEach(() => { + // jsdom doesn't implement Pointer Capture API + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = stub(); + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = stub(); + } + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = stub().returns(false); + } + }); + describeConformance( , () => ({ @@ -65,13 +78,14 @@ describe.skipIf(!supportsTouch())('', () => { }, }, skip: [ + 'componentsProp', 'slotPropsCallback', // not supported yet 'slotPropsCallbackWithPropsAsOwnerState', // not supported yet ], }), ); - it('should call handlers', () => { + it.skipIf(isJsdom())('should call handlers', () => { const handleChange = spy(); const handleChangeCommitted = spy(); @@ -84,13 +98,15 @@ describe.skipIf(!supportsTouch())('', () => { })); const slider = screen.getByRole('slider'); - fireEvent.mouseDown(container.firstChild, { + fireEvent.pointerDown(container.firstChild, { buttons: 1, clientX: 10, + pointerId: 1, }); - fireEvent.mouseUp(container.firstChild, { + fireEvent.pointerUp(container.firstChild, { buttons: 1, clientX: 10, + pointerId: 1, }); expect(handleChange.callCount).to.equal(1); @@ -140,7 +156,7 @@ describe.skipIf(!supportsTouch())('', () => { expect(handleChangeCommitted.callCount).to.equal(1); }); - it('should hedge against a dropped mouseup event', () => { + it.skipIf(isJsdom())('should hedge against a dropped pointerup event', () => { const handleChange = spy(); const { container } = render(); stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ @@ -148,29 +164,32 @@ describe.skipIf(!supportsTouch())('', () => { left: 0, })); - fireEvent.mouseDown(container.firstChild, { + fireEvent.pointerDown(container.firstChild, { buttons: 1, clientX: 1, + pointerId: 1, }); expect(handleChange.callCount).to.equal(1); expect(handleChange.args[0][1]).to.equal(1); - fireEvent.mouseMove(document.body, { + fireEvent.pointerMove(document.body, { buttons: 1, clientX: 10, + pointerId: 1, }); expect(handleChange.callCount).to.equal(2); expect(handleChange.args[1][1]).to.equal(10); - fireEvent.mouseMove(document.body, { + fireEvent.pointerMove(document.body, { buttons: 0, clientX: 11, + pointerId: 1, }); - // The mouse's button was released, stop the dragging session. + // The pointer's button was released, stop the dragging session. expect(handleChange.callCount).to.equal(2); }); - it('should only fire onChange when the value changes', () => { + it.skipIf(isJsdom())('should only fire onChange when the value changes', () => { const handleChange = spy(); const { container } = render(); stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ @@ -178,19 +197,22 @@ describe.skipIf(!supportsTouch())('', () => { left: 0, })); - fireEvent.mouseDown(container.firstChild, { + fireEvent.pointerDown(container.firstChild, { buttons: 1, clientX: 21, + pointerId: 1, }); - fireEvent.mouseMove(document.body, { + fireEvent.pointerMove(document.body, { buttons: 1, clientX: 22, + pointerId: 1, }); // Sometimes another event with the same position is fired by the browser. - fireEvent.mouseMove(document.body, { + fireEvent.pointerMove(document.body, { buttons: 1, clientX: 22, + pointerId: 1, }); expect(handleChange.callCount).to.equal(2); @@ -324,7 +346,7 @@ describe.skipIf(!supportsTouch())('', () => { expect(document.activeElement).to.have.attribute('data-index', '0'); }); - it('should focus the slider when dragging', async () => { + it.skipIf(isJsdom())('should focus the slider when dragging', async () => { const { container } = render( ', () => { left: 0, })); - fireEvent.mouseDown(thumb, { + fireEvent.pointerDown(thumb, { buttons: 1, clientX: 1, + pointerId: 1, }); await waitFor(() => { @@ -384,13 +407,13 @@ describe.skipIf(!supportsTouch())('', () => { expect(handleChange.args[1][1]).to.deep.equal([22, 30]); }); - it('should not react to right clicks', () => { + it.skipIf(isJsdom())('should not react to right clicks', () => { const handleChange = spy(); render(); const thumb = screen.getByRole('slider'); - fireEvent.mouseDown(thumb, { button: 2 }); + fireEvent.pointerDown(thumb, { button: 2, pointerId: 1 }); expect(handleChange.callCount).to.equal(0); }); }); @@ -1692,37 +1715,165 @@ describe.skipIf(!supportsTouch())('', () => { }); }); - describe('When the onMouseUp event occurs at a different location than the last onChange event', () => { - it('should pass onChangeCommitted the same value that was passed to the last onChange event', () => { - const handleChange = spy(); - const handleChangeCommitted = spy(); + describe('When the pointer up event occurs at a different location than the last onChange event', () => { + it.skipIf(isJsdom())( + 'should pass onChangeCommitted the same value that was passed to the last onChange event', + () => { + const handleChange = spy(); + const handleChangeCommitted = spy(); + + const { container } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.pointerDown(container.firstChild, { + buttons: 1, + clientX: 10, + pointerId: 1, + }); + fireEvent.pointerMove(container.firstChild, { + buttons: 1, + clientX: 15, + pointerId: 1, + }); + fireEvent.pointerUp(container.firstChild, { + buttons: 1, + clientX: 20, + pointerId: 1, + }); + + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[0][1]).to.equal(10); + expect(handleChange.args[1][1]).to.equal(15); + expect(handleChangeCommitted.callCount).to.equal(1); + expect(handleChangeCommitted.args[0][1]).to.equal(15); + }, + ); + }); + + it.skipIf(isJsdom())('should not crash when unmounted during a pointer drag (#26754)', () => { + const { container, unmount } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.pointerDown(container.firstChild, { clientX: 100, pointerId: 1 }); + unmount(); + fireEvent.pointerMove(document, { clientX: 150, pointerId: 1 }); + fireEvent.pointerUp(document, { pointerId: 1 }); + }); + + it('should not crash when unmounted during a touch drag (#26754)', () => { + const { container, unmount } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.touchStart( + container.firstChild, + createTouches([{ identifier: 0, clientX: 100, clientY: 5 }]), + ); + unmount(); + fireEvent.touchMove(document, createTouches([{ identifier: 0, clientX: 150, clientY: 5 }])); + fireEvent.touchEnd(document, createTouches([{ identifier: 0, clientX: 150, clientY: 5 }])); + }); + + it.skipIf(isJsdom())('should end drag when pointermove fires with buttons === 0', () => { + const onChangeCommitted = spy(); + const { container } = render( + , + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + left: 0, + })); + + fireEvent.pointerDown(container.firstChild, { clientX: 100, pointerId: 1 }); + fireEvent.pointerMove(document, { clientX: 150, pointerId: 1, buttons: 0 }); + expect(onChangeCommitted.callCount).to.equal(1); + }); + it.skipIf(isJsdom())( + 'should allow consumers to prevent drag via onPointerDown + preventDefault()', + () => { + const handleChange = spy(); const { container } = render( - , + pointerDownEvent.preventDefault(), + }, + }} + />, ); stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ width: 100, left: 0, })); - fireEvent.mouseDown(container.firstChild, { - buttons: 1, - clientX: 10, - }); - fireEvent.mouseMove(container.firstChild, { - buttons: 1, - clientX: 15, - }); - fireEvent.mouseUp(container.firstChild, { - buttons: 1, - clientX: 20, - }); + fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 }); + expect(handleChange.callCount).to.equal(0); + }, + ); + + it.skipIf(isJsdom())( + 'should not fire onChange twice on touch devices (pointer+touch dual fire)', + () => { + const handleChange = spy(); + const { container } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + // Touch devices fire both pointer and touch events for the same physical touch + fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 }); + fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 0, clientX: 20 }])); + // Move — only the pointer path listener should be on document + fireEvent.pointerMove(document, { clientX: 40, pointerId: 1, buttons: 1 }); + + // onChange: once from pointerDown (value change) + once from pointerMove = 2, not 3 expect(handleChange.callCount).to.equal(2); - expect(handleChange.args[0][1]).to.equal(10); - expect(handleChange.args[1][1]).to.equal(15); - expect(handleChangeCommitted.callCount).to.equal(1); - expect(handleChangeCommitted.args[0][1]).to.equal(15); - }); - }); + }, + ); + + it.skipIf(isJsdom())( + 'should ignore pointerup from a different pointer than the one that started the drag', + () => { + const handleChange = spy(); + const { container } = render(); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + // Start drag with pointer 1 + fireEvent.pointerDown(container.firstChild, { clientX: 50, pointerId: 1 }); + const changesAfterDown = handleChange.callCount; + + // A second pointer fires pointerup — should be ignored + fireEvent.pointerUp(document, { clientX: 60, pointerId: 2 }); + + // The drag should still be active — a move from the original pointer + // must still produce onChange. Without pointerId filtering, the stray + // pointerup tears down listeners and this move is silently dropped. + fireEvent.pointerMove(document, { clientX: 70, pointerId: 1, buttons: 1 }); + expect(handleChange.callCount).to.be.greaterThan(changesAfterDown); + }, + ); }); diff --git a/packages/mui-material/src/Slider/useSlider.test.js b/packages/mui-material/src/Slider/useSlider.test.js index e4ee0313bda6a5..fd5af687c84f54 100644 --- a/packages/mui-material/src/Slider/useSlider.test.js +++ b/packages/mui-material/src/Slider/useSlider.test.js @@ -1,7 +1,7 @@ import * as React from 'react'; import { expect } from 'chai'; -import { spy } from 'sinon'; -import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; +import { spy, stub } from 'sinon'; +import { createRenderer, screen, fireEvent, isJsdom } from '@mui/internal-test-utils'; import { useSlider } from './useSlider'; describe('useSlider', () => { @@ -40,6 +40,135 @@ describe('useSlider', () => { }); }); + beforeEach(() => { + // jsdom doesn't implement Pointer Capture API + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = stub(); + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = stub(); + } + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = stub().returns(false); + } + }); + + describe('getThumbStyle', () => { + function RangeSliderTest({ onSliderState }) { + const result = useSlider({ + defaultValue: [20, 80], + }); + onSliderState(result); + return ( +
+ {result.values.map((value, index) => ( +
+ +
+ ))} +
+ ); + } + + function SingleSliderTest({ onSliderState }) { + const result = useSlider({ + defaultValue: 50, + }); + onSliderState(result); + return ( +
+
+ +
+
+ ); + } + + it.skipIf(isJsdom())('range slider: active thumb should have zIndex 2', () => { + let sliderState; + const { container } = render( + { + sliderState = state; + }} + />, + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + // Activate thumb 0 by pointer down near value 20 + fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 }); + expect(sliderState.getThumbStyle(0).zIndex).to.equal(2); + }); + + it.skipIf(isJsdom())('range slider: inactive thumb should have no zIndex during drag', () => { + let sliderState; + const { container } = render( + { + sliderState = state; + }} + />, + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + // Activate thumb 0 + fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 }); + expect(sliderState.getThumbStyle(1).zIndex).to.equal(undefined); + }); + + it.skipIf(isJsdom())('range slider: last-used thumb should have zIndex 1 after release', () => { + let sliderState; + const { container } = render( + { + sliderState = state; + }} + />, + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + // Activate then release thumb 0 + fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 }); + fireEvent.pointerUp(document, { pointerId: 1 }); + expect(sliderState.getThumbStyle(0).zIndex).to.equal(1); + }); + + it.skipIf(isJsdom())('single slider: active thumb should have zIndex 1', () => { + let sliderState; + const { container } = render( + { + sliderState = state; + }} + />, + ); + stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({ + width: 100, + height: 10, + bottom: 10, + left: 0, + })); + + fireEvent.pointerDown(container.firstChild, { clientX: 50, pointerId: 1 }); + expect(sliderState.getThumbStyle(0).zIndex).to.equal(1); + }); + }); + describe('getHiddenInputProps', () => { function Test( props = { @@ -88,4 +217,34 @@ describe('useSlider', () => { expect(handleClick.callCount).to.equal(1); }); }); + + describe('hidden input step attribute', () => { + function StepTest(props) { + const { getRootProps, getThumbProps, getHiddenInputProps } = useSlider(props); + return ( +
+
+ +
+
+ ); + } + + it('should set step="any" when step is null and marks are provided', () => { + render( + , + ); + expect(screen.getByTestId('step-input')).to.have.attribute('step', 'any'); + }); + + it('should not set step="any" when step is omitted', () => { + render(); + // step is omitted (undefined), not null — should NOT become "any" + expect(screen.getByTestId('step-input')).not.to.have.attribute('step', 'any'); + }); + }); }); diff --git a/packages/mui-material/src/Slider/useSlider.ts b/packages/mui-material/src/Slider/useSlider.ts index 1365a81cf29d9b..9832909f96c97a 100644 --- a/packages/mui-material/src/Slider/useSlider.ts +++ b/packages/mui-material/src/Slider/useSlider.ts @@ -19,6 +19,7 @@ import { } from './useSlider.types'; import { EventHandlers } from '../utils/types'; import areArraysEqual from '../utils/areArraysEqual'; +import getActiveElement from '../utils/getActiveElement'; const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; @@ -42,7 +43,7 @@ function findClosest(values: number[], currentValue: number) { (acc, value: number, index: number) => { const distance = Math.abs(currentValue - value); - if (acc === null || distance < acc.distance || distance === acc.distance) { + if (acc == null || distance <= acc.distance) { return { distance, index, @@ -57,15 +58,15 @@ function findClosest(values: number[], currentValue: number) { } function trackFinger( - event: TouchEvent | MouseEvent | React.MouseEvent, - touchId: React.RefObject, + event: TouchEvent | PointerEvent | React.PointerEvent, + touchIdRef: React.RefObject, ) { // The event is TouchEvent - if (touchId.current !== undefined && (event as TouchEvent).changedTouches) { + if (touchIdRef.current != null && (event as TouchEvent).changedTouches) { const touchEvent = event as TouchEvent; for (let i = 0; i < touchEvent.changedTouches.length; i += 1) { const touch = touchEvent.changedTouches[i]; - if (touch.identifier === touchId.current) { + if (touch.identifier === touchIdRef.current) { return { x: touch.clientX, y: touch.clientY, @@ -76,7 +77,7 @@ function trackFinger( return false; } - // The event is MouseEvent + // The event is PointerEvent or MouseEvent return { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY, @@ -109,50 +110,38 @@ function roundValueToStep(value: number, step: number, min: number) { return Number(nearest.toFixed(getDecimalPrecision(step))); } -function setValueIndex({ - values, - newValue, - index, -}: { - values: number[]; - newValue: number; - index: number; -}) { +function setValueIndex(values: number[], newValue: number, index: number) { const output = values.slice(); output[index] = newValue; return output.sort(asc); } -function focusThumb({ - sliderRef, - activeIndex, - setActive, - focusVisible, -}: { - sliderRef: React.RefObject; - activeIndex: number; - setActive?: ((num: number) => void) | undefined; - focusVisible?: boolean | undefined; -}) { +function focusThumb( + sliderRef: React.RefObject, + activeIndex: number, + setActive?: ((num: number) => void) | undefined, + focusVisible?: boolean | undefined, +) { const doc = ownerDocument(sliderRef.current); + const activeElement = getActiveElement(doc); if ( - !sliderRef.current?.contains(doc.activeElement) || - Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex + !sliderRef.current?.contains(activeElement) || + Number(activeElement?.getAttribute('data-index')) !== activeIndex ) { const input = sliderRef.current?.querySelector( `[type="range"][data-index="${activeIndex}"]`, ) as HTMLInputElement | null; if (input != null) { - if (focusVisible === undefined) { + if (focusVisible == null) { input.focus({ preventScroll: true }); } else { - input.focus({ + const focusOptions = { preventScroll: true, // Prevent pointer-driven focus rings in browsers that support this option. // Chrome 144+ supports `focusVisible` in `HTMLElement.focus()` options. - // @ts-ignore - `focusVisible` is not yet in TypeScript's lib.dom FocusOptions. focusVisible, - }); + }; + input.focus(focusOptions); } } } @@ -190,29 +179,10 @@ const axisProps = { }, }; -export const Identity = (x: any) => x; - -// TODO: remove support for Safari < 13. -// https://caniuse.com/#search=touch-action -// -// Safari, on iOS, supports touch action since v13. -// Over 80% of the iOS phones are compatible -// in August 2020. -// Utilizing the CSS.supports method to check if touch-action is supported. -// Since CSS.supports is supported on all but Edge@12 and IE and touch-action -// is supported on both Edge@12 and IE if CSS.supports is not available that means that -// touch-action will be supported -let cachedSupportsTouchActionNone: any; -function doesSupportTouchActionNone() { - if (cachedSupportsTouchActionNone === undefined) { - if (typeof CSS !== 'undefined' && typeof CSS.supports === 'function') { - cachedSupportsTouchActionNone = CSS.supports('touch-action', 'none'); - } else { - cachedSupportsTouchActionNone = true; - } - } - return cachedSupportsTouchActionNone; -} +export const Identity = (x: number) => x; + +const EMPTY_MARKS: readonly Mark[] = []; +const EMPTY_OBJ: EventHandlers = {}; export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue { const { @@ -236,23 +206,31 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue value: valueProp, } = parameters; - const touchId = React.useRef(undefined); - const focusFrame = React.useRef(null); + const touchIdRef = React.useRef(undefined); + const focusFrameRef = React.useRef(null); // We can't use the :active browser pseudo-classes. // - The active state isn't triggered when clicking on the rail. // - The active state isn't transferred when inversing a range slider. const [active, setActive] = React.useState(-1); const [open, setOpen] = React.useState(-1); const [dragging, setDragging] = React.useState(false); - const moveCount = React.useRef(0); + const moveCountRef = React.useRef(0); + // Ref (not state) because setActive() always accompanies updates, providing the re-render. + const lastUsedThumbIndexRef = React.useRef(-1); + // Prevents duplicate listener registration when both pointer and touch events fire + // for the same physical touch interaction. + const pointerDownHandledRef = React.useRef(false); + // Tracks which pointer owns the current drag session, so stray pointerup/pointermove + // events from a second pointer don't interfere. + const activePointerIdRef = React.useRef(-1); const cancelFocusFrame = useEventCallback(() => { - if (focusFrame.current != null) { - cancelAnimationFrame(focusFrame.current); - focusFrame.current = null; + if (focusFrameRef.current != null) { + cancelAnimationFrame(focusFrameRef.current); + focusFrameRef.current = null; } }); - // lastChangedValue is updated whenever onChange is triggered. - const lastChangedValue = React.useRef(null); + // lastChangedValueRef is updated whenever onChange is triggered. + const lastChangedValueRef = React.useRef(null); const [valueDerived, setValueState] = useControlled({ controlled: valueProp, @@ -260,38 +238,63 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue name: 'Slider', }); - const handleChange = - onChange && - ((event: Event | React.SyntheticEvent, value: number | number[], thumbIndex: number) => { + const handleChange = useEventCallback( + (event: Event | React.SyntheticEvent, value: number | number[], thumbIndex: number) => { // Redefine target to allow name and value to be read. // This allows seamless integration with the most popular form libraries. // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492 // Clone the event to not override `target` of the original event. - const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event; - // @ts-ignore The nativeEvent is function, not object - const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent); + const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event; + const clonedEvent = new Event(nativeEvent.type, nativeEvent); Object.defineProperty(clonedEvent, 'target', { writable: true, value: { value, name }, }); - lastChangedValue.current = value; - onChange(clonedEvent, value, thumbIndex); - }); + lastChangedValueRef.current = value; + onChange?.(clonedEvent, value, thumbIndex); + }, + ); const range = Array.isArray(valueDerived); - let values = range ? valueDerived.slice().sort(asc) : [valueDerived]; - values = values.map((value) => (value == null ? min : clamp(value, min, max))); + const values = React.useMemo(() => { + if (typeof valueDerived === 'number') { + return [clamp(valueDerived, min, max)]; + } + + if (valueDerived == null) { + return [min]; + } - const marks = - marksProp === true && step !== null - ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ - value: min + step * index, - })) - : marksProp || []; + const sortedValues = valueDerived.slice().sort(asc); + for (let i = 0; i < sortedValues.length; i += 1) { + const value = sortedValues[i]; + sortedValues[i] = value == null ? min : clamp(value, min, max); + } - const marksValues = (marks as readonly Mark[]).map((mark: Mark) => mark.value); + return sortedValues; + }, [valueDerived, min, max]); + + const marks = React.useMemo(() => { + if (marksProp === true && step != null) { + const generatedMarks = new Array(Math.floor((max - min) / step) + 1); + for (let i = 0; i < generatedMarks.length; i += 1) { + generatedMarks[i] = { value: min + step * i }; + } + return generatedMarks; + } + + return Array.isArray(marksProp) ? marksProp : EMPTY_MARKS; + }, [marksProp, step, min, max]); + + const marksValues = React.useMemo(() => { + const markValues = new Array(marks.length); + for (let i = 0; i < marks.length; i += 1) { + markValues[i] = marks[i].value; + } + return markValues; + }, [marks]); const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1); @@ -342,11 +345,7 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue } const previousValue = newValue; - newValue = setValueIndex({ - values, - newValue, - index, - }); + newValue = setValueIndex(values, newValue, index); let activeIndex = index; @@ -355,18 +354,18 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue activeIndex = newValue.indexOf(previousValue); } - focusThumb({ sliderRef, activeIndex }); + focusThumb(sliderRef, activeIndex); } setValueState(newValue); setFocusedThumbIndex(index); - if (handleChange && !areValuesEqual(newValue, valueDerived)) { + if (onChange && !areValuesEqual(newValue, valueDerived)) { handleChange(event, newValue, index); } if (onChangeCommitted) { - onChangeCommitted(event, lastChangedValue.current ?? newValue); + onChangeCommitted(event, lastChangedValueRef.current ?? newValue); } }; @@ -457,12 +456,14 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue }; useEnhancedEffect(() => { - if (disabled && sliderRef.current!.contains(document.activeElement)) { + const activeElement = getActiveElement(ownerDocument(sliderRef.current)); + if (disabled && sliderRef.current?.contains(activeElement)) { // This is necessary because Firefox and Safari will keep focus // on a disabled element: // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js - // @ts-ignore - document.activeElement?.blur(); + if (activeElement != null && 'blur' in activeElement) { + (activeElement as HTMLElement).blur(); + } } }, [disabled]); @@ -474,28 +475,29 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue } const createHandleHiddenInputChange = - (otherHandlers: EventHandlers) => (event: React.ChangeEvent) => { + (otherHandlers: EventHandlers) => (event: React.ChangeEvent) => { otherHandlers.onChange?.(event); - // this handles value change by Pointer or Touch events - // @ts-ignore - changeValue(event, event.target.valueAsNumber); + // Handles value changes reported through the hidden range input. + changeValue(event, event.currentTarget.valueAsNumber); }; - const previousIndex = React.useRef(undefined); + const previousIndexRef = React.useRef(undefined); let axis = orientation; if (isRtl && orientation === 'horizontal') { axis += '-reverse'; } - const getFingerNewValue = ({ - finger, - move = false, - }: { - finger: { x: number; y: number }; - move?: boolean | undefined; - }) => { + // Converts finger coordinates to a slider value and determines the active thumb. + // For range sliders, reads `previousIndexRef.current` to decide which thumb is active: + // -1 = initial press → find closest thumb + // ≥0 = drag in progress → keep same thumb + // Callers must reset `previousIndexRef.current = -1` before calling on a new interaction. + const getValueAtFinger = (finger: { x: number; y: number }) => { const { current: slider } = sliderRef; - const { width, height, bottom, left } = slider!.getBoundingClientRect(); + if (!slider) { + return null; + } + const { width, height, bottom, left } = slider.getBoundingClientRect(); let percent; if (axis.startsWith('vertical')) { @@ -521,11 +523,8 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue let activeIndex = 0; if (range) { - if (!move) { - activeIndex = findClosest(values, newValue)!; - } else { - activeIndex = previousIndex.current!; - } + const isDragging = previousIndexRef.current !== -1; + activeIndex = isDragging ? previousIndexRef.current! : findClosest(values, newValue)!; // Bound the new value to the thumb's neighbours. if (disableSwap) { @@ -537,76 +536,92 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue } const previousValue = newValue; - newValue = setValueIndex({ - values, - newValue, - index: activeIndex, - }); + newValue = setValueIndex(values, newValue, activeIndex); // Potentially swap the index if needed. - if (!(disableSwap && move)) { + if (!(disableSwap && isDragging)) { activeIndex = newValue.indexOf(previousValue); - previousIndex.current = activeIndex; + previousIndexRef.current = activeIndex; } } return { newValue, activeIndex }; }; - const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { - const finger = trackFinger(nativeEvent, touchId); + const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => { + // Ignore pointer events from a different pointer than the one that started the drag. + if ('pointerId' in nativeEvent && nativeEvent.pointerId !== activePointerIdRef.current) { + return; + } + + const finger = trackFinger(nativeEvent, touchIdRef); if (!finger) { return; } - moveCount.current += 1; + moveCountRef.current += 1; - // Cancel move in case some other element consumed a mouseup event and it was not fired. - // @ts-ignore buttons doesn't not exists on touch event - if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { + // Cancel move in case some other element consumed a pointerup event and it was not fired. + if (nativeEvent.type === 'pointermove' && (nativeEvent as PointerEvent).buttons === 0) { // eslint-disable-next-line @typescript-eslint/no-use-before-define handleTouchEnd(nativeEvent); return; } - const { newValue, activeIndex } = getFingerNewValue({ - finger, - move: true, - }); + const newFingerValue = getValueAtFinger(finger); - focusThumb({ sliderRef, activeIndex, setActive, focusVisible: false }); - setValueState(newValue); + if (!newFingerValue) { + return; + } - if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { + focusThumb(sliderRef, newFingerValue.activeIndex, setActive, false); + lastUsedThumbIndexRef.current = newFingerValue.activeIndex; + setValueState(newFingerValue.newValue); + + if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { setDragging(true); } - if (handleChange && !areValuesEqual(newValue, valueDerived)) { - handleChange(nativeEvent, newValue, activeIndex); + if (onChange && !areValuesEqual(newFingerValue.newValue, valueDerived)) { + handleChange(nativeEvent, newFingerValue.newValue, newFingerValue.activeIndex); } }); - const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { - const finger = trackFinger(nativeEvent, touchId); + const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => { + // Ignore pointer events from a different pointer than the one that started the drag. + if ('pointerId' in nativeEvent && nativeEvent.pointerId !== activePointerIdRef.current) { + return; + } + + const finger = trackFinger(nativeEvent, touchIdRef); setDragging(false); if (!finger) { return; } - const { newValue } = getFingerNewValue({ finger, move: true }); + const newFingerValue = getValueAtFinger(finger); setActive(-1); if (nativeEvent.type === 'touchend') { setOpen(-1); } - if (onChangeCommitted) { - onChangeCommitted(nativeEvent, lastChangedValue.current ?? newValue); + if (newFingerValue && onChangeCommitted) { + onChangeCommitted(nativeEvent, lastChangedValueRef.current ?? newFingerValue.newValue); } - touchId.current = undefined; + // Release pointer capture if applicable + if ( + 'pointerType' in nativeEvent && + sliderRef.current?.hasPointerCapture(nativeEvent.pointerId) + ) { + sliderRef.current.releasePointerCapture(nativeEvent.pointerId); + } + + touchIdRef.current = undefined; + activePointerIdRef.current = -1; // eslint-disable-next-line @typescript-eslint/no-use-before-define stopListening(); @@ -616,29 +631,40 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue if (disabled) { return; } - // If touch-action: none; is not supported we need to prevent the scroll manually. - if (!doesSupportTouchActionNone()) { - nativeEvent.preventDefault(); + + // If the pointer path already handled this interaction, + // only record the touch identifier and skip duplicate listener registration. + if (pointerDownHandledRef.current) { + pointerDownHandledRef.current = false; + const touch = nativeEvent.changedTouches[0]; + if (touch != null) { + touchIdRef.current = touch.identifier; + } + return; } const touch = nativeEvent.changedTouches[0]; if (touch != null) { // A number that uniquely identifies the current finger in the touch session. - touchId.current = touch.identifier; + touchIdRef.current = touch.identifier; } - const finger = trackFinger(nativeEvent, touchId); + const finger = trackFinger(nativeEvent, touchIdRef); if (finger !== false) { - const { newValue, activeIndex } = getFingerNewValue({ finger }); - focusThumb({ sliderRef, activeIndex, setActive, focusVisible: false }); + previousIndexRef.current = -1; + const newFingerValue = getValueAtFinger(finger); + if (newFingerValue) { + focusThumb(sliderRef, newFingerValue.activeIndex, setActive, false); + lastUsedThumbIndexRef.current = newFingerValue.activeIndex; - setValueState(newValue); + setValueState(newFingerValue.newValue); - if (handleChange && !areValuesEqual(newValue, valueDerived)) { - handleChange(nativeEvent, newValue, activeIndex); + if (onChange && !areValuesEqual(newFingerValue.newValue, valueDerived)) { + handleChange(nativeEvent, newFingerValue.newValue, newFingerValue.activeIndex); + } } } - moveCount.current = 0; + moveCountRef.current = 0; const doc = ownerDocument(sliderRef.current); doc.addEventListener('touchmove', handleTouchMove, { passive: true }); doc.addEventListener('touchend', handleTouchEnd, { passive: true }); @@ -646,20 +672,23 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue const stopListening = React.useCallback(() => { const doc = ownerDocument(sliderRef.current); - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('pointermove', handleTouchMove); + doc.removeEventListener('pointerup', handleTouchEnd); doc.removeEventListener('touchmove', handleTouchMove); doc.removeEventListener('touchend', handleTouchEnd); }, [handleTouchEnd, handleTouchMove]); React.useEffect(() => { - const { current: slider } = sliderRef; - slider!.addEventListener('touchstart', handleTouchStart, { - passive: doesSupportTouchActionNone(), + const slider = sliderRef.current; + if (!slider) { + return undefined; + } + slider.addEventListener('touchstart', handleTouchStart, { + passive: true, }); return () => { - slider!.removeEventListener('touchstart', handleTouchStart); + slider.removeEventListener('touchstart', handleTouchStart); cancelFocusFrame(); stopListening(); @@ -673,54 +702,69 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue } }, [disabled, stopListening, cancelFocusFrame]); - const createHandleMouseDown = - (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { - otherHandlers.onMouseDown?.(event); - if (disabled) { - return; - } + const createHandlePointerDown = + (otherHandlers: EventHandlers) => (event: React.PointerEvent) => { + otherHandlers.onPointerDown?.(event); - if (event.defaultPrevented) { - return; + // On touch devices, the browser fires both pointerdown and touchstart for the + // same physical touch. Mark this BEFORE early returns so handleTouchStart always + // knows the pointer path saw this interaction — even if it was prevented or disabled. + if (event.pointerType === 'touch') { + pointerDownHandledRef.current = true; } - // Only handle left clicks - if (event.button !== 0) { + if (disabled || event.defaultPrevented || event.button !== 0) { return; } - const finger = trackFinger(event, touchId); + const finger = trackFinger(event, touchIdRef); if (finger !== false) { - const { newValue, activeIndex } = getFingerNewValue({ finger }); - const doc = ownerDocument(sliderRef.current); - const activeElement = doc.activeElement; - const pressedOnFocusedThumb = - sliderRef.current?.contains(activeElement) && - Number(activeElement?.getAttribute('data-index')) === activeIndex; - - setActive(activeIndex); - - if (pressedOnFocusedThumb) { - event.preventDefault(); - } else { - cancelFocusFrame(); - focusFrame.current = requestAnimationFrame(() => { - focusFrame.current = null; - focusThumb({ sliderRef, activeIndex, focusVisible: false }); - }); - } + previousIndexRef.current = -1; + const newFingerValue = getValueAtFinger(finger); + if (newFingerValue) { + const thumbInput = sliderRef.current?.querySelector( + `input[type="range"][data-index="${newFingerValue.activeIndex}"]`, + ); + const doc = ownerDocument(sliderRef.current); + const pressedOnFocusedThumb = thumbInput != null && thumbInput === getActiveElement(doc); + + setActive(newFingerValue.activeIndex); + lastUsedThumbIndexRef.current = newFingerValue.activeIndex; + + if (pressedOnFocusedThumb) { + event.preventDefault(); + } else { + cancelFocusFrame(); + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null; + focusThumb(sliderRef, newFingerValue.activeIndex, undefined, false); + }); + } - setValueState(newValue); + setValueState(newFingerValue.newValue); - if (handleChange && !areValuesEqual(newValue, valueDerived)) { - handleChange(event, newValue, activeIndex); + if (onChange && !areValuesEqual(newFingerValue.newValue, valueDerived)) { + handleChange(event, newFingerValue.newValue, newFingerValue.activeIndex); + } } } - moveCount.current = 0; + moveCountRef.current = 0; + activePointerIdRef.current = event.pointerId; const doc = ownerDocument(sliderRef.current); - doc.addEventListener('mousemove', handleTouchMove, { passive: true }); - doc.addEventListener('mouseup', handleTouchEnd); + + // Use pointer capture for reliable drag tracking + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // setPointerCapture can throw if the pointerId is invalid (e.g. synthetic + // events in tests, or the pointer was already released). The slider still + // works via document-level listeners; pointer capture is a progressive + // enhancement for reliable drag tracking. + } + + doc.addEventListener('pointermove', handleTouchMove, { passive: true }); + doc.addEventListener('pointerup', handleTouchEnd); }; const trackOffset = valueToPercent(range ? values[0] : min, min, max); @@ -729,10 +773,10 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue const getRootProps = = {}>( externalProps: ExternalProps = {} as ExternalProps, ): UseSliderRootSlotProps => { - const externalHandlers = extractEventHandlers(externalProps); + const externalHandlers = extractEventHandlers(externalProps) || EMPTY_OBJ; const ownEventHandlers = { - onMouseDown: createHandleMouseDown(externalHandlers || {}), + onPointerDown: createHandlePointerDown(externalHandlers), }; const mergedEventHandlers = { @@ -765,11 +809,11 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue const getThumbProps = = {}>( externalProps: ExternalProps = {} as ExternalProps, ): UseSliderThumbSlotProps => { - const externalHandlers = extractEventHandlers(externalProps); + const externalHandlers = extractEventHandlers(externalProps) || EMPTY_OBJ; const ownEventHandlers = { - onMouseOver: createHandleMouseOver(externalHandlers || {}), - onMouseLeave: createHandleMouseLeave(externalHandlers || {}), + onMouseOver: createHandleMouseOver(externalHandlers), + onMouseLeave: createHandleMouseLeave(externalHandlers), }; return { @@ -780,9 +824,21 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue }; const getThumbStyle = (index: number) => { + let zIndex: number | undefined; + if (range) { + if (active === index) { + zIndex = 2; + } else if (lastUsedThumbIndexRef.current === index) { + zIndex = 1; + } + } else if (active === index) { + zIndex = 1; + } + return { // So the non active thumb doesn't show its label on hover. pointerEvents: active !== -1 && active !== index ? 'none' : undefined, + zIndex, }; }; @@ -794,13 +850,13 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue const getHiddenInputProps = = {}>( externalProps: ExternalProps = {} as ExternalProps, ): UseSliderHiddenInputProps => { - const externalHandlers = extractEventHandlers(externalProps); + const externalHandlers = extractEventHandlers(externalProps) || EMPTY_OBJ; const ownEventHandlers = { - onChange: createHandleHiddenInputChange(externalHandlers || {}), - onFocus: createHandleHiddenInputFocus(externalHandlers || {}), - onBlur: createHandleHiddenInputBlur(externalHandlers || {}), - onKeyDown: createHandleHiddenInputKeyDown(externalHandlers || {}), + onChange: createHandleHiddenInputChange(externalHandlers), + onFocus: createHandleHiddenInputFocus(externalHandlers), + onBlur: createHandleHiddenInputBlur(externalHandlers), + onKeyDown: createHandleHiddenInputKeyDown(externalHandlers), }; const mergedEventHandlers = { @@ -842,7 +898,7 @@ export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue getHiddenInputProps, getRootProps, getThumbProps, - marks: marks as readonly Mark[], + marks, open, range, rootRef: handleRef, diff --git a/packages/mui-material/src/Slider/useSlider.types.ts b/packages/mui-material/src/Slider/useSlider.types.ts index 3313d92b19e64c..e6ae2b84085048 100644 --- a/packages/mui-material/src/Slider/useSlider.types.ts +++ b/packages/mui-material/src/Slider/useSlider.types.ts @@ -58,7 +58,7 @@ export interface UseSliderParameters { */ onChange?: ((event: Event, value: number | number[], activeThumb: number) => void) | undefined; /** - * Callback function that is fired when the `mouseup` is triggered. + * Callback function that is fired when the pointer or touch interaction ends. * * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. * @param {number | number[]} value The new value. @@ -115,7 +115,7 @@ export interface Mark { } export type UseSliderRootSlotOwnProps = { - onMouseDown: React.MouseEventHandler; + onPointerDown: React.PointerEventHandler; ref: React.RefCallback | null; };