diff --git a/packages/@react-aria/slider/docs/useSlider.mdx b/packages/@react-aria/slider/docs/useSlider.mdx index ecdd4728e66..b4eda2dbc04 100644 --- a/packages/@react-aria/slider/docs/useSlider.mdx +++ b/packages/@react-aria/slider/docs/useSlider.mdx @@ -305,6 +305,22 @@ function RangeSlider(props) { The following examples show how to use the `Slider` and `RangeSlider` components created in the above examples. +### Swap multi thumb + +By default, thumbs cannot pass through each other. +In order to allow the thumb to pass through each other, you can use the `allowSwap` prop. + +```tsx example export=true + +``` + ### Vertical orientation Sliders are horizontally oriented by default. The `orientation` prop can be set to `"vertical"` to create a vertical slider. diff --git a/packages/@react-aria/slider/src/useSlider.ts b/packages/@react-aria/slider/src/useSlider.ts index 5bdbc253415..36a46df86e8 100644 --- a/packages/@react-aria/slider/src/useSlider.ts +++ b/packages/@react-aria/slider/src/useSlider.ts @@ -13,13 +13,14 @@ import {AriaSliderProps} from '@react-types/slider'; import {clamp, mergeProps, useGlobalListeners} from '@react-aria/utils'; import {DOMAttributes} from '@react-types/shared'; -import {getSliderThumbId, sliderData} from './utils'; +import {getSliderThumbId, getStuckThumbsIndexes, sliderData} from './utils'; import React, {LabelHTMLAttributes, OutputHTMLAttributes, RefObject, useRef} from 'react'; import {setInteractionModality, useMove} from '@react-aria/interactions'; import {SliderState} from '@react-stately/slider'; import {useLabel} from '@react-aria/label'; import {useLocale} from '@react-aria/i18n'; + export interface SliderAria { /** Props for the label element. */ labelProps: LabelHTMLAttributes, @@ -70,51 +71,129 @@ export function useSlider( // It is set onMouseDown/onTouchDown; see trackProps below. const realTimeTrackDraggingIndex = useRef(null); + const isBeingStuckBeforeDragging = useRef(undefined); + const reverseX = direction === 'rtl'; - const currentPosition = useRef(null); + const currentPosition = useRef(null); const {moveProps} = useMove({ onMoveStart() { currentPosition.current = null; }, onMove({deltaX, deltaY}) { + const { + getThumbPercent, + getThumbValue, + getPercentValue, + isThumbEditable, + setThumbPercent, + setThumbDragging, + setFocusedThumb, + swapEnabled + } = state; + let {height, width} = trackRef.current.getBoundingClientRect(); let size = isVertical ? height : width; + let controlledThumbIndex = realTimeTrackDraggingIndex.current; + if (currentPosition.current == null) { - currentPosition.current = state.getThumbPercent(realTimeTrackDraggingIndex.current) * size; + currentPosition.current = getThumbPercent(controlledThumbIndex) * size; } let delta = isVertical ? deltaY : deltaX; + if (isVertical || reverseX) { delta = -delta; } currentPosition.current += delta; - if (realTimeTrackDraggingIndex.current != null && trackRef.current) { - const percent = clamp(currentPosition.current / size, 0, 1); - state.setThumbPercent(realTimeTrackDraggingIndex.current, percent); + const percent = clamp(currentPosition.current / size, 0, 1); + + const value = getThumbValue(controlledThumbIndex); + + const isValueMustBeDecreasing = getPercentValue(percent) < value; + const isValueMustBeChanged = getPercentValue(percent) !== value; + + const stuckThumbsIndexes = getStuckThumbsIndexes(state, controlledThumbIndex); + const isNeededToSwap = stuckThumbsIndexes !== null; + + if (isNeededToSwap && isValueMustBeChanged) { + const possibleIndexesForSwap = stuckThumbsIndexes.filter((i) => + isValueMustBeDecreasing + ? i < controlledThumbIndex && isThumbEditable(i) + : i > controlledThumbIndex && isThumbEditable(i) + ); + + // Select the most initial thumb or the most recent one from the array of stuck ones + // (depending on the increase or decrease in value) + // so that the order of thumbs works correctly + const indexForSwap = isValueMustBeDecreasing + ? possibleIndexesForSwap[0] + : possibleIndexesForSwap[possibleIndexesForSwap.length - 1]; + + if (indexForSwap !== undefined && swapEnabled) { + controlledThumbIndex = indexForSwap; + } + + // This allows to select the controlled thumb once and when it gets stuck again + // as you move in other thumbs, then control will remain over it. + if (!swapEnabled && isBeingStuckBeforeDragging.current) { + controlledThumbIndex = indexForSwap ?? controlledThumbIndex; + isBeingStuckBeforeDragging.current = false; + } + } + + if ( + realTimeTrackDraggingIndex.current !== null && + realTimeTrackDraggingIndex.current !== controlledThumbIndex + ) { + setThumbDragging(controlledThumbIndex, true); + setThumbDragging(realTimeTrackDraggingIndex.current, false); + + setFocusedThumb(controlledThumbIndex); + + realTimeTrackDraggingIndex.current = controlledThumbIndex; + } + + if (controlledThumbIndex !== null && trackRef.current && isValueMustBeChanged) { + setThumbPercent(controlledThumbIndex, percent); } }, onMoveEnd() { - if (realTimeTrackDraggingIndex.current != null) { - state.setThumbDragging(realTimeTrackDraggingIndex.current, false); + const controlledThumbIndex = realTimeTrackDraggingIndex.current; + + if (controlledThumbIndex !== null) { realTimeTrackDraggingIndex.current = null; + isBeingStuckBeforeDragging.current = undefined; + + state.setThumbDragging(controlledThumbIndex, false); } } }); let currentPointer = useRef(undefined); - let onDownTrack = (e: React.UIEvent, id: number, clientX: number, clientY: number) => { + let onDownTrack = ( + e: React.UIEvent, + id: number, + clientX: number, + clientY: number + ) => { // We only trigger track-dragging if the user clicks on the track itself and nothing is currently being dragged. - if (trackRef.current && !props.isDisabled && state.values.every((_, i) => !state.isThumbDragging(i))) { - let {height, width, top, left} = trackRef.current.getBoundingClientRect(); + if ( + trackRef.current && + !props.isDisabled && + !state.values.some((_, i) => state.isThumbDragging(i)) + ) { + let {height, width, top, left} = + trackRef.current.getBoundingClientRect(); let size = isVertical ? height : width; // Find the closest thumb const trackPosition = isVertical ? top : left; const clickPosition = isVertical ? clientY : clientX; const offset = clickPosition - trackPosition; let percent = offset / size; + if (direction === 'rtl' || isVertical) { percent = 1 - percent; } @@ -122,14 +201,18 @@ export function useSlider( // to find the closet thumb we split the array based on the first thumb position to the "right/end" of the click. let closestThumb; - let split = state.values.findIndex(v => value - v < 0); - if (split === 0) { // If the index is zero then the closetThumb is the first one + let split = state.values.findIndex((v) => value - v < 0); + + if (split === 0) { + // If the index is zero then the closetThumb is the first one closestThumb = split; - } else if (split === -1) { // If no index is found they've clicked past all the thumbs + } else if (split === -1) { + // If no index is found they've clicked past all the thumbs closestThumb = state.values.length - 1; } else { let lastLeft = state.values[split - 1]; let firstRight = state.values[split]; + // Pick the last left/start thumb, unless they are stacked on top of each other, then pick the right/end one if (Math.abs(lastLeft - value) < Math.abs(firstRight - value)) { closestThumb = split - 1; @@ -142,11 +225,13 @@ export function useSlider( if (closestThumb >= 0 && state.isThumbEditable(closestThumb)) { // Don't unfocus anything e.preventDefault(); - realTimeTrackDraggingIndex.current = closestThumb; state.setFocusedThumb(closestThumb); currentPointer.current = id; + isBeingStuckBeforeDragging.current = + getStuckThumbsIndexes(state, realTimeTrackDraggingIndex.current) !== null; + state.setThumbDragging(realTimeTrackDraggingIndex.current, true); state.setThumbValue(closestThumb, value); @@ -155,16 +240,20 @@ export function useSlider( addGlobalListener(window, 'pointerup', onUpTrack, false); } else { realTimeTrackDraggingIndex.current = null; + isBeingStuckBeforeDragging.current = undefined; } } }; let onUpTrack = (e) => { let id = e.pointerId ?? e.changedTouches?.[0].identifier; + if (id === currentPointer.current) { if (realTimeTrackDraggingIndex.current != null) { state.setThumbDragging(realTimeTrackDraggingIndex.current, false); realTimeTrackDraggingIndex.current = null; + + isBeingStuckBeforeDragging.current = undefined; } removeGlobalListener(window, 'mouseup', onUpTrack, false); @@ -196,27 +285,43 @@ export function useSlider( role: 'group', ...fieldProps }, - trackProps: mergeProps({ - onMouseDown(e: React.MouseEvent) { - if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { - return; - } - onDownTrack(e, undefined, e.clientX, e.clientY); - }, - onPointerDown(e: React.PointerEvent) { - if (e.pointerType === 'mouse' && (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)) { - return; + trackProps: mergeProps( + { + onMouseDown(e: React.MouseEvent) { + if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { + return; + } + + onDownTrack(e, undefined, e.clientX, e.clientY); + }, + onPointerDown(e: React.PointerEvent) { + if ( + e.pointerType === 'mouse' && + (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) + ) { + return; + } + onDownTrack(e, e.pointerId, e.clientX, e.clientY); + }, + onTouchStart(e: React.TouchEvent) { + onDownTrack( + e, + e.changedTouches[0].identifier, + e.changedTouches[0].clientX, + e.changedTouches[0].clientY + ); + }, + style: { + position: 'relative', + touchAction: 'none' } - onDownTrack(e, e.pointerId, e.clientX, e.clientY); }, - onTouchStart(e: React.TouchEvent) { onDownTrack(e, e.changedTouches[0].identifier, e.changedTouches[0].clientX, e.changedTouches[0].clientY); }, - style: { - position: 'relative', - touchAction: 'none' - } - }, moveProps), + moveProps + ), outputProps: { - htmlFor: state.values.map((_, index) => getSliderThumbId(state, index)).join(' '), + htmlFor: state.values + .map((_, index) => getSliderThumbId(state, index)) + .join(' '), 'aria-live': 'off' } }; diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index c42402db655..f5dfaacfb86 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -1,7 +1,7 @@ import {AriaSliderThumbProps} from '@react-types/slider'; import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils'; import {DOMAttributes} from '@react-types/shared'; -import {getSliderThumbId, sliderData} from './utils'; +import {getSliderThumbId, getStuckThumbsIndexes, sliderData} from './utils'; import React, {ChangeEvent, InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useEffect, useRef} from 'react'; import {SliderState} from '@react-stately/slider'; import {useFocusable} from '@react-aria/focus'; @@ -40,10 +40,7 @@ export interface AriaSliderThumbOptions extends AriaSliderThumbProps { * @param opts Options for this Slider thumb. * @param state Slider state, created via `useSliderState`. */ -export function useSliderThumb( - opts: AriaSliderThumbOptions, - state: SliderState -): SliderThumbAria { +export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState): SliderThumbAria { let { index = 0, isRequired, @@ -87,82 +84,216 @@ export function useSliderThumb( let reverseX = direction === 'rtl'; let currentPosition = useRef(null); + const realTimeThumbDraggingIndex = useRef(null); + const isCanBeSwapped = useRef(undefined); + let {keyboardProps} = useKeyboard({ onKeyDown(e) { let { getThumbMaxValue, getThumbMinValue, - decrementThumb, - incrementThumb, setThumbValue, setThumbDragging, - pageSize + setFocusedThumb, + decrementThumb, + incrementThumb, + isThumbEditable, + pageSize, + swapEnabled } = state; + // 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); + + let controlledThumbIndex = index; + realTimeThumbDraggingIndex.current = index; + + setThumbDragging(controlledThumbIndex, true); + + if (isCanBeSwapped.current === undefined) { + isCanBeSwapped.current = getStuckThumbsIndexes(state, index) !== null; + } + + const stuckThumbsIndexes = getStuckThumbsIndexes(state, controlledThumbIndex); + const isValueMustBeDecreasing = (e.key === 'PageDown') || (e.key === 'Home'); + + const isNeededToSwap = stuckThumbsIndexes !== null; + + if (isNeededToSwap) { + const possibleIndexesForSwap = stuckThumbsIndexes.filter((i) => + isValueMustBeDecreasing + ? i < controlledThumbIndex && isThumbEditable(i) + : i > controlledThumbIndex && isThumbEditable(i) + ); + + const indexForSwap = isValueMustBeDecreasing + ? possibleIndexesForSwap[0] + : possibleIndexesForSwap[possibleIndexesForSwap.length - 1]; + + if (indexForSwap !== undefined && swapEnabled) { + controlledThumbIndex = indexForSwap; + } + + if (!swapEnabled && isCanBeSwapped.current) { + controlledThumbIndex = indexForSwap ?? realTimeThumbDraggingIndex.current; + isCanBeSwapped.current = false; + } + } + switch (e.key) { case 'PageUp': - incrementThumb(index, pageSize); + incrementThumb(controlledThumbIndex, pageSize); break; case 'PageDown': - decrementThumb(index, pageSize); + decrementThumb(controlledThumbIndex, pageSize); break; case 'Home': - setThumbValue(index, getThumbMinValue(index)); + setThumbValue(controlledThumbIndex, getThumbMinValue(controlledThumbIndex)); break; case 'End': - setThumbValue(index, getThumbMaxValue(index)); + setThumbValue(controlledThumbIndex, getThumbMaxValue(controlledThumbIndex)); break; } - setThumbDragging(index, false); + + if ( + realTimeThumbDraggingIndex.current !== controlledThumbIndex + ) { + isCanBeSwapped.current = undefined; + + setFocusedThumb(controlledThumbIndex); + setThumbDragging(realTimeThumbDraggingIndex.current, false); + } + + realTimeThumbDraggingIndex.current = null; + setThumbDragging(controlledThumbIndex, false); } }); let {moveProps} = useMove({ onMoveStart() { currentPosition.current = null; - state.setThumbDragging(index, true); + + if (isCanBeSwapped.current === undefined) { + isCanBeSwapped.current = getStuckThumbsIndexes(state, index) !== null; + } + + if (realTimeThumbDraggingIndex.current === null) { + realTimeThumbDraggingIndex.current = index; + state.setThumbDragging(index, true); + } }, onMove({deltaX, deltaY, pointerType, shiftKey}) { const { getThumbPercent, + getPercentValue, + getThumbValue, setThumbPercent, + setFocusedThumb, + setThumbDragging, decrementThumb, incrementThumb, + isThumbEditable, step, - pageSize + pageSize, + swapEnabled } = state; let {width, height} = trackRef.current.getBoundingClientRect(); let size = isVertical ? height : width; + let controlledThumbIndex = realTimeThumbDraggingIndex.current; + if (currentPosition.current == null) { - currentPosition.current = getThumbPercent(index) * size; + currentPosition.current = getThumbPercent(controlledThumbIndex) * size; } + + let isValueMustBeDecreasing = false; + let isValueMustBeChanged = false; + if (pointerType === 'keyboard') { - if ((deltaX > 0 && reverseX) || (deltaX < 0 && !reverseX) || deltaY > 0) { - decrementThumb(index, shiftKey ? pageSize : step); - } else { - incrementThumb(index, shiftKey ? pageSize : step); - } + isValueMustBeChanged = true; + isValueMustBeDecreasing = + (deltaX > 0 && reverseX) || (deltaX < 0 && !reverseX) || deltaY > 0; } else { let delta = isVertical ? deltaY : deltaX; + if (isVertical || reverseX) { delta = -delta; } currentPosition.current += delta; - setThumbPercent(index, clamp(currentPosition.current / size, 0, 1)); + + const percent = clamp(currentPosition.current / size, 0, 1); + + isValueMustBeChanged = getPercentValue(percent) !== getThumbValue(controlledThumbIndex); + isValueMustBeDecreasing = getPercentValue(percent) < getThumbValue(controlledThumbIndex); + } + + const stuckThumbsIndexes = getStuckThumbsIndexes(state, controlledThumbIndex); + const isNeededToSwap = stuckThumbsIndexes !== null; + + if (isNeededToSwap && isValueMustBeChanged) { + const possibleIndexesForSwap = stuckThumbsIndexes.filter((i) => + isValueMustBeDecreasing + ? i < controlledThumbIndex && isThumbEditable(i) + : i > controlledThumbIndex && isThumbEditable(i) + ); + + const indexForSwap = isValueMustBeDecreasing + ? possibleIndexesForSwap[0] + : possibleIndexesForSwap[possibleIndexesForSwap.length - 1]; + + if (indexForSwap !== undefined && swapEnabled) { + controlledThumbIndex = indexForSwap; + } + + if (!swapEnabled && isCanBeSwapped.current) { + controlledThumbIndex = indexForSwap ?? realTimeThumbDraggingIndex.current; + isCanBeSwapped.current = false; + } + } + + if ( + realTimeThumbDraggingIndex.current !== null && + realTimeThumbDraggingIndex.current !== controlledThumbIndex + ) { + // The order matters because in the case of an empty array, + // an event (onChangeEnd) will be prematurely called + setThumbDragging(controlledThumbIndex, true); + setThumbDragging(realTimeThumbDraggingIndex.current, false); + + setFocusedThumb(controlledThumbIndex); + realTimeThumbDraggingIndex.current = controlledThumbIndex; + } + + if (pointerType === 'keyboard') { + if (isValueMustBeDecreasing) { + decrementThumb(controlledThumbIndex, shiftKey ? pageSize : step); + } else { + incrementThumb(controlledThumbIndex, shiftKey ? pageSize : step); + } + } + + console.log('move', isValueMustBeChanged); + + if (pointerType !== 'keyboard' && isValueMustBeChanged) { + setThumbPercent(controlledThumbIndex, clamp(currentPosition.current / size, 0, 1)); } }, - onMoveEnd() { - state.setThumbDragging(index, false); + onMoveEnd({pointerType}) { + if (pointerType !== 'keyboard') { + isCanBeSwapped.current = undefined; + } + + if (realTimeThumbDraggingIndex.current !== null) { + state.setThumbDragging(realTimeThumbDraggingIndex.current, false); + realTimeThumbDraggingIndex.current = null; + } } }); @@ -181,19 +312,30 @@ export function useSliderThumb( let onDown = (id?: number) => { focusInput(); currentPointer.current = id; - state.setThumbDragging(index, true); + realTimeThumbDraggingIndex.current = index; + isCanBeSwapped.current = getStuckThumbsIndexes(state, index) !== null; + + state.setThumbDragging(index, true); + addGlobalListener(window, 'mouseup', onUp, false); addGlobalListener(window, 'touchend', onUp, false); addGlobalListener(window, 'pointerup', onUp, false); - }; let onUp = (e) => { let id = e.pointerId ?? e.changedTouches?.[0].identifier; + if (id === currentPointer.current) { - focusInput(); - state.setThumbDragging(index, false); + if (realTimeThumbDraggingIndex.current !== null) { + focusInput(); + + state.setThumbDragging(realTimeThumbDraggingIndex.current, false); + + realTimeThumbDraggingIndex.current = null; + isCanBeSwapped.current = undefined; + } + removeGlobalListener(window, 'mouseup', onUp, false); removeGlobalListener(window, 'touchend', onUp, false); removeGlobalListener(window, 'pointerup', onUp, false); @@ -201,13 +343,14 @@ export function useSliderThumb( }; let thumbPosition = state.getThumbPercent(index); + if (isVertical || direction === 'rtl') { thumbPosition = 1 - thumbPosition; } let interactions = !isDisabled ? mergeProps( - keyboardProps, - moveProps, + keyboardProps, + moveProps, { onMouseDown: (e: React.MouseEvent) => { if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { @@ -221,9 +364,11 @@ export function useSliderThumb( } onDown(e.pointerId); }, - onTouchStart: (e: React.TouchEvent) => {onDown(e.changedTouches[0].identifier);} - } - ) : {}; + onTouchStart: (e: React.TouchEvent) => { + onDown(e.changedTouches[0].identifier); + } + }) + : {}; useFormReset(inputRef, value, (v) => { state.setThumbValue(index, v); @@ -248,7 +393,9 @@ export function useSliderThumb( 'aria-required': isRequired || undefined, 'aria-invalid': isInvalid || validationState === 'invalid' || undefined, 'aria-errormessage': opts['aria-errormessage'], - 'aria-describedby': [data['aria-describedby'], opts['aria-describedby']].filter(Boolean).join(' '), + 'aria-describedby': [data['aria-describedby'], opts['aria-describedby']] + .filter(Boolean) + .join(' '), 'aria-details': [data['aria-details'], opts['aria-details']].filter(Boolean).join(' '), onChange: (e: ChangeEvent) => { state.setThumbValue(index, parseFloat(e.target.value)); diff --git a/packages/@react-aria/slider/src/utils.ts b/packages/@react-aria/slider/src/utils.ts index 18ccf42b1e5..140f89406be 100644 --- a/packages/@react-aria/slider/src/utils.ts +++ b/packages/@react-aria/slider/src/utils.ts @@ -16,3 +16,21 @@ export function getSliderThumbId(state: SliderState, index: number) { return `${data.id}-${index}`; } + + /** + * Returns an array of thumbs indexes where the current thumb is stuck or null there are none. + * + * @param state Slider state. + * @param index Thumb index. + */ +export function getStuckThumbsIndexes(state: SliderState, index: number): number[] | null { + const stuckThumbsIndexes = state.values.reduce((acc, value, i) => { + if (value === state.values[index] && i !== index) { + acc.push(i); + } + + return acc; + }, [] as number[]); + + return stuckThumbsIndexes.length !== 0 ? stuckThumbsIndexes : null; +} diff --git a/packages/@react-aria/slider/stories/Slider.stories.tsx b/packages/@react-aria/slider/stories/Slider.stories.tsx index ea58e39c1ba..533789f8eca 100644 --- a/packages/@react-aria/slider/stories/Slider.stories.tsx +++ b/packages/@react-aria/slider/stories/Slider.stories.tsx @@ -140,7 +140,7 @@ _3ThumbsWithDisabled.story = { export const _8ThumbsWithDisabled = () => ( @@ -174,3 +174,39 @@ export const _3ThumbsWithAriaLabel = () => ( _3ThumbsWithAriaLabel.story = { name: '3 thumbs with aria-label' }; + +export const _4StackedThumbsWithDisabledSwap = () => ( + + + + + + +); + +_4StackedThumbsWithDisabledSwap.story = { + name: '4 stacked thumbs with disabled swap' +}; + +export const _4StackedThumbsWithEnabledSwap = () => ( + + + + + + +); + +_4StackedThumbsWithEnabledSwap.story = { + name: '4 stacked thumbs with enabled swap' +}; diff --git a/packages/@react-aria/slider/test/useSliderThumb.test.js b/packages/@react-aria/slider/test/useSliderThumb.test.js index eefe094d5b6..1da4a1414df 100644 --- a/packages/@react-aria/slider/test/useSliderThumb.test.js +++ b/packages/@react-aria/slider/test/useSliderThumb.test.js @@ -172,31 +172,6 @@ describe('useSliderThumb', () => { expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); expect(stateRef.current.values).toEqual([40, 80]); - - onChangeSpy.mockClear(); - onChangeEndSpy.mockClear(); - - // Drag thumb1 past thumb0 - let thumb1 = screen.getByTestId('thumb1'); - fireEvent.pointerDown(thumb1, {clientX: 80, pageX: 80}); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 80]); - - fireEvent.pointerMove(thumb1, {clientX: 60, pageX: 60}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 60]); - - fireEvent.pointerMove(thumb1, {clientX: 30, pageX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 40]); - - fireEvent.pointerUp(thumb1, {clientX: 30, pageX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 40]); - expect(stateRef.current.values).toEqual([40, 40]); }); }); describe('using MouseEvents', () => { @@ -227,31 +202,6 @@ describe('useSliderThumb', () => { expect(onChangeSpy).toHaveBeenLastCalledWith([40, 80]); expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 80]); expect(stateRef.current.values).toEqual([40, 80]); - - onChangeSpy.mockClear(); - onChangeEndSpy.mockClear(); - - // Drag thumb1 past thumb0 - let thumb1 = screen.getByTestId('thumb1'); - fireEvent.mouseDown(thumb1, {clientX: 80, pageX: 80}); - expect(onChangeSpy).not.toHaveBeenCalled(); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 80]); - - fireEvent.mouseMove(thumb1, {clientX: 60, pageX: 60}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 60]); - - fireEvent.mouseMove(thumb1, {clientX: 30, pageX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).not.toHaveBeenCalled(); - expect(stateRef.current.values).toEqual([40, 40]); - - fireEvent.mouseUp(thumb1, {clientX: 30, pageX: 30}); - expect(onChangeSpy).toHaveBeenLastCalledWith([40, 40]); - expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 40]); - expect(stateRef.current.values).toEqual([40, 40]); }); }); @@ -497,4 +447,553 @@ describe('useSliderThumb', () => { }); }); }); + + describe('interactions with 4 thumbs on track', () => { + let widthStub; + beforeAll(() => { + widthStub = jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100})); + }); + afterAll(() => { + widthStub.mockReset(); + }); + installMouseEvent(); + + let stateRef = React.createRef(); + + function RangeExample(props) { + let input0Ref = useRef(null); + let input1Ref = useRef(null); + let input2Ref = useRef(null); + let input3Ref = useRef(null); + + let trackRef = useRef(null); + + let state = useSliderState({...props, numberFormatter}); + stateRef.current = state; + + let {trackProps, thumbProps: commonThumbProps} = useSlider(props, state, trackRef); + + let {inputProps: input0Props, thumbProps: thumb0Props} = useSliderThumb({ + ...commonThumbProps, + 'aria-label': 'Min', + index: 0, + trackRef, + inputRef: input0Ref + }, state); + let {inputProps: input1Props, thumbProps: thumb1Props} = useSliderThumb({ + ...commonThumbProps, + index: 1, + trackRef, + inputRef: input1Ref + }, state); + + let {inputProps: input2Props, thumbProps: thumb2Props} = useSliderThumb({ + ...commonThumbProps, + index: 2, + trackRef, + inputRef: input2Ref + }, state); + let {inputProps: input3Props, thumbProps: thumb3Props} = useSliderThumb({ + ...commonThumbProps, + 'aria-label': 'Max', + index: 3, + trackRef, + inputRef: input3Ref + }, state); + + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); + } + + const defaultValue = [10, 30, 50, 70]; + + describe('thumbs swap is enabled', () => { + + describe('using PointerEvents', () => { + installPointerEvent(); + + it('can be swapped with the pointer', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + fireEvent.pointerDown(thumb3, {clientX: 70, pageX: 70}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 50, 70]); + + fireEvent.pointerMove(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + + fireEvent.pointerMove(thumb3, {clientX: 40, pageX: 40}); + fireEvent.pointerUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 40, 50]); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([10, 30, 40, 50]); + expect(onChangeEndSpy).toBeCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 40, 50]); + }); + + it('can be swapped thumbs when they get stuck in each other', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb4 = screen.getByTestId('thumb3'); + + // Choose the thumb 4 as it overlaps all the others + fireEvent.pointerDown(thumb4, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.pointerMove(thumb4, {clientX: 40, pageX: 40}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + fireEvent.pointerUp(thumb4, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + fireEvent.pointerDown(thumb4, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.pointerMove(thumb4, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + + fireEvent.pointerUp(thumb4, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + }); + }); + + describe('using MouseEvents', () => { + it('can be swapped', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + // Drag thumb3 + fireEvent.mouseDown(thumb3, {clientX: 70, pageX: 70}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + // it is important to swipe through the other thumb otherwise the swap will not work correctly + fireEvent.mouseMove(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + + // Here swap 3 and 2 + fireEvent.mouseMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 40, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 40, 50]); + + fireEvent.mouseUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 40, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([10, 30, 40, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 40, 50]); + }); + + it('can be swapped thumbs when they get stuck in each other', () => { + const defaultValue = [50, 50, 50, 50]; + + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + // Choose the thumb 4 as it overlaps all the others + fireEvent.mouseDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.mouseMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + fireEvent.mouseUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + fireEvent.mouseDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.mouseMove(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + + fireEvent.mouseUp(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + }); + }); + + describe('using KeyEvents', () => { + it('can be swapped', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([20, 30, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([20, 30, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([20, 30, 50, 70]); + + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([30, 30, 50, 70]); + + // Here thumbs with indexes 0 and 1 should be swap + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([30, 40, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeEndSpy).toHaveBeenCalledWith([30, 40, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(3); + expect(stateRef.current.values).toEqual([30, 40, 50, 70]); + }); + + it('can be swapped thumbs when they get stuck in each other', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Choose the third thumb + await user.tab(); + await user.tab(); + await user.tab(); + + // The first thumb should change as we move to the left + await user.keyboard('{ArrowLeft}'); + expect(onChangeSpy).toHaveBeenCalledWith([49, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([49, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([49, 50, 50, 50]); + + await user.keyboard('{ArrowLeft}'); + expect(onChangeSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([48, 50, 50, 50]); + + // Choose the second thumb + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([48, 50, 50, 51]); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeEndSpy).toHaveBeenCalledWith([48, 50, 50, 51]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(3); + expect(stateRef.current.values).toEqual([48, 50, 50, 51]); + }); + + }); + + }); + + describe('thumbs swap is disabled', () => { + + describe('using PointerEvents', () => { + installPointerEvent(); + + it('can not be swapped', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + fireEvent.pointerDown(thumb3, {clientX: 70, pageX: 70}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 50, 70]); + + fireEvent.pointerMove(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + + fireEvent.pointerMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenCalledWith([10, 30, 50, 50]); + + fireEvent.pointerUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeEndSpy).toBeCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + }); + + it('can be swapped thumbs when they get stuck in each other (only once)', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + // Choose the thumb 4 as it overlaps all the others + fireEvent.pointerDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.pointerMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + fireEvent.pointerUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + fireEvent.pointerDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.pointerMove(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + + fireEvent.pointerUp(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + }); + }); + + describe('using MouseEvents', () => { + it('can not be swapped', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + // Drag thumb3 + fireEvent.mouseDown(thumb3, {clientX: 70, pageX: 70}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + // it is important to swipe through the other thumb otherwise the swap will not work correctly + fireEvent.mouseMove(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + + // Here swap 3 and 2 + fireEvent.mouseMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + + fireEvent.mouseUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([10, 30, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([10, 30, 50, 50]); + }); + + it('can be swapped thumbs when they get stuck in each other (only once)', () => { + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + const thumb3 = screen.getByTestId('thumb3'); + + // Choose the thumb 4 as it overlaps all the others + fireEvent.mouseDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.mouseMove(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + fireEvent.mouseUp(thumb3, {clientX: 40, pageX: 40}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenLastCalledWith([40, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 50]); + + onChangeSpy.mockClear(); + onChangeEndSpy.mockClear(); + + fireEvent.mouseDown(thumb3, {clientX: 50, pageX: 50}); + expect(onChangeSpy).not.toHaveBeenCalled(); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + + fireEvent.mouseMove(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).not.toHaveBeenCalled(); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + + fireEvent.mouseUp(thumb3, {clientX: 60, pageX: 60}); + expect(onChangeSpy).toHaveBeenLastCalledWith([40, 50, 50, 60]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([40, 50, 50, 60]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([40, 50, 50, 60]); + }); + }); + + describe('using KeyEvents', () => { + it('can not be swapped', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([20, 30, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([20, 30, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([20, 30, 50, 70]); + + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([30, 30, 50, 70]); + + // Here thumbs with indexes 0 and 1 should be swap + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledWith([30, 30, 50, 70]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(3); + expect(stateRef.current.values).toEqual([30, 30, 50, 70]); + }); + + it('can be swapped thumbs when they get stuck in each other (only once)', async () => { + const defaultValue = [50, 50, 50, 50]; + let user = userEvent.setup({delay: null, pointerMap}); + + let onChangeSpy = jest.fn(); + let onChangeEndSpy = jest.fn(); + render(); + + // Choose the first thumb + await user.tab(); + + // The first thumb should change as we move to the left + await user.keyboard('{ArrowLeft}'); + expect(onChangeSpy).toHaveBeenCalledWith([49, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeEndSpy).toHaveBeenCalledWith([49, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(1); + expect(stateRef.current.values).toEqual([49, 50, 50, 50]); + + await user.keyboard('{ArrowLeft}'); + expect(onChangeSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(2); + expect(onChangeEndSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(2); + expect(stateRef.current.values).toEqual([48, 50, 50, 50]); + + // Choose the second thumb + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(onChangeSpy).toHaveBeenCalledWith([48, 50, 50, 51]); + expect(onChangeSpy).toHaveBeenCalledTimes(3); + expect(onChangeEndSpy).toHaveBeenCalledWith([48, 50, 50, 51]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(3); + expect(stateRef.current.values).toEqual([48, 50, 50, 51]); + + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowLeft}'); + expect(onChangeSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeSpy).toHaveBeenCalledTimes(4); + expect(onChangeEndSpy).toHaveBeenCalledWith([48, 50, 50, 50]); + expect(onChangeEndSpy).toHaveBeenCalledTimes(6); + expect(stateRef.current.values).toEqual([48, 50, 50, 50]); + }); + + }); + }); + }); }); diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts index dc6b489c2de..552b05a6a02 100644 --- a/packages/@react-stately/slider/src/useSliderState.ts +++ b/packages/@react-stately/slider/src/useSliderState.ts @@ -123,6 +123,7 @@ export interface SliderState { * 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. */ @@ -142,7 +143,10 @@ export interface SliderState { readonly orientation: Orientation, /** Whether the slider is disabled. */ - readonly isDisabled: boolean + readonly isDisabled: boolean, + + /** The behavior of swapping thumbs. */ + readonly swapEnabled: boolean } const DEFAULT_MIN_VALUE = 0; @@ -159,41 +163,50 @@ export interface SliderStateOptions extends SliderProps { * of any thumbs. * @param props */ -export function useSliderState(props: SliderStateOptions): SliderState { +export function useSliderState( + props: SliderStateOptions +): SliderState { const { isDisabled = false, minValue = DEFAULT_MIN_VALUE, maxValue = DEFAULT_MAX_VALUE, numberFormatter: formatter, step = DEFAULT_STEP_VALUE, - orientation = 'horizontal' + orientation = 'horizontal', + allowSwap = false } = props; // Page step should be at least equal to step and always a multiple of the step. let pageSize = useMemo(() => { let calcPageSize = (maxValue - minValue) / 10; + calcPageSize = snapValueToStep(calcPageSize, 0, calcPageSize + step, step); + return Math.max(calcPageSize, step); }, [step, maxValue, minValue]); - let restrictValues = useCallback((values: number[]) => values?.map((val, idx) => { - let min = idx === 0 ? minValue : val[idx - 1]; - let max = idx === values.length - 1 ? maxValue : val[idx + 1]; - return snapValueToStep(val, min, max, step); - }), [minValue, maxValue, step]); + let restrictValues = useCallback( + (values: number[]) => + values?.map((val, idx) => { + let min = idx === 0 ? minValue : val[idx - 1]; + let max = idx === values.length - 1 ? maxValue : val[idx + 1]; + + return snapValueToStep(val, min, max, step); + }), + [minValue, maxValue, step] + ); let value = useMemo(() => restrictValues(convertValue(props.value)), [props.value]); let defaultValue = useMemo(() => restrictValues(convertValue(props.defaultValue) ?? [minValue]), [props.defaultValue, minValue]); + let onChange = createOnChange(props.value, props.defaultValue, props.onChange); let onChangeEnd = createOnChange(props.value, props.defaultValue, props.onChangeEnd); - const [values, setValuesState] = useControlledState( - value, - defaultValue, - onChange - ); + const [values, setValuesState] = useControlledState(value, defaultValue, onChange); const [isDraggings, setDraggingsState] = useState(new Array(values.length).fill(false)); + const isEditablesRef = useRef(new Array(values.length).fill(true)); + const [focusedIndex, setFocusedIndex] = useState(undefined); const valuesRef = useRef(values); @@ -229,15 +242,20 @@ export function useSliderState(props: SliderStateOp } function updateValue(index: number, value: number) { - if (isDisabled || !isThumbEditable(index)) { + let controlIndex = index; + + if (isDisabled || !isThumbEditable(controlIndex)) { return; } - const thisMin = getThumbMinValue(index); - const thisMax = getThumbMaxValue(index); + + const thisMin = getThumbMinValue(controlIndex); + const thisMax = getThumbMaxValue(controlIndex); // Round value to multiple of step, clamp value between min and max value = snapValueToStep(value, thisMin, thisMax, step); - let newValues = replaceIndex(valuesRef.current, index, value); + + let newValues = replaceIndex(valuesRef.current, controlIndex, value); + setValues(newValues); } @@ -250,7 +268,12 @@ export function useSliderState(props: SliderStateOp } const wasDragging = isDraggingsRef.current[index]; - isDraggingsRef.current = replaceIndex(isDraggingsRef.current, index, dragging); + + isDraggingsRef.current = replaceIndex( + isDraggingsRef.current, + index, + dragging + ); setDraggings(isDraggingsRef.current); // Call onChangeEnd if no handles are dragging. @@ -273,17 +296,26 @@ export function useSliderState(props: SliderStateOp function getPercentValue(percent: number) { const val = percent * (maxValue - minValue) + minValue; + 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)); + + 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)); + + updateValue( + index, + snapValueToStep(values[index] - s, minValue, maxValue, step) + ); } return { @@ -309,7 +341,8 @@ export function useSliderState(props: SliderStateOp step, pageSize, orientation, - isDisabled + isDisabled, + swapEnabled: allowSwap }; } diff --git a/packages/@react-types/slider/src/index.d.ts b/packages/@react-types/slider/src/index.d.ts index ec9c1558c25..d14772bf805 100644 --- a/packages/@react-types/slider/src/index.d.ts +++ b/packages/@react-types/slider/src/index.d.ts @@ -41,7 +41,11 @@ export interface SliderProps extends RangeInputBase