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