Skip to content

Commit c2271e4

Browse files
authored
[slider] Add thumbCollisionBehavior prop (#2856)
1 parent b41aae0 commit c2271e4

File tree

11 files changed

+950
-65
lines changed

11 files changed

+950
-65
lines changed

docs/reference/generated/slider-root.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
"description": "How the thumb(s) are aligned relative to `Slider.Control` when the value is at `min` or `max`:\n- `center`: The center of the thumb is aligned with the control edge\n- `edge`: The thumb is inset within the control such that its edge is aligned with the control edge\n- `edge-client-only`: Same as `edge` but renders after React hydration on the client, reducing bundle size in return",
4343
"detailedType": "'center' | 'edge' | 'edge-client-only' | undefined"
4444
},
45+
"thumbCollisionBehavior": {
46+
"type": "'none' | 'push' | 'swap'",
47+
"default": "'push'",
48+
"description": "Controls how thumbs behave when they collide during pointer interactions.\n\n- `'push'` (default): Thumbs push each other without restoring their previous positions when dragged back.\n- `'swap'`: Thumbs swap places when dragged past each other.\n- `'none'`: Thumbs cannot move past each other; excess movement is ignored.",
49+
"detailedType": "'none' | 'push' | 'swap' | undefined"
50+
},
4551
"step": {
4652
"type": "number",
4753
"default": "1",

docs/src/app/(public)/(content)/react/components/slider/page.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ To create a range slider:
4141
1. Pass an array of values and place a `<Slider.Thumb>` for each value in the array
4242
2. Additionally for server-side rendering, specify a numeric `index` for each thumb that corresponds to the index of its value in the value array
4343

44+
Thumbs can be configured to behave differently when they collide during pointer interactions using the `thumbCollisionBehavior` prop on `<Slider.Root>`.
45+
4446
import { DemoSliderRangeSlider } from './demos/range-slider';
4547

4648
<DemoSliderRangeSlider compact />

packages/react/src/slider/control/SliderControl.tsx

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isElement } from '@floating-ui/utils/dom';
44
import { ownerDocument } from '@base-ui-components/utils/owner';
55
import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
66
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
7+
import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef';
78
import { activeElement, contains } from '../../floating-ui-react/utils';
89
import type { Coords } from '../../floating-ui-react/types';
910
import { clamp } from '../../utils/clamp';
@@ -19,9 +20,9 @@ import { useSliderRootContext } from '../root/SliderRootContext';
1920
import { sliderStateAttributesMapping } from '../root/stateAttributesMapping';
2021
import type { SliderRoot } from '../root/SliderRoot';
2122
import { getMidpoint } from '../utils/getMidpoint';
22-
import { replaceArrayItemAtIndex } from '../utils/replaceArrayItemAtIndex';
2323
import { roundValueToStep } from '../utils/roundValueToStep';
2424
import { validateMinimumDistance } from '../utils/validateMinimumDistance';
25+
import { resolveThumbCollision } from '../utils/resolveThumbCollision';
2526

2627
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
2728

@@ -101,13 +102,15 @@ export const SliderControl = React.forwardRef(function SliderControl(
101102
pressedInputRef,
102103
pressedThumbCenterOffsetRef,
103104
pressedThumbIndexRef,
105+
pressedValuesRef,
104106
registerFieldControlRef,
105107
renderBeforeHydration,
106108
setActive,
107109
setDragging,
108110
setValue,
109111
state,
110112
step,
113+
thumbCollisionBehavior,
111114
thumbRefs,
112115
values,
113116
} = useSliderRootContext();
@@ -133,6 +136,23 @@ export const SliderControl = React.forwardRef(function SliderControl(
133136
// The offset amount to each side of the control for inset sliders.
134137
// This value should be equal to the radius or half the width/height of the thumb.
135138
const insetThumbOffsetRef = React.useRef(0);
139+
const latestValuesRef = useValueAsRef(values);
140+
141+
const updatePressedThumb = useStableCallback((nextIndex: number) => {
142+
if (pressedThumbIndexRef.current !== nextIndex) {
143+
pressedThumbIndexRef.current = nextIndex;
144+
}
145+
146+
const thumbElement = thumbRefs.current[nextIndex];
147+
148+
if (!thumbElement) {
149+
pressedThumbCenterOffsetRef.current = null;
150+
pressedInputRef.current = null;
151+
return;
152+
}
153+
154+
pressedInputRef.current = thumbElement.querySelector<HTMLInputElement>('input[type="range"]');
155+
});
136156

137157
const getFingerState = useStableCallback((fingerCoords: Coords): FingerState | null => {
138158
const control = controlRef.current;
@@ -165,25 +185,42 @@ export const SliderControl = React.forwardRef(function SliderControl(
165185
return {
166186
value: newValue,
167187
thumbIndex: 0,
188+
didSwap: false,
168189
};
169190
}
170191

171-
const minValueDifference = minStepsBetweenValues * step;
192+
const thumbIndex = pressedThumbIndexRef.current;
193+
194+
if (thumbIndex < 0) {
195+
return null;
196+
}
197+
198+
const collisionResult = resolveThumbCollision({
199+
behavior: thumbCollisionBehavior,
200+
values,
201+
currentValues: latestValuesRef.current ?? values,
202+
initialValues: pressedValuesRef.current,
203+
pressedIndex: thumbIndex,
204+
nextValue: newValue,
205+
min,
206+
max,
207+
step,
208+
minStepsBetweenValues,
209+
});
172210

173-
// Bound the new value to the thumb's neighbours.
174-
newValue = clamp(
175-
newValue,
176-
values[pressedThumbIndexRef.current - 1] + minValueDifference || -Infinity,
177-
values[pressedThumbIndexRef.current + 1] - minValueDifference || Infinity,
178-
);
211+
if (thumbCollisionBehavior === 'swap' && collisionResult.didSwap) {
212+
updatePressedThumb(collisionResult.thumbIndex);
213+
} else {
214+
pressedThumbIndexRef.current = collisionResult.thumbIndex;
215+
}
179216

180-
return {
181-
value: replaceArrayItemAtIndex(values, pressedThumbIndexRef.current, newValue),
182-
thumbIndex: pressedThumbIndexRef.current,
183-
};
217+
return collisionResult;
184218
});
185219

186220
const startPressing = useStableCallback((fingerCoords: Coords) => {
221+
pressedValuesRef.current = range ? values.slice() : null;
222+
latestValuesRef.current = values;
223+
187224
const pressedThumbIndex = pressedThumbIndexRef.current;
188225
let closestThumbIndex = pressedThumbIndex;
189226

@@ -219,7 +256,7 @@ export const SliderControl = React.forwardRef(function SliderControl(
219256
}
220257

221258
if (closestThumbIndex > -1 && closestThumbIndex !== pressedThumbIndex) {
222-
pressedThumbIndexRef.current = closestThumbIndex;
259+
updatePressedThumb(closestThumbIndex);
223260
}
224261

225262
if (inset) {
@@ -270,6 +307,12 @@ export const SliderControl = React.forwardRef(function SliderControl(
270307
activeThumbIndex: finger.thumbIndex,
271308
}),
272309
);
310+
311+
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
312+
313+
if (finger.didSwap) {
314+
focusThumb(finger.thumbIndex);
315+
}
273316
}
274317
});
275318

@@ -282,25 +325,17 @@ export const SliderControl = React.forwardRef(function SliderControl(
282325
pressedThumbIndexRef.current = -1;
283326

284327
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
285-
286-
if (fingerCoords == null) {
287-
return;
288-
}
289-
290-
const finger = getFingerState(fingerCoords);
291-
292-
if (finger == null) {
293-
return;
328+
const finger = fingerCoords != null ? getFingerState(fingerCoords) : null;
329+
330+
if (finger != null) {
331+
const commitReason = lastChangeReasonRef.current;
332+
validation.commit(lastChangedValueRef.current ?? finger.value);
333+
onValueCommitted(
334+
lastChangedValueRef.current ?? finger.value,
335+
createGenericEventDetails(commitReason, nativeEvent),
336+
);
294337
}
295338

296-
const commitReason = lastChangeReasonRef.current;
297-
298-
validation.commit(lastChangedValueRef.current ?? finger.value);
299-
onValueCommitted(
300-
lastChangedValueRef.current ?? finger.value,
301-
createGenericEventDetails(commitReason, nativeEvent),
302-
);
303-
304339
if (
305340
'pointerType' in nativeEvent &&
306341
controlRef.current?.hasPointerCapture(nativeEvent.pointerId)
@@ -309,6 +344,7 @@ export const SliderControl = React.forwardRef(function SliderControl(
309344
}
310345

311346
touchIdRef.current = null;
347+
pressedValuesRef.current = null;
312348
// eslint-disable-next-line @typescript-eslint/no-use-before-define
313349
stopListening();
314350
}
@@ -342,6 +378,12 @@ export const SliderControl = React.forwardRef(function SliderControl(
342378
activeThumbIndex: finger.thumbIndex,
343379
}),
344380
);
381+
382+
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
383+
384+
if (finger.didSwap) {
385+
focusThumb(finger.thumbIndex);
386+
}
345387
}
346388

347389
moveCountRef.current = 0;
@@ -356,6 +398,7 @@ export const SliderControl = React.forwardRef(function SliderControl(
356398
doc.removeEventListener('pointerup', handleTouchEnd);
357399
doc.removeEventListener('touchmove', handleTouchMove);
358400
doc.removeEventListener('touchend', handleTouchEnd);
401+
pressedValuesRef.current = null;
359402
});
360403

361404
const focusFrame = useAnimationFrame();
@@ -438,6 +481,12 @@ export const SliderControl = React.forwardRef(function SliderControl(
438481
activeThumbIndex: finger.thumbIndex,
439482
}),
440483
);
484+
485+
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
486+
487+
if (finger.didSwap) {
488+
focusThumb(finger.thumbIndex);
489+
}
441490
}
442491
}
443492

@@ -463,10 +512,12 @@ export const SliderControl = React.forwardRef(function SliderControl(
463512
interface FingerState {
464513
value: number | number[];
465514
thumbIndex: number;
515+
didSwap: boolean;
466516
}
467517

468518
export interface SliderControlProps extends BaseUIComponentProps<'div', SliderRoot.State> {}
469519

470520
export namespace SliderControl {
521+
export type State = SliderRoot.State;
471522
export type Props = SliderControlProps;
472523
}

packages/react/src/slider/root/SliderRoot.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
7979
onValueCommitted: onValueCommittedProp,
8080
orientation = 'horizontal',
8181
step = 1,
82+
thumbCollisionBehavior = 'push',
8283
thumbAlignment = 'center',
8384
value: valueProp,
8485
...elementProps
@@ -134,6 +135,8 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
134135
// This is updated on pointerdown, which is sooner than the `active/activeIndex`
135136
// state which is updated later when the nested `input` receives focus.
136137
const pressedThumbIndexRef = React.useRef(-1);
138+
// The values when the current drag interaction started.
139+
const pressedValuesRef = React.useRef<readonly number[] | null>(null);
137140
const lastChangedValueRef = React.useRef<number | readonly number[] | null>(null);
138141
const lastChangeReasonRef = React.useRef<SliderRoot.ChangeEventReason>('none');
139142

@@ -142,17 +145,25 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
142145
// We can't use the :active browser pseudo-classes.
143146
// - The active state isn't triggered when clicking on the rail.
144147
// - The active state isn't transferred when inversing a range slider.
145-
const [active, setActive] = React.useState(-1);
148+
const [active, setActiveState] = React.useState(-1);
149+
const [lastUsedThumbIndex, setLastUsedThumbIndex] = React.useState(-1);
146150
const [dragging, setDragging] = React.useState(false);
147151
const [thumbMap, setThumbMap] = React.useState(
148152
() => new Map<Node, CompositeMetadata<ThumbMetadata> | null>(),
149153
);
150-
151154
const [indicatorPosition, setIndicatorPosition] = React.useState<(number | undefined)[]>([
152155
undefined,
153156
undefined,
154157
]);
155158

159+
const setActive = useStableCallback((value: number) => {
160+
setActiveState(value);
161+
162+
if (value !== -1) {
163+
setLastUsedThumbIndex(value);
164+
}
165+
});
166+
156167
useField({
157168
id,
158169
commit: validation.commit,
@@ -314,6 +325,7 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
314325
inset: thumbAlignment !== 'center',
315326
labelId: ariaLabelledby,
316327
largeStep,
328+
lastUsedThumbIndex,
317329
lastChangedValueRef,
318330
lastChangeReasonRef,
319331
locale,
@@ -326,6 +338,7 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
326338
pressedInputRef,
327339
pressedThumbCenterOffsetRef,
328340
pressedThumbIndexRef,
341+
pressedValuesRef,
329342
registerFieldControlRef,
330343
renderBeforeHydration: thumbAlignment === 'edge',
331344
setActive,
@@ -334,6 +347,7 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
334347
setValue,
335348
state,
336349
step,
350+
thumbCollisionBehavior,
337351
thumbMap,
338352
thumbRefs,
339353
values,
@@ -349,6 +363,7 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
349363
handleInputChange,
350364
indicatorPosition,
351365
largeStep,
366+
lastUsedThumbIndex,
352367
lastChangedValueRef,
353368
lastChangeReasonRef,
354369
locale,
@@ -361,13 +376,15 @@ export const SliderRoot = React.forwardRef(function SliderRoot<
361376
pressedInputRef,
362377
pressedThumbCenterOffsetRef,
363378
pressedThumbIndexRef,
379+
pressedValuesRef,
364380
registerFieldControlRef,
365381
setActive,
366382
setDragging,
367383
setIndicatorPosition,
368384
setValue,
369385
state,
370386
step,
387+
thumbCollisionBehavior,
371388
thumbAlignment,
372389
thumbMap,
373390
thumbRefs,
@@ -440,6 +457,7 @@ export interface SliderRootState extends FieldRoot.State {
440457
*/
441458
values: readonly number[];
442459
}
460+
443461
export interface SliderRootProps<
444462
Value extends number | readonly number[] = number | readonly number[],
445463
> extends BaseUIComponentProps<'div', SliderRoot.State> {
@@ -509,6 +527,16 @@ export interface SliderRootProps<
509527
* @default 'center'
510528
*/
511529
thumbAlignment?: 'center' | 'edge' | 'edge-client-only';
530+
/**
531+
* Controls how thumbs behave when they collide during pointer interactions.
532+
*
533+
* - `'push'` (default): Thumbs push each other without restoring their previous positions when dragged back.
534+
* - `'swap'`: Thumbs swap places when dragged past each other.
535+
* - `'none'`: Thumbs cannot move past each other; excess movement is ignored.
536+
*
537+
* @default 'push'
538+
*/
539+
thumbCollisionBehavior?: 'push' | 'swap' | 'none';
512540
/**
513541
* The value of the slider.
514542
* For ranged sliders, provide an array with two values.

packages/react/src/slider/root/SliderRootContext.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface SliderRootContext {
1111
* The index of the active thumb.
1212
*/
1313
active: number;
14+
/**
15+
* The index of the most recently interacted thumb.
16+
*/
17+
lastUsedThumbIndex: number;
1418
controlRef: React.RefObject<HTMLElement | null>;
1519
dragging: boolean;
1620
disabled: boolean;
@@ -65,9 +69,10 @@ export interface SliderRootContext {
6569
pressedInputRef: React.RefObject<HTMLInputElement | null>;
6670
pressedThumbCenterOffsetRef: React.RefObject<number | null>;
6771
pressedThumbIndexRef: React.RefObject<number>;
68-
registerFieldControlRef: React.RefCallback<Element> | null;
72+
pressedValuesRef: React.RefObject<readonly number[] | null>;
6973
renderBeforeHydration: boolean;
70-
setActive: React.Dispatch<React.SetStateAction<number>>;
74+
registerFieldControlRef: React.RefCallback<Element> | null;
75+
setActive: (index: number) => void;
7176
setDragging: React.Dispatch<React.SetStateAction<boolean>>;
7277
setIndicatorPosition: React.Dispatch<React.SetStateAction<(number | undefined)[]>>;
7378
/**
@@ -81,6 +86,7 @@ export interface SliderRootContext {
8186
* @default 1
8287
*/
8388
step: number;
89+
thumbCollisionBehavior: 'push' | 'swap' | 'none';
8490
thumbMap: Map<Node, CompositeMetadata<ThumbMetadata> | null>;
8591
thumbRefs: React.RefObject<(HTMLElement | null)[]>;
8692
/**

0 commit comments

Comments
 (0)