Skip to content

Commit bd9635e

Browse files
author
Michael Jordan
committed
useSliderThumb: fire onChangeEnd for PageUp/PageDown/Home/End (adobe#2659 / adobe#2819) adobe#2
1 parent c6bf853 commit bd9635e

File tree

9 files changed

+295
-21
lines changed

9 files changed

+295
-21
lines changed

packages/@react-aria/slider/src/useSliderThumb.ts

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import {getSliderThumbId, sliderIds} from './utils';
44
import React, {ChangeEvent, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useEffect, useRef} from 'react';
55
import {SliderState} from '@react-stately/slider';
66
import {useFocusable} from '@react-aria/focus';
7+
import {useKeyboard, useMove} from '@react-aria/interactions';
78
import {useLabel} from '@react-aria/label';
89
import {useLocale} from '@react-aria/i18n';
9-
import {useMove} from '@react-aria/interactions';
1010

1111
interface SliderThumbAria {
1212
/** Props for the root thumb element; handles the dragging motion. */
@@ -77,34 +77,88 @@ export function useSliderThumb(
7777
stateRef.current = state;
7878
let reverseX = direction === 'rtl';
7979
let currentPosition = useRef<number>(null);
80+
81+
let {keyboardProps} = useKeyboard({
82+
onKeyDown(e) {
83+
let {
84+
getThumbMaxValue,
85+
getThumbMinValue,
86+
pageSize
87+
} = stateRef.current;
88+
// these are the cases that useMove or useSlider don't handle
89+
if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) {
90+
e.continuePropagation();
91+
return;
92+
}
93+
// same handling as useMove, stopPropagation to prevent useSlider from handling the event as well.
94+
e.preventDefault();
95+
// remember to set this so that onChangeEnd is fired
96+
state.setThumbDragging(index, true);
97+
switch (e.key) {
98+
case 'PageUp':
99+
stateRef.current.incrementThumb(index, pageSize);
100+
break;
101+
case 'PageDown':
102+
stateRef.current.decrementThumb(index, pageSize);
103+
break;
104+
case 'Home':
105+
state.setThumbValue(index, getThumbMinValue(index));
106+
break;
107+
case 'End':
108+
state.setThumbValue(index, getThumbMaxValue(index));
109+
break;
110+
}
111+
state.setThumbDragging(index, false);
112+
}
113+
});
114+
80115
let {moveProps} = useMove({
81116
onMoveStart() {
82117
currentPosition.current = null;
83-
state.setThumbDragging(index, true);
118+
stateRef.current.setThumbDragging(index, true);
84119
},
85-
onMove({deltaX, deltaY, pointerType}) {
120+
onMove({deltaX, deltaY, pointerType, shiftKey}) {
121+
const {
122+
getThumbPercent,
123+
setThumbPercent,
124+
step,
125+
pageSize
126+
} = stateRef.current;
86127
let size = isVertical ? trackRef.current.offsetHeight : trackRef.current.offsetWidth;
87128

88129
if (currentPosition.current == null) {
89-
currentPosition.current = stateRef.current.getThumbPercent(index) * size;
130+
currentPosition.current = getThumbPercent(index) * size;
90131
}
91132
if (pointerType === 'keyboard') {
92-
// (invert left/right according to language direction) + (according to vertical)
93-
let delta = ((reverseX ? -deltaX : deltaX) + (isVertical ? -deltaY : -deltaY)) * stateRef.current.step;
94-
currentPosition.current += delta * size;
95-
stateRef.current.setThumbValue(index, stateRef.current.getThumbValue(index) + delta);
133+
if (deltaX > 0) {
134+
if (reverseX) {
135+
stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
136+
} else {
137+
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
138+
}
139+
} else if (deltaY < 0) {
140+
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
141+
} else if (deltaX < 0) {
142+
if (reverseX) {
143+
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
144+
} else {
145+
stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
146+
}
147+
} else if (deltaY > 0) {
148+
stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
149+
}
96150
} else {
97151
let delta = isVertical ? deltaY : deltaX;
98152
if (isVertical || reverseX) {
99153
delta = -delta;
100154
}
101155

102156
currentPosition.current += delta;
103-
stateRef.current.setThumbPercent(index, clamp(currentPosition.current / size, 0, 1));
157+
setThumbPercent(index, clamp(currentPosition.current / size, 0, 1));
104158
}
105159
},
106160
onMoveEnd() {
107-
state.setThumbDragging(index, false);
161+
stateRef.current.setThumbDragging(index, false);
108162
}
109163
});
110164

@@ -161,10 +215,11 @@ export function useSliderThumb(
161215
'aria-invalid': validationState === 'invalid' || undefined,
162216
'aria-errormessage': opts['aria-errormessage'],
163217
onChange: (e: ChangeEvent<HTMLInputElement>) => {
164-
state.setThumbValue(index, parseFloat(e.target.value));
218+
stateRef.current.setThumbValue(index, parseFloat(e.target.value));
165219
}
166220
}),
167221
thumbProps: !isDisabled ? mergeProps(
222+
keyboardProps,
168223
moveProps,
169224
{
170225
onMouseDown: (e: React.MouseEvent<HTMLElement>) => {

packages/@react-aria/slider/stories/Slider.stories.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ storiesOf('Slider (hooks)', module)
2828
'single with aria label',
2929
() => <StorySlider aria-label="Size" onChange={action('onChange')} onChangeEnd={action('onChangeEnd')} showTip />
3030
)
31+
.add(
32+
'single with pageSize',
33+
() => <StorySlider label="Degrees" onChange={action('onChange')} onChangeEnd={action('onChangeEnd')} minValue={0} maxValue={360} pageSize={15} formatOptions={{style: 'unit', unit: 'degree', unitDisplay: 'narrow'}} showTip />
34+
)
3135
.add(
3236
'range',
3337
() => (<StoryRangeSlider
@@ -56,6 +60,23 @@ storiesOf('Slider (hooks)', module)
5660
unitDisplay: 'narrow'
5761
} as any} />)
5862
)
63+
.add(
64+
'range with pageSize',
65+
() => (<StoryRangeSlider
66+
label="Arc"
67+
defaultValue={[45, 135]}
68+
minValue={0}
69+
maxValue={360}
70+
pageSize={15}
71+
onChange={action('onChange')}
72+
onChangeEnd={action('onChangeEnd')}
73+
showTip
74+
formatOptions={{
75+
style: 'unit',
76+
unit: 'degree',
77+
unitDisplay: 'narrow'
78+
} as any} />)
79+
)
5980
.add(
6081
'3 thumbs',
6182
() => (

packages/@react-aria/slider/test/useSliderThumb.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,60 @@ describe('useSliderThumb', () => {
384384
// Drag thumb
385385
let thumb0 = screen.getByTestId('thumb').firstChild;
386386
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
387+
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
387388
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
388389
expect(onChangeSpy).toHaveBeenCalledTimes(1);
389390
expect(onChangeEndSpy).toHaveBeenLastCalledWith([11]);
390391
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
391392
expect(stateRef.current.values).toEqual([11]);
393+
394+
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
395+
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
396+
expect(onChangeSpy).toHaveBeenLastCalledWith([10]);
397+
expect(onChangeSpy).toHaveBeenCalledTimes(2);
398+
expect(onChangeEndSpy).toHaveBeenLastCalledWith([10]);
399+
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
400+
expect(stateRef.current.values).toEqual([10]);
401+
});
402+
403+
it('can be moved with keys at the beginning of the slider', () => {
404+
let onChangeSpy = jest.fn();
405+
let onChangeEndSpy = jest.fn();
406+
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[0]} />);
407+
408+
let thumb0 = screen.getByTestId('thumb').firstChild;
409+
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
410+
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
411+
expect(onChangeSpy).not.toHaveBeenCalled();
412+
expect(onChangeEndSpy).toHaveBeenCalledWith([0]);
413+
414+
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
415+
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
416+
expect(onChangeSpy).toHaveBeenLastCalledWith([1]);
417+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
418+
expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]);
419+
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
420+
expect(stateRef.current.values).toEqual([1]);
421+
});
422+
423+
it('can be moved with keys at the end of the slider', () => {
424+
let onChangeSpy = jest.fn();
425+
let onChangeEndSpy = jest.fn();
426+
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[100]} />);
427+
428+
let thumb0 = screen.getByTestId('thumb').firstChild;
429+
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
430+
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
431+
expect(onChangeSpy).not.toHaveBeenCalled();
432+
expect(onChangeEndSpy).toHaveBeenCalledWith([100]);
433+
434+
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
435+
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
436+
expect(onChangeSpy).toHaveBeenLastCalledWith([99]);
437+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
438+
expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]);
439+
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
440+
expect(stateRef.current.values).toEqual([99]);
392441
});
393442

