diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 7aafaf8d6b2..5fe78aef821 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -4,9 +4,9 @@ import {getSliderThumbId, sliderIds} from './utils'; import React, {ChangeEvent, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useEffect, useRef} from 'react'; import {SliderState} from '@react-stately/slider'; import {useFocusable} from '@react-aria/focus'; +import {useKeyboard, useMove} from '@react-aria/interactions'; import {useLabel} from '@react-aria/label'; import {useLocale} from '@react-aria/i18n'; -import {useMove} from '@react-aria/interactions'; interface SliderThumbAria { /** Props for the root thumb element; handles the dragging motion. */ @@ -77,22 +77,70 @@ export function useSliderThumb( stateRef.current = state; let reverseX = direction === 'rtl'; let currentPosition = useRef(null); + + let {keyboardProps} = useKeyboard({ + onKeyDown(e) { + let { + getThumbMaxValue, + getThumbMinValue, + decrementThumb, + incrementThumb, + setThumbValue, + setThumbDragging, + pageSize + } = stateRef.current; + // these are the cases that useMove or useSlider don't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, stopPropagation to prevent useSlider from handling the event as well. + e.preventDefault(); + // remember to set this so that onChangeEnd is fired + setThumbDragging(index, true); + switch (e.key) { + case 'PageUp': + incrementThumb(index, pageSize); + break; + case 'PageDown': + decrementThumb(index, pageSize); + break; + case 'Home': + setThumbValue(index, getThumbMinValue(index)); + break; + case 'End': + setThumbValue(index, getThumbMaxValue(index)); + break; + } + setThumbDragging(index, false); + } + }); + let {moveProps} = useMove({ onMoveStart() { currentPosition.current = null; - state.setThumbDragging(index, true); + stateRef.current.setThumbDragging(index, true); }, - onMove({deltaX, deltaY, pointerType}) { + onMove({deltaX, deltaY, pointerType, shiftKey}) { + const { + getThumbPercent, + setThumbPercent, + decrementThumb, + incrementThumb, + step, + pageSize + } = stateRef.current; let size = isVertical ? trackRef.current.offsetHeight : trackRef.current.offsetWidth; if (currentPosition.current == null) { - currentPosition.current = stateRef.current.getThumbPercent(index) * size; + currentPosition.current = getThumbPercent(index) * size; } if (pointerType === 'keyboard') { - // (invert left/right according to language direction) + (according to vertical) - let delta = ((reverseX ? -deltaX : deltaX) + (isVertical ? -deltaY : -deltaY)) * stateRef.current.step; - currentPosition.current += delta * size; - stateRef.current.setThumbValue(index, stateRef.current.getThumbValue(index) + delta); + if ((deltaX > 0 && reverseX) || (deltaX < 0 && !reverseX) || deltaY > 0) { + decrementThumb(index, shiftKey ? pageSize : step); + } else { + incrementThumb(index, shiftKey ? pageSize : step); + } } else { let delta = isVertical ? deltaY : deltaX; if (isVertical || reverseX) { @@ -100,11 +148,11 @@ export function useSliderThumb( } currentPosition.current += delta; - stateRef.current.setThumbPercent(index, clamp(currentPosition.current / size, 0, 1)); + setThumbPercent(index, clamp(currentPosition.current / size, 0, 1)); } }, onMoveEnd() { - state.setThumbDragging(index, false); + stateRef.current.setThumbDragging(index, false); } }); @@ -161,10 +209,11 @@ export function useSliderThumb( 'aria-invalid': validationState === 'invalid' || undefined, 'aria-errormessage': opts['aria-errormessage'], onChange: (e: ChangeEvent) => { - state.setThumbValue(index, parseFloat(e.target.value)); + stateRef.current.setThumbValue(index, parseFloat(e.target.value)); } }), thumbProps: !isDisabled ? mergeProps( + keyboardProps, moveProps, { onMouseDown: (e: React.MouseEvent) => { diff --git a/packages/@react-aria/slider/stories/Slider.stories.tsx b/packages/@react-aria/slider/stories/Slider.stories.tsx index aab72c994c0..20a4f65750b 100644 --- a/packages/@react-aria/slider/stories/Slider.stories.tsx +++ b/packages/@react-aria/slider/stories/Slider.stories.tsx @@ -28,6 +28,10 @@ storiesOf('Slider (hooks)', module) 'single with aria label', () => ) + .add( + 'single with pageSize', + () => + ) .add( 'range', () => () ) + .add( + 'range with pageSize', + () => () + ) .add( '3 thumbs', () => ( diff --git a/packages/@react-aria/slider/test/useSliderThumb.test.js b/packages/@react-aria/slider/test/useSliderThumb.test.js index e7ee3f726e3..6b0a8ed5d95 100644 --- a/packages/@react-aria/slider/test/useSliderThumb.test.js +++ b/packages/@react-aria/slider/test/useSliderThumb.test.js @@ -384,11 +384,60 @@ describe('useSliderThumb', () => { // Drag thumb let thumb0 = screen.getByTestId('thumb').firstChild; fireEvent.keyDown(thumb0, {key: 'ArrowRight'}); + fireEvent.keyUp(thumb0, {key: 'ArrowRight'}); expect(onChangeSpy).toHaveBeenLastCalledWith([11]); expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeEndSpy).toHaveBeenLastCalledWith([11]); expect(onChangeEndSpy).toHaveBeenCalledTimes(1); expect(stateRef.current.values).toEqual([11]); + + fireEvent.keyDown(thumb0, {key: 'ArrowLeft'}); + fireEvent.keyUp(thumb0, {key: 'ArrowLeft'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([10]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([10]); + }); + + it('can be moved with keys at the beginning of the slider', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + let thumb0 = screen.getByTestId('thumb').firstChild; + fireEvent.keyDown(thumb0, {key: 'ArrowLeft'}); + fireEvent.keyUp(thumb0, {key: 'ArrowLeft'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).toHaveBeenCalledWith([0]); + + fireEvent.keyDown(thumb0, {key: 'ArrowRight'}); + fireEvent.keyUp(thumb0, {key: 'ArrowRight'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([1]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([1]); + }); + + it('can be moved with keys at the end of the slider', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + let thumb0 = screen.getByTestId('thumb').firstChild; + fireEvent.keyDown(thumb0, {key: 'ArrowRight'}); + fireEvent.keyUp(thumb0, {key: 'ArrowRight'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).toHaveBeenCalledWith([100]); + + fireEvent.keyDown(thumb0, {key: 'ArrowLeft'}); + fireEvent.keyUp(thumb0, {key: 'ArrowLeft'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([99]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([99]); }); it('can be moved with keys (vertical)', () => { @@ -399,18 +448,64 @@ describe('useSliderThumb', () => { // Drag thumb let thumb0 = screen.getByTestId('thumb').firstChild; fireEvent.keyDown(thumb0, {key: 'ArrowRight'}); + fireEvent.keyUp(thumb0, {key: 'ArrowRight'}); expect(onChangeSpy).toHaveBeenLastCalledWith([11]); expect(onChangeSpy).toHaveBeenCalledTimes(1); fireEvent.keyDown(thumb0, {key: 'ArrowUp'}); + fireEvent.keyUp(thumb0, {key: 'ArrowUp'}); expect(onChangeSpy).toHaveBeenLastCalledWith([12]); expect(onChangeSpy).toHaveBeenCalledTimes(2); fireEvent.keyDown(thumb0, {key: 'ArrowDown'}); + fireEvent.keyUp(thumb0, {key: 'ArrowDown'}); expect(onChangeSpy).toHaveBeenLastCalledWith([11]); expect(onChangeSpy).toHaveBeenCalledTimes(3); fireEvent.keyDown(thumb0, {key: 'ArrowLeft'}); + fireEvent.keyUp(thumb0, {key: 'ArrowLeft'}); expect(onChangeSpy).toHaveBeenLastCalledWith([10]); expect(onChangeSpy).toHaveBeenCalledTimes(4); }); + + it('can be moved with keys (vertical) at the bottom of the slider', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb + let thumb0 = screen.getByTestId('thumb').firstChild; + fireEvent.keyDown(thumb0, {key: 'ArrowDown'}); + fireEvent.keyUp(thumb0, {key: 'ArrowDown'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).toHaveBeenCalledWith([0]); + + fireEvent.keyDown(thumb0, {key: 'ArrowUp'}); + fireEvent.keyUp(thumb0, {key: 'ArrowUp'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([1]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([1]); + }); + + it('can be moved with keys (vertical) at the top of the slider', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Drag thumb + let thumb0 = screen.getByTestId('thumb').firstChild; + fireEvent.keyDown(thumb0, {key: 'ArrowUp'}); + fireEvent.keyUp(thumb0, {key: 'ArrowUp'}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).toHaveBeenCalledWith([100]); + + fireEvent.keyDown(thumb0, {key: 'ArrowDown'}); + fireEvent.keyUp(thumb0, {key: 'ArrowDown'}); + expect(onChangeSpy).toHaveBeenLastCalledWith([99]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([99]); + }); }); }); }); diff --git a/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx index 36f8e0995c9..918273d4c70 100644 --- a/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx +++ b/packages/@react-spectrum/slider/stories/RangeSlider.stories.tsx @@ -71,6 +71,10 @@ storiesOf('Slider/RangeSlider', module) .add( 'min/max', () => render({label: 'Label', minValue: 30, maxValue: 70}) + ) + .add( + 'pageSize', + () => render({label: 'Label', minValue: 0, maxValue: 360, pageSize: 15, formatOptions: {style: 'unit', unit: 'degree', unitDisplay: 'narrow'}}) ); function render(props: SpectrumRangeSliderProps = {}) { @@ -79,5 +83,10 @@ function render(props: SpectrumRangeSliderProps = {}) { action('change')(v.start, v.end); }; } + if (props.onChangeEnd == null) { + props.onChangeEnd = (v) => { + action('changeEnd')(v.start, v.end); + }; + } return ; } diff --git a/packages/@react-spectrum/slider/stories/Slider.stories.tsx b/packages/@react-spectrum/slider/stories/Slider.stories.tsx index d0f8cc4c3d9..5ece93e5a53 100644 --- a/packages/@react-spectrum/slider/stories/Slider.stories.tsx +++ b/packages/@react-spectrum/slider/stories/Slider.stories.tsx @@ -96,6 +96,10 @@ storiesOf('Slider', module) 'step', () => render({label: 'Label', minValue: 0, maxValue: 100, step: 5}) ) + .add( + 'pageSize', + () => render({label: 'Label', minValue: 0, maxValue: 360, pageSize: 15, formatOptions: {style: 'unit', unit: 'degree', unitDisplay: 'narrow'}}) + ) .add( 'isFilled: true', () => render({label: 'Label', isFilled: true}) @@ -121,5 +125,8 @@ function render(props: SpectrumSliderProps = {}) { if (props.onChange == null) { props.onChange = action('change'); } + if (props.onChangeEnd == null) { + props.onChangeEnd = action('changeEnd'); + } return ; } diff --git a/packages/@react-spectrum/slider/test/Slider.test.tsx b/packages/@react-spectrum/slider/test/Slider.test.tsx index 4f0828cb6e4..a038fbc95cc 100644 --- a/packages/@react-spectrum/slider/test/Slider.test.tsx +++ b/packages/@react-spectrum/slider/test/Slider.test.tsx @@ -207,13 +207,17 @@ describe('Slider', function () { }); describe('keyboard interactions', () => { - // Can't test arrow/page up/down, home/end arrows because they are handled by the browser and JSDOM doesn't feel like it. - it.each` Name | props | commands ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}]} ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}]} ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}]} + ${'(up/down arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowUp, result: +1}, {left: press.ArrowDown, result: -1}]} + ${'(up/down arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowUp, result: +1}, {left: press.ArrowDown, result: -1}]} + ${'(page up/down, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.PageUp, result: +10}, {left: press.PageDown, result: -10}]} + ${'(page up/down, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.PageUp, result: +10}, {left: press.PageDown, result: -10}]} + ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.Home, result: -50}, {left: press.End, result: +100}]} + ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.Home, result: -50}, {left: press.End, result: +100}]} `('$Name moves the slider in the correct direction', function ({props, commands}) { let tree = render( @@ -224,14 +228,53 @@ describe('Slider', function () { testKeypresses([slider, slider], commands); }); + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}]} + ${'(left/right arrows, isDisabled)'} | ${{locale: 'de-DE', isDisabled: true}}| ${[{left: press.ArrowRight, result: 0}, {left: press.ArrowLeft, result: 0}]} + ${'(up/down arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowUp, result: +1}, {left: press.ArrowDown, result: -1}]} + ${'(up/down arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowUp, result: +1}, {left: press.ArrowDown, result: -1}]} + ${'(page up/down, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.PageUp, result: +10}, {left: press.PageDown, result: -10}]} + ${'(page up/down, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.PageUp, result: +10}, {left: press.PageDown, result: -10}]} + ${'(home/end, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.Home, result: -50}, {left: press.End, result: +100}]} + ${'(home/end, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.Home, result: -50}, {left: press.End, result: +100}]} + `('$Name moves the slider in the correct direction orientation vertical', function ({props, commands}) { + let tree = render( + + + + ); + let slider = tree.getByRole('slider'); + testKeypresses([slider, slider], commands); + }); + it.each` Name | props | commands - ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +10}, {left: press.ArrowLeft, result: -10}]} - ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -10}, {left: press.ArrowLeft, result: +10}]} + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +20}, {left: press.ArrowLeft, result: -20}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -20}, {left: press.ArrowLeft, result: +20}]} + ${'(page up/down, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.PageUp, result: +20}, {left: press.PageDown, result: -20}]} + ${'(page up/down, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.PageUp, result: +20}, {left: press.PageDown, result: -20}]} `('$Name respects the step size', function ({props, commands}) { let tree = render( - + + + ); + let slider = tree.getByRole('slider'); + testKeypresses([slider, slider], commands); + }); + + it.each` + Name | props | commands + ${'(left/right arrows, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.ArrowRight, result: +1}, {left: press.ArrowLeft, result: -1}]} + ${'(left/right arrows, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.ArrowRight, result: -1}, {left: press.ArrowLeft, result: +1}]} + ${'(page up/down, ltr)'} | ${{locale: 'de-DE'}} | ${[{left: press.PageUp, result: +20}, {left: press.PageDown, result: -20}]} + ${'(page up/down, rtl)'} | ${{locale: 'ar-AE'}} | ${[{left: press.PageUp, result: +20}, {left: press.PageDown, result: -20}]} + `('$Name respects the page size', function ({props, commands}) { + let tree = render( + + ); let slider = tree.getByRole('slider'); diff --git a/packages/@react-spectrum/slider/test/utils.ts b/packages/@react-spectrum/slider/test/utils.ts index 9419dbbdf1f..0fe322f77ab 100644 --- a/packages/@react-spectrum/slider/test/utils.ts +++ b/packages/@react-spectrum/slider/test/utils.ts @@ -14,12 +14,17 @@ import {act, fireEvent} from '@testing-library/react'; function pressKeyOnButton(key, button) { fireEvent.keyDown(button, {key}); + fireEvent.keyUp(button, {key}); } export const press = { ArrowRight: (button: HTMLElement) => pressKeyOnButton('ArrowRight', button), ArrowLeft: (button: HTMLElement) => pressKeyOnButton('ArrowLeft', button), + ArrowUp: (button: HTMLElement) => pressKeyOnButton('ArrowUp', button), + ArrowDown: (button: HTMLElement) => pressKeyOnButton('ArrowDown', button), Home: (button: HTMLElement) => pressKeyOnButton('Home', button), - End: (button: HTMLElement) => pressKeyOnButton('End', button) + End: (button: HTMLElement) => pressKeyOnButton('End', button), + PageUp: (button: HTMLElement) => pressKeyOnButton('PageUp', button), + PageDown: (button: HTMLElement) => pressKeyOnButton('PageDown', button) }; export function testKeypresses([sliderLeft, sliderRight], commands: any[]) { diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts index 15686e46ddb..64ac5262973 100644 --- a/packages/@react-stately/slider/src/useSliderState.ts +++ b/packages/@react-stately/slider/src/useSliderState.ts @@ -119,10 +119,24 @@ export interface SliderState { */ setThumbEditable(index: number, editable: boolean): void, + /** + * Increments the value of the thumb by the step or page amount. + */ + incrementThumb(index: number, stepSize?: number): void, + /** + * Decrements the value of the thumb by the step or page amount. + */ + decrementThumb(index: number, stepSize?: number): void, + /** * The step amount for the slider. */ - readonly step: number + readonly step: number, + + /** + * The page size for the slider, used to do a bigger step. + */ + readonly pageSize: number } const DEFAULT_MIN_VALUE = 0; @@ -140,7 +154,14 @@ interface SliderStateOptions extends SliderProps { * @param props */ export function useSliderState(props: SliderStateOptions): SliderState { - const {isDisabled, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, numberFormatter: formatter, step = DEFAULT_STEP_VALUE} = props; + const { + isDisabled, + minValue = DEFAULT_MIN_VALUE, + maxValue = DEFAULT_MAX_VALUE, + numberFormatter: formatter, + step = DEFAULT_STEP_VALUE, + pageSize = Math.max((maxValue - minValue) / 10, step) + } = props; const [values, setValues] = useControlledState( props.value as any, @@ -220,6 +241,16 @@ export function useSliderState(props: SliderStateOptions): SliderState { return clamp(getRoundedValue(val), minValue, maxValue); } + function incrementThumb(index: number, stepSize: number = 1) { + let s = Math.max(stepSize, step); + updateValue(index, snapValueToStep(values[index] + s, minValue, maxValue, step)); + } + + function decrementThumb(index: number, stepSize: number = 1) { + let s = Math.max(stepSize, step); + updateValue(index, snapValueToStep(values[index] - s, minValue, maxValue, step)); + } + return { values: values, getThumbValue: (index: number) => values[index], @@ -238,7 +269,10 @@ export function useSliderState(props: SliderStateOptions): SliderState { getPercentValue, isThumbEditable, setThumbEditable, - step + incrementThumb, + decrementThumb, + step, + pageSize }; } diff --git a/packages/@react-types/slider/src/index.d.ts b/packages/@react-types/slider/src/index.d.ts index 146232518f4..ceeed40627d 100644 --- a/packages/@react-types/slider/src/index.d.ts +++ b/packages/@react-types/slider/src/index.d.ts @@ -39,7 +39,12 @@ export interface SliderProps extends RangeInputBase, Value * The slider's step value. * @default 1 */ - step?: number + step?: number, + /** + * The slider's page step value, used when incrementing and decrementing. + * @default 1 + */ + pageSize?: number } export interface SliderThumbProps extends FocusableProps, Validation, LabelableProps {