Skip to content

Commit 464f055

Browse files
fix: Range Slider Mobile UX (#1909)
1 parent 90a623f commit 464f055

File tree

3 files changed

+86
-31
lines changed

3 files changed

+86
-31
lines changed

packages/components/src/Form/Inputs/RangeSlider/RangeSlider.test.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { renderWithTheme } from '@looker/components-test-utils'
3030
import { RangeSlider } from './RangeSlider'
3131

3232
const globalConsole = global.console
33+
const globalRequestAnimationFrame = global.requestAnimationFrame
3334
/* eslint-disable-next-line @typescript-eslint/unbound-method */
3435
const globalGetBoundingClientRect = Element.prototype.getBoundingClientRect
3536

@@ -39,6 +40,9 @@ beforeEach(() => {
3940
error: jest.fn(),
4041
warn: jest.fn(),
4142
}
43+
global.requestAnimationFrame = jest
44+
.fn()
45+
.mockImplementation((cb: Function) => cb())
4246
/* eslint-disable-next-line @typescript-eslint/unbound-method */
4347
Element.prototype.getBoundingClientRect = jest.fn(() => {
4448
return {
@@ -58,6 +62,7 @@ beforeEach(() => {
5862
afterEach(() => {
5963
jest.resetAllMocks()
6064
global.console = globalConsole
65+
global.requestAnimationFrame = globalRequestAnimationFrame
6166
/* eslint-disable-next-line @typescript-eslint/unbound-method */
6267
Element.prototype.getBoundingClientRect = globalGetBoundingClientRect
6368
})
@@ -85,7 +90,7 @@ test('warns the developer if value prop falls outside of possible min/max range'
8590
expect(handleChange).toHaveBeenCalledWith([10, 20])
8691
})
8792

88-
test('fires onChange callback when on mouseMove', () => {
93+
test('fires onChange callback on mouseMove', () => {
8994
const handleChange = jest.fn()
9095
const { getByTestId } = renderWithTheme(
9196
<RangeSlider onChange={handleChange} />
@@ -94,6 +99,22 @@ test('fires onChange callback when on mouseMove', () => {
9499
const wrapper = getByTestId('range-slider-wrapper')
95100
fireEvent.mouseDown(wrapper)
96101
fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 10 })
102+
fireEvent.mouseUp(wrapper)
103+
104+
expect(handleChange).toHaveBeenLastCalledWith([3, 10])
105+
})
106+
107+
test('fires onChange callback on touchMove', () => {
108+
const handleChange = jest.fn()
109+
const { getByTestId } = renderWithTheme(
110+
<RangeSlider onChange={handleChange} />
111+
)
112+
113+
const wrapper = getByTestId('range-slider-wrapper')
114+
fireEvent.touchStart(wrapper)
115+
fireEvent.touchMove(wrapper, { touches: [{ clientX: 100, clientY: 10 }] })
116+
fireEvent.touchEnd(wrapper)
117+
97118
expect(handleChange).toHaveBeenLastCalledWith([3, 10])
98119
})
99120

@@ -192,4 +213,20 @@ describe('disabled prop', () => {
192213
expect(handleChange).toHaveBeenLastCalledWith([0, 10]) // unchanged
193214
expect(handleChange).toHaveBeenCalledTimes(1)
194215
})
216+
217+
test('disabled component does not respond to TOUCH input', () => {
218+
const handleChange = jest.fn()
219+
const { getByTestId } = renderWithTheme(
220+
<RangeSlider onChange={handleChange} disabled />
221+
)
222+
223+
expect(handleChange).toHaveBeenCalledWith([0, 10]) // initial render
224+
225+
const wrapper = getByTestId('range-slider-wrapper')
226+
fireEvent.touchStart(wrapper)
227+
fireEvent.touchMove(wrapper, { touches: [{ clientX: 100, clientY: 10 }] })
228+
229+
expect(handleChange).toHaveBeenLastCalledWith([0, 10]) // unchanged
230+
expect(handleChange).toHaveBeenCalledTimes(1)
231+
})
195232
})

packages/components/src/Form/Inputs/RangeSlider/RangeSlider.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ import startsWith from 'lodash/startsWith'
4747
import partial from 'lodash/partial'
4848
import map from 'lodash/map'
4949
import isEqual from 'lodash/isEqual'
50+
5051
import {
5152
useMeasuredElement,
5253
useMouseDragPosition,
53-
usePreviousValue,
5454
useReadOnlyWarn,
5555
} from '../../../utils'
5656
import { ValidationType } from '../../ValidationMessage'
@@ -202,7 +202,6 @@ export const InternalRangeSlider = forwardRef(
202202
const containerRect = useMeasuredElement(containerRef)
203203

204204
const { mousePos, isMouseDown } = useMouseDragPosition(containerRef)
205-
const prevMouseDown = usePreviousValue(isMouseDown)
206205

207206
const minThumbRef = useRef<HTMLDivElement>(null)
208207
const maxThumbRef = useRef<HTMLDivElement>(null)
@@ -285,6 +284,7 @@ export const InternalRangeSlider = forwardRef(
285284
newPoint,
286285
maintainFocus ? focusedThumb : undefined
287286
)
287+
288288
focusChangedPoint(newValue, newPoint)
289289
setValue(newValue)
290290
}
@@ -298,7 +298,7 @@ export const InternalRangeSlider = forwardRef(
298298
*/
299299
useEffect(
300300
() => {
301-
if (isMouseDown && prevMouseDown) {
301+
if (isMouseDown) {
302302
handleMouseDrag()
303303
}
304304
},
@@ -336,6 +336,8 @@ export const InternalRangeSlider = forwardRef(
336336
<div
337337
data-testid="range-slider-wrapper"
338338
onMouseDown={handleMouseDown}
339+
onTouchStart={handleMouseDown}
340+
onTouchEnd={handleBlur}
339341
className={className}
340342
id={id}
341343
ref={setContainerRef}
@@ -412,6 +414,8 @@ export const RangeSlider = styled(InternalRangeSlider).attrs<RangeSliderProps>(
412414
${space}
413415
${typography}
414416
padding: 1.5rem 0 0.5rem;
417+
touch-action: none;
418+
user-select: none;
415419
`
416420

417421
const SliderTrack = styled.div`

packages/components/src/utils/useMouseDragPosition.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import { useState, useEffect } from 'react'
2828
import throttle from 'lodash/throttle'
29+
import get from 'lodash/get'
2930

3031
interface MouseState {
3132
mousePos: { x: number; y: number }
@@ -42,43 +43,56 @@ export function useMouseDragPosition(
4243
const [isMouseDown, setIsMouseDown] = useState(false)
4344
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
4445

45-
const handleMouseDown = (e: globalThis.MouseEvent) => {
46-
setMousePos({ x: e.pageX, y: e.pageY })
47-
setIsMouseDown(true)
46+
const updateMousePos = (e: globalThis.TouchEvent | globalThis.MouseEvent) => {
47+
// use e.touches[0] for touch events, or simply e for mouse events
48+
const event = get(e, 'touches[0]', e)
49+
// e.clientX and e.clientY fallbacks are included for testing purposes. jsDOM doesn't support pageX/pageY attributes
50+
const { pageX, clientX, pageY, clientY } = event
51+
setMousePos({ x: pageX || clientX, y: pageY || clientY })
4852
}
4953

50-
const handleMouseUp = () => {
51-
setIsMouseDown(false)
54+
const handleStart = (e: globalThis.TouchEvent | globalThis.MouseEvent) => {
55+
// update mouse down state AFTER mouse position state is updated
56+
requestAnimationFrame(() => {
57+
setIsMouseDown(true)
58+
})
59+
updateMousePos(e)
5260
}
5361

54-
const handleMouseMove = throttle((e: globalThis.MouseEvent) => {
62+
const handleMove = throttle(updateMousePos, 50)
63+
64+
const handleEnd = () => {
65+
requestAnimationFrame(() => {
66+
setIsMouseDown(false)
67+
})
68+
}
69+
70+
useEffect(() => {
71+
targetRef && targetRef.addEventListener('mousedown', handleStart)
72+
targetRef && targetRef.addEventListener('touchstart', handleStart)
73+
window.addEventListener('mouseup', handleEnd)
74+
window.addEventListener('touchend', handleEnd)
75+
5576
if (isMouseDown) {
56-
// e.clientX and e.clientY fallbacks are included for testing purposes. jsDOM doesn't support pageX/pageY attributes
57-
setMousePos({ x: e.pageX || e.clientX, y: e.pageY || e.clientY })
77+
window.addEventListener('touchmove', handleMove)
78+
window.addEventListener('mousemove', handleMove)
79+
window.addEventListener('mouseleave', handleEnd)
5880
}
59-
}, 50)
6081

61-
const handleMouseLeave = () => {
62-
setIsMouseDown(false)
63-
}
82+
return () => {
83+
targetRef && targetRef.removeEventListener('mousedown', handleStart)
84+
targetRef && targetRef.removeEventListener('touchstart', handleStart)
85+
window.removeEventListener('mouseup', handleEnd)
86+
window.removeEventListener('touchend', handleEnd)
6487

65-
useEffect(
66-
() => {
67-
targetRef && targetRef.addEventListener('mousedown', handleMouseDown)
68-
window.addEventListener('mouseup', handleMouseUp)
69-
window.addEventListener('mousemove', handleMouseMove)
70-
window.addEventListener('handleMouseLeave', handleMouseLeave)
71-
72-
return () => {
73-
targetRef && targetRef.removeEventListener('mousedown', handleMouseDown)
74-
window.removeEventListener('mouseup', handleMouseUp)
75-
window.removeEventListener('mousemove', handleMouseMove)
76-
window.removeEventListener('handleMouseLeave', handleMouseLeave)
88+
if (isMouseDown) {
89+
window.removeEventListener('touchmove', handleMove)
90+
window.removeEventListener('mousemove', handleMove)
91+
window.removeEventListener('mouseleave', handleEnd)
7792
}
78-
},
93+
}
7994
// eslint-disable-next-line react-hooks/exhaustive-deps
80-
[isMouseDown, targetRef]
81-
)
95+
}, [isMouseDown, targetRef])
8296

8397
return { isMouseDown, mousePos: mousePos }
8498
}

0 commit comments

Comments
 (0)