394443
it('can be moved with keys (vertical)', () => {
@@ -399,18 +448,64 @@ describe('useSliderThumb', () => {
399448
// Drag thumb
400449
let thumb0 = screen.getByTestId('thumb').firstChild;
401450
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
451+
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
402452
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
403453
expect(onChangeSpy).toHaveBeenCalledTimes(1);
404454
fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
455+
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
405456
expect(onChangeSpy).toHaveBeenLastCalledWith([12]);
406457
expect(onChangeSpy).toHaveBeenCalledTimes(2);
407458
fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
459+
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
408460
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
409461
expect(onChangeSpy).toHaveBeenCalledTimes(3);
410462
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
463+
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
411464
expect(onChangeSpy).toHaveBeenLastCalledWith([10]);
412465
expect(onChangeSpy).toHaveBeenCalledTimes(4);
413466
});
467+
468+
it('can be moved with keys (vertical) at the bottom of the slider', () => {
469+
let onChangeSpy = jest.fn();
470+
let onChangeEndSpy = jest.fn();
471+
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[0]} orientation="vertical" />);
472+
473+
// Drag thumb
474+
let thumb0 = screen.getByTestId('thumb').firstChild;
475+
fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
476+
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
477+
expect(onChangeSpy).not.toHaveBeenCalled();
478+
expect(onChangeEndSpy).toHaveBeenCalledWith([0]);
479+
480+
fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
481+
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
482+
expect(onChangeSpy).toHaveBeenLastCalledWith([1]);
483+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
484+
expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]);
485+
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
486+
expect(stateRef.current.values).toEqual([1]);
487+
});
488+
489+
it('can be moved with keys (vertical) at the top of the slider', () => {
490+
let onChangeSpy = jest.fn();
491+
let onChangeEndSpy = jest.fn();
492+
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[100]} orientation="vertical" />);
493+
494+
// Drag thumb
495+
let thumb0 = screen.getByTestId('thumb').firstChild;
496+
fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
497+
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
498+
expect(onChangeSpy).not.toHaveBeenCalled();
499+
expect(onChangeEndSpy).toHaveBeenCalledWith([100]);
500+
501+
fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
502+
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
503+
expect(onChangeSpy).toHaveBeenLastCalledWith([99]);
504+
expect(onChangeSpy).toHaveBeenCalledTimes(1);
505+
expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]);
506+
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
507+
expect(stateRef.current.values).toEqual([99]);
508+
});
414509
});
415510
});
416511
});

packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ storiesOf('Slider/RangeSlider', module)
7171
.add(
7272
'min/max',
7373
() => render({label: 'Label', minValue: 30, maxValue: 70})
74+
)
75+
.add(
76+
'pageSize',
77+
() => render({label: 'Label', minValue: 0, maxValue: 360, pageSize: 15, formatOptions: {style: 'unit', unit: 'degree', unitDisplay: 'narrow'}})
7478
);
7579

7680
function render(props: SpectrumRangeSliderProps = {}) {
@@ -79,5 +83,10 @@ function render(props: SpectrumRangeSliderProps = {}) {
7983
action('change')(v.start, v.end);
8084
};
8185
}
86+
if (props.onChangeEnd == null) {
87+
props.onChangeEnd = (v) => {
88+
action('changeEnd')(v.start, v.end);
89+
};
90+
}
8291
return <RangeSlider {...props} />;
8392
}

packages/@react-spectrum/slider/stories/Slider.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ storiesOf('Slider', module)
9696
'step',
9797
() => render({label: 'Label', minValue: 0, maxValue: 100, step: 5})
9898
)
99+
.add(
100+
'pageSize',
101+
() => render({label: 'Label', minValue: 0, maxValue: 360, pageSize: 15, formatOptions: {style: 'unit', unit: 'degree', unitDisplay: 'narrow'}})
102+
)
99103
.add(
100104
'isFilled: true',
101105
() => render({label: 'Label', isFilled: true})
@@ -121,5 +125,8 @@ function render(props: SpectrumSliderProps = {}) {
121125
if (props.onChange == null) {
122126
props.onChange = action('change');
123127
}
128+
if (props.onChangeEnd == null) {
129+
props.onChangeEnd = action('changeEnd');
130+
}
124131
return <Slider {...props} />;
125132
}

0 commit comments

Comments
 (0)