Skip to content

Commit fccc869

Browse files
committed
Use pointer events
1 parent 709d924 commit fccc869

7 files changed

Lines changed: 613 additions & 247 deletions

File tree

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: 190 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
() => ({
@@ -65,13 +78,14 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
6578
},
6679
},
6780
skip: [
81+
'componentsProp',
6882
'slotPropsCallback', // not supported yet
6983
'slotPropsCallbackWithPropsAsOwnerState', // not supported yet
7084
],
7185
}),
7286
);
7387

74-
it('should call handlers', () => {
88+
it.skipIf(isJsdom())('should call handlers', () => {
7589
const handleChange = spy();
7690
const handleChangeCommitted = spy();
7791

@@ -84,13 +98,15 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
8498
}));
8599
const slider = screen.getByRole('slider');
86100

87-
fireEvent.mouseDown(container.firstChild, {
101+
fireEvent.pointerDown(container.firstChild, {
88102
buttons: 1,
89103
clientX: 10,
104+
pointerId: 1,
90105
});
91-
fireEvent.mouseUp(container.firstChild, {
106+
fireEvent.pointerUp(container.firstChild, {
92107
buttons: 1,
93108
clientX: 10,
109+
pointerId: 1,
94110
});
95111

96112
expect(handleChange.callCount).to.equal(1);
@@ -140,57 +156,63 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
140156
expect(handleChangeCommitted.callCount).to.equal(1);
141157
});
142158

143-
it('should hedge against a dropped mouseup event', () => {
159+
it.skipIf(isJsdom())('should hedge against a dropped pointerup event', () => {
144160
const handleChange = spy();
145161
const { container } = render(<Slider onChange={handleChange} value={0} />);
146162
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
147163
width: 100,
148164
left: 0,
149165
}));
150166

151-
fireEvent.mouseDown(container.firstChild, {
167+
fireEvent.pointerDown(container.firstChild, {
152168
buttons: 1,
153169
clientX: 1,
170+
pointerId: 1,
154171
});
155172
expect(handleChange.callCount).to.equal(1);
156173
expect(handleChange.args[0][1]).to.equal(1);
157174

158-
fireEvent.mouseMove(document.body, {
175+
fireEvent.pointerMove(document.body, {
159176
buttons: 1,
160177
clientX: 10,
178+
pointerId: 1,
161179
});
162180
expect(handleChange.callCount).to.equal(2);
163181
expect(handleChange.args[1][1]).to.equal(10);
164182

165-
fireEvent.mouseMove(document.body, {
183+
fireEvent.pointerMove(document.body, {
166184
buttons: 0,
167185
clientX: 11,
186+
pointerId: 1,
168187
});
169-
// The mouse's button was released, stop the dragging session.
188+
// The pointer's button was released, stop the dragging session.
170189
expect(handleChange.callCount).to.equal(2);
171190
});
172191

173-
it('should only fire onChange when the value changes', () => {
192+
it.skipIf(isJsdom())('should only fire onChange when the value changes', () => {
174193
const handleChange = spy();
175194
const { container } = render(<Slider defaultValue={20} onChange={handleChange} />);
176195
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
177196
width: 100,
178197
left: 0,
179198
}));
180199

181-
fireEvent.mouseDown(container.firstChild, {
200+
fireEvent.pointerDown(container.firstChild, {
182201
buttons: 1,
183202
clientX: 21,
203+
pointerId: 1,
184204
});
185205

186-
fireEvent.mouseMove(document.body, {
206+
fireEvent.pointerMove(document.body, {
187207
buttons: 1,
188208
clientX: 22,
209+
pointerId: 1,
189210
});
190211
// Sometimes another event with the same position is fired by the browser.
191-
fireEvent.mouseMove(document.body, {
212+
fireEvent.pointerMove(document.body, {
192213
buttons: 1,
193214
clientX: 22,
215+
pointerId: 1,
194216
});
195217

196218
expect(handleChange.callCount).to.equal(2);
@@ -324,7 +346,7 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
324346
expect(document.activeElement).to.have.attribute('data-index', '0');
325347
});
326348

327-
it('should focus the slider when dragging', async () => {
349+
it.skipIf(isJsdom())('should focus the slider when dragging', async () => {
328350
const { container } = render(
329351
<Slider
330352
slotProps={{ thumb: { 'data-testid': 'thumb' } }}
@@ -341,9 +363,10 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
341363
left: 0,
342364
}));
343365

344-
fireEvent.mouseDown(thumb, {
366+
fireEvent.pointerDown(thumb, {
345367
buttons: 1,
346368
clientX: 1,
369+
pointerId: 1,
347370
});
348371

349372
await waitFor(() => {
@@ -384,13 +407,13 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
384407
expect(handleChange.args[1][1]).to.deep.equal([22, 30]);
385408
});
386409

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

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

392415
const thumb = screen.getByRole('slider');
393-
fireEvent.mouseDown(thumb, { button: 2 });
416+
fireEvent.pointerDown(thumb, { button: 2, pointerId: 1 });
394417
expect(handleChange.callCount).to.equal(0);
395418
});
396419
});
@@ -1692,37 +1715,165 @@ describe.skipIf(!supportsTouch())('<Slider />', () => {
16921715
});
16931716
});
16941717

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

1804+
it.skipIf(isJsdom())(
1805+
'should allow consumers to prevent drag via onPointerDown + preventDefault()',
1806+
() => {
1807+
const handleChange = spy();
17001808
const { container } = render(
1701-
<Slider onChange={handleChange} onChangeCommitted={handleChangeCommitted} value={0} />,
1809+
<Slider
1810+
defaultValue={50}
1811+
onChange={handleChange}
1812+
slotProps={{
1813+
root: {
1814+
onPointerDown: (pointerDownEvent) => pointerDownEvent.preventDefault(),
1815+
},
1816+
}}
1817+
/>,
17021818
);
17031819
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
17041820
width: 100,
17051821
left: 0,
17061822
}));
17071823

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-
});
1824+
fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 });
1825+
expect(handleChange.callCount).to.equal(0);
1826+
},
1827+
);
1828+
1829+
it.skipIf(isJsdom())(
1830+
'should not fire onChange twice on touch devices (pointer+touch dual fire)',
1831+
() => {
1832+
const handleChange = spy();
1833+
const { container } = render(<Slider defaultValue={50} onChange={handleChange} />);
1834+
stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
1835+
width: 100,
1836+
height: 10,
1837+
bottom: 10,
1838+
left: 0,
1839+
}));
1840+
1841+
// Touch devices fire both pointer and touch events for the same physical touch
1842+
fireEvent.pointerDown(container.firstChild, { clientX: 20, pointerId: 1 });
1843+
fireEvent.touchStart(container.firstChild, createTouches([{ identifier: 0, clientX: 20 }]));
17201844

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

0 commit comments

Comments
 (0)