Skip to content

Commit af86546

Browse files
authored
[slider] Use pointer events instead of mouse events (#48164)
1 parent 3f1a610 commit af86546

8 files changed

Lines changed: 661 additions & 248 deletions

File tree

docs/data/material/migration/upgrade-to-v9/upgrade-to-v9.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ The `StepButton` has:
284284
- The `aria-setsize` added. The value is the total number of steps.
285285
- The `aria-posinset` added. The value is the index of the step inside the list, 1-based.
286286

287+
### Slider
288+
289+
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.
290+
287291
### Tabs
288292

289293
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.

docs/translations/api-docs/slider/slider.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
}
5858
},
5959
"onChangeCommitted": {
60-
"description": "Callback function that is fired when the <code>mouseup</code> is triggered.",
60+
"description": "Callback function that is fired when the pointer or touch interaction ends.",
6161
"typeDescriptions": {
6262
"event": {
6363
"name": "event",

packages/mui-material/src/Slider/Slider.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ export interface SliderOwnProps<Value extends number | readonly number[]> {
216216
*/
217217
onChange?: ((event: Event, value: Value, activeThumb: number) => void) | undefined;
218218
/**
219-
* Callback function that is fired when the `mouseup` is triggered.
219+
* Callback function that is fired when the pointer or touch interaction ends.
220220
*
221221
* @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event.
222222
* @param {Value} value The new value.

packages/mui-material/src/Slider/Slider.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,7 @@ Slider.propTypes /* remove-proptypes */ = {
933933
*/
934934
onChange: PropTypes.func,
935935
/**
936-
* Callback function that is fired when the `mouseup` is triggered.
936+
* Callback function that is fired when the pointer or touch interaction ends.
937937
*
938938
* @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event.
939939
* @param {Value} value The new value.

packages/mui-material/src/Slider/Slider.test.js

Lines changed: 189 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ function createTouches(touches) {
3030
describe.skipIf(!supportsTouch())('<Slider />', () => {
3131
const { render } = createRenderer();
3232

33+
beforeEach(() => {
34+
// jsdom doesn't implement Pointer Capture API
35+
if (!Element.prototype.setPointerCapture) {
36+
Element.prototype.setPointerCapture = stub();
37+
}
38+
if (!Element.prototype.releasePointerCapture) {
39+
Element.prototype.releasePointerCapture = stub();
40+
}
41+
if (!Element.prototype.hasPointerCapture) {
42+
Element.prototype.hasPointerCapture = stub().returns(false);
43+
}
44+
});
45+
3346
describeConformance(
3447
<Slider value={0} marks={[{ value: 0, label: '0' }]} valueLabelDisplay="on" />,
3548
() => ({
@@ -71,7 +84,7 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
7184
}),
7285
);
7386

74-
it('should call handlers', () => {
87+
it.skipIf(isJsdom())('should call handlers', () => {
7588
const handleChange = spy();
7689
const handleChangeCommitted = spy();
7790

@@ -84,13 +97,15 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
8497
}));
8598
const slider = screen.getByRole('slider');
8699

87-
fireEvent.mouseDown(container.firstChild, {
100+
fireEvent.pointerDown(container.firstChild, {
88101
buttons: 1,
89102
clientX: 10,
103+
pointerId: 1,
90104
});
91-
fireEvent.mouseUp(container.firstChild, {
105+
fireEvent.pointerUp(container.firstChild, {
92106
buttons: 1,
93107
clientX: 10,
108+
pointerId: 1,
94109
});
95110

96111
expect(handleChange.callCount).to.equal(1);
@@ -140,57 +155,63 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
140155
expect(handleChangeCommitted.callCount).to.equal(1);
141156
});
142157

143-
it('should hedge against a dropped mouseup event', () => {
158+
it.skipIf(isJsdom())('should hedge against a dropped pointerup event', () => {
144159
const handleChange = spy();
145160
const { container } = render(<Slider onChange={handleChange} value={0} />);
146161
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
147162
width: 100,
148163
left: 0,
149164
}));
150165

151-
fireEvent.mouseDown(container.firstChild, {
166+
fireEvent.pointerDown(container.firstChild, {
152167
buttons: 1,
153168
clientX: 1,
169+
pointerId: 1,
154170
});
155171
expect(handleChange.callCount).to.equal(1);
156172
expect(handleChange.args[0][1]).to.equal(1);
157173

158-
fireEvent.mouseMove(document.body, {
174+
fireEvent.pointerMove(document.body, {
159175
buttons: 1,
160176
clientX: 10,
177+
pointerId: 1,
161178
});
162179
expect(handleChange.callCount).to.equal(2);
163180
expect(handleChange.args[1][1]).to.equal(10);
164181

165-
fireEvent.mouseMove(document.body, {
182+
fireEvent.pointerMove(document.body, {
166183
buttons: 0,
167184
clientX: 11,
185+
pointerId: 1,
168186
});
169-
// The mouse's button was released, stop the dragging session.
187+
// The pointer's button was released, stop the dragging session.
170188
expect(handleChange.callCount).to.equal(2);
171189
});
172190

173-
it('should only fire onChange when the value changes', () => {
191+
it.skipIf(isJsdom())('should only fire onChange when the value changes', () => {
174192
const handleChange = spy();
175193
const { container } = render(<Slider defaultValue={20} onChange={handleChange} />);
176194
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
177195
width: 100,
178196
left: 0,
179197
}));
180198

181-
fireEvent.mouseDown(container.firstChild, {
199+
fireEvent.pointerDown(container.firstChild, {
182200
buttons: 1,
183201
clientX: 21,
202+
pointerId: 1,
184203
});
185204

186-
fireEvent.mouseMove(document.body, {
205+
fireEvent.pointerMove(document.body, {
187206
buttons: 1,
188207
clientX: 22,
208+
pointerId: 1,
189209
});
190210
// Sometimes another event with the same position is fired by the browser.
191-
fireEvent.mouseMove(document.body, {
211+
fireEvent.pointerMove(document.body, {
192212
buttons: 1,
193213
clientX: 22,
214+
pointerId: 1,
194215
});
195216

196217
expect(handleChange.callCount).to.equal(2);
@@ -324,7 +345,7 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
324345
expect(document.activeElement).to.have.attribute('data-index', '0');
325346
});
326347

327-
it('should focus the slider when dragging', async () => {
348+
it.skipIf(isJsdom())('should focus the slider when dragging', async () => {
328349
const { container } = render(
329350
<Slider
330351
slotProps={{ thumb: { 'data-testid': 'thumb' } }}
@@ -341,9 +362,10 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
341362
left: 0,
342363
}));
343364

344-
fireEvent.mouseDown(thumb, {
365+
fireEvent.pointerDown(thumb, {
345366
buttons: 1,
346367
clientX: 1,
368+
pointerId: 1,
347369
});
348370

349371
await waitFor(() => {
@@ -384,13 +406,13 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
384406
expect(handleChange.args[1][1]).to.deep.equal([22, 30]);
385407
});
386408

387-
it('should not react to right clicks', () => {
409+
it.skipIf(isJsdom())('should not react to right clicks', () => {
388410
const handleChange = spy();
389411

390412
render(<Slider onChange={handleChange} defaultValue={30} step={10} marks />);
391413

392414
const thumb = screen.getByRole('slider');
393-
fireEvent.mouseDown(thumb, { button: 2 });
415+
fireEvent.pointerDown(thumb, { button: 2, pointerId: 1 });
394416
expect(handleChange.callCount).to.equal(0);
395417
});
396418
});
@@ -1692,37 +1714,165 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
16921714
});
16931715
});
16941716

1695-
describe('When the onMouseUp event occurs at a different location than the last onChange event', () => {
1696-
it('should pass onChangeCommitted the same value that was passed to the last onChange event', () => {
1697-
const handleChange = spy();
1698-
const handleChangeCommitted = spy();
1717+
describe('When the pointer up event occurs at a different location than the last onChange event', () => {
1718+
it.skipIf(isJsdom())(
1719+
'should pass onChangeCommitted the same value that was passed to the last onChange event',
1720+
() => {
1721+
const handleChange = spy();
1722+
const handleChangeCommitted = spy();
1723+
1724+
const { container } = render(
1725+
<Slider onChange={handleChange} onChangeCommitted={handleChangeCommitted} value={0} />,
1726+
);
1727+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1728+
width: 100,
1729+
left: 0,
1730+
}));
1731+
1732+
fireEvent.pointerDown(container.firstChild, {
1733+
buttons: 1,
1734+
clientX: 10,
1735+
pointerId: 1,
1736+
});
1737+
fireEvent.pointerMove(container.firstChild, {
1738+
buttons: 1,
1739+
clientX: 15,
1740+
pointerId: 1,
1741+
});
1742+
fireEvent.pointerUp(container.firstChild, {
1743+
buttons: 1,
1744+
clientX: 20,
1745+
pointerId: 1,
1746+
});
1747+
1748+
expect(handleChange.callCount).to.equal(2);
1749+
expect(handleChange.args[0][1]).to.equal(10);
1750+
expect(handleChange.args[1][1]).to.equal(15);
1751+
expect(handleChangeCommitted.callCount).to.equal(1);
1752+
expect(handleChangeCommitted.args[0][1]).to.equal(15);
1753+
},
1754+
);
1755+
});
1756+
1757+
it.skipIf(isJsdom())('should not crash when unmounted during a pointer drag (#26754)', () => {
1758+
const { container, unmount } = render(<Slider defaultValue={50} />);
1759+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1760+
width: 100,
1761+
left: 0,
1762+
}));
1763+
1764+
fireEvent.pointerDown(container.firstChild, { clientX: 100, pointerId: 1 });
1765+
unmount();
1766+
fireEvent.pointerMove(document, { clientX: 150, pointerId: 1 });
1767+
fireEvent.pointerUp(document, { pointerId: 1 });
1768+
});
1769+
1770+
it('should not crash when unmounted during a touch drag (#26754)', () => {
1771+
const { container, unmount } = render(<Slider defaultValue={50} />);
1772+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1773+
width: 100,
1774+
height: 10,
1775+
bottom: 10,
1776+
left: 0,
1777+
}));
1778+
1779+
fireEvent.touchStart(
1780+
container.firstChild,
1781+
createTouches([{ identifier: 0, clientX: 100, clientY: 5 }]),
1782+
);
1783+
unmount();
1784+
fireEvent.touchMove(document, createTouches([{ identifier: 0, clientX: 150, clientY: 5 }]));
1785+
fireEvent.touchEnd(document, createTouches([{ identifier: 0, clientX: 150, clientY: 5 }]));
1786+
});
1787+
1788+
it.skipIf(isJsdom())('should end drag when pointermove fires with buttons === 0', () => {
1789+
const onChangeCommitted = spy();
1790+
const { container } = render(
1791+
<Slider defaultValue={50} onChangeCommitted={onChangeCommitted} />,
1792+
);
1793+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1794+
width: 100,
1795+
left: 0,
1796+
}));
1797+
1798+
fireEvent.pointerDown(container.firstChild, { clientX: 100, pointerId: 1 });
1799+
fireEvent.pointerMove(document, { clientX: 150, pointerId: 1, buttons: 0 });
1800+
expect(onChangeCommitted.callCount).to.equal(1);
1801+
});
16991802

1803+
it.skipIf(isJsdom())(
1804+
'should allow consumers to prevent drag via onPointerDown + preventDefault()',
1805+
() => {
1806+
const handleChange = spy();
17001807
const { container } = render(
1701-
<Slider onChange={handleChange} onChangeCommitted={handleChangeCommitted} value={0} />,
1808+
<Slider
1809+
defaultValue={50}
1810+
onChange={handleChange}
1811+
slotProps={{
1812+
root: {
1813+
onPointerDown: (pointerDownEvent) => pointerDownEvent.preventDefault(),
1814+
},
1815+
}}
1816+
/>,
17021817
);
17031818
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
17041819
width: 100,
17051820
left: 0,
17061821
}));
17071822

1708-
fireEvent.mouseDown(container.firstChild, {
1709-
buttons: 1,
1710-
clientX: 10,
1711-
});
1712-
fireEvent.mouseMove(container.firstChild, {
1713-
buttons: 1,
1714-
clientX: 15,
1715-
});
1716-
fireEvent.mouseUp(container.firstChild, {
1717-
buttons: 1,
1718-
clientX: 20,
1719-
});
1823+
fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 });
1824+
expect(handleChange.callCount).to.equal(0);
1825+
},
1826+
);
1827+
1828+
it.skipIf(isJsdom())(
1829+
'should not fire onChange twice on touch devices (pointer+touch dual fire)',
1830+
() => {
1831+
const handleChange = spy();
1832+
const { container } = render(<Slider defaultValue={50} onChange={handleChange} />);
1833+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1834+
width: 100,
1835+
height: 10,
1836+
bottom: 10,
1837+
left: 0,
1838+
}));
1839+
1840+
// Touch devices fire both pointer and touch events for the same physical touch
1841+
fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 });
1842+
fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 0, clientX: 20 }]));
17201843

1844+
// Move — only the pointer path listener should be on document
1845+
fireEvent.pointerMove(document, { clientX: 40, pointerId: 1, buttons: 1 });
1846+
1847+
// onChange: once from pointerDown (value change) + once from pointerMove = 2, not 3
17211848
expect(handleChange.callCount).to.equal(2);
1722-
expect(handleChange.args[0][1]).to.equal(10);
1723-
expect(handleChange.args[1][1]).to.equal(15);
1724-
expect(handleChangeCommitted.callCount).to.equal(1);
1725-
expect(handleChangeCommitted.args[0][1]).to.equal(15);
1726-
});
1727-
});
1849+
},
1850+
);
1851+
1852+
it.skipIf(isJsdom())(
1853+
'should ignore pointerup from a different pointer than the one that started the drag',
1854+
() => {
1855+
const handleChange = spy();
1856+
const { container } = render(<Slider defaultValue={50} onChange={handleChange} />);
1857+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1858+
width: 100,
1859+
height: 10,
1860+
bottom: 10,
1861+
left: 0,
1862+
}));
1863+
1864+
// Start drag with pointer 1
1865+
fireEvent.pointerDown(container.firstChild, { clientX: 50, pointerId: 1 });
1866+
const changesAfterDown = handleChange.callCount;
1867+
1868+
// A second pointer fires pointerup — should be ignored
1869+
fireEvent.pointerUp(document, { clientX: 60, pointerId: 2 });
1870+
1871+
// The drag should still be active — a move from the original pointer
1872+
// must still produce onChange. Without pointerId filtering, the stray
1873+
// pointerup tears down listeners and this move is silently dropped.
1874+
fireEvent.pointerMove(document, { clientX: 70, pointerId: 1, buttons: 1 });
1875+
expect(handleChange.callCount).to.be.greaterThan(changesAfterDown);
1876+
},
1877+
);
17281878
});

0 commit comments

Comments
 (0)