@base-ui-components/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
404 lines (399 loc) • 14.7 kB
JavaScript
'use client';
import * as React from 'react';
import { isElement } from '@floating-ui/utils/dom';
import { ownerDocument } from '@base-ui-components/utils/owner';
import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef';
import { activeElement, contains } from "../../floating-ui-react/utils.js";
import { clamp } from "../../utils/clamp.js";
import { createChangeEventDetails, createGenericEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { useDirection } from "../../direction-provider/DirectionContext.js";
import { useSliderRootContext } from "../root/SliderRootContext.js";
import { sliderStateAttributesMapping } from "../root/stateAttributesMapping.js";
import { getMidpoint } from "../utils/getMidpoint.js";
import { roundValueToStep } from "../utils/roundValueToStep.js";
import { validateMinimumDistance } from "../utils/validateMinimumDistance.js";
import { resolveThumbCollision } from "../utils/resolveThumbCollision.js";
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
function getControlOffset(styles, vertical) {
if (!styles) {
return {
start: 0,
end: 0
};
}
function parseSize(value) {
const parsed = value != null ? parseFloat(value) : 0;
return Number.isNaN(parsed) ? 0 : parsed;
}
const start = !vertical ? 'InlineStart' : 'Top';
const end = !vertical ? 'InlineEnd' : 'Bottom';
return {
start: parseSize(styles[`border${start}Width`]) + parseSize(styles[`padding${start}`]),
end: parseSize(styles[`border${end}Width`]) + parseSize(styles[`padding${end}`])
};
}
function getFingerCoords(event, touchIdRef) {
// The event is TouchEvent
if (touchIdRef.current != null && event.changedTouches) {
const touchEvent = event;
for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
const touch = touchEvent.changedTouches[i];
if (touch.identifier === touchIdRef.current) {
return {
x: touch.clientX,
y: touch.clientY
};
}
}
return null;
}
// The event is PointerEvent
return {
x: event.clientX,
y: event.clientY
};
}
/**
* The clickable, interactive part of the slider.
* Renders a `<div>` element.
*
* Documentation: [Base UI Slider](https://base-ui.com/react/components/slider)
*/
export const SliderControl = /*#__PURE__*/React.forwardRef(function SliderControl(componentProps, forwardedRef) {
const {
render: renderProp,
className,
...elementProps
} = componentProps;
const {
disabled,
dragging,
validation,
inset,
lastChangedValueRef,
lastChangeReasonRef,
max,
min,
minStepsBetweenValues,
onValueCommitted,
orientation,
pressedInputRef,
pressedThumbCenterOffsetRef,
pressedThumbIndexRef,
pressedValuesRef,
registerFieldControlRef,
renderBeforeHydration,
setActive,
setDragging,
setValue,
state,
step,
thumbCollisionBehavior,
thumbRefs,
values
} = useSliderRootContext();
const direction = useDirection();
const range = values.length > 1;
const vertical = orientation === 'vertical';
const controlRef = React.useRef(null);
const stylesRef = React.useRef(null);
const setStylesRef = useStableCallback(element => {
if (element && stylesRef.current == null) {
if (stylesRef.current == null) {
stylesRef.current = getComputedStyle(element);
}
}
});
// A number that uniquely identifies the current finger in the touch session.
const touchIdRef = React.useRef(null);
// The number of touch/pointermove events that have fired.
const moveCountRef = React.useRef(0);
// The offset amount to each side of the control for inset sliders.
// This value should be equal to the radius or half the width/height of the thumb.
const insetThumbOffsetRef = React.useRef(0);
const latestValuesRef = useValueAsRef(values);
const updatePressedThumb = useStableCallback(nextIndex => {
if (pressedThumbIndexRef.current !== nextIndex) {
pressedThumbIndexRef.current = nextIndex;
}
const thumbElement = thumbRefs.current[nextIndex];
if (!thumbElement) {
pressedThumbCenterOffsetRef.current = null;
pressedInputRef.current = null;
return;
}
pressedInputRef.current = thumbElement.querySelector('input[type="range"]');
});
const getFingerState = useStableCallback(fingerCoords => {
const control = controlRef.current;
if (!control) {
return null;
}
const {
width,
height,
bottom,
left,
right
} = control.getBoundingClientRect();
const controlOffset = getControlOffset(stylesRef.current, vertical);
const insetThumbOffset = insetThumbOffsetRef.current;
const controlSize = (vertical ? height : width) - controlOffset.start - controlOffset.end - insetThumbOffset * 2;
const thumbCenterOffset = pressedThumbCenterOffsetRef.current ?? 0;
const fingerX = fingerCoords.x - thumbCenterOffset;
const fingerY = fingerCoords.y - thumbCenterOffset;
const valueSize = vertical ? bottom - fingerY - controlOffset.end : (direction === 'rtl' ? right - fingerX : fingerX - left) - controlOffset.start;
// the value at the finger origin scaled down to fit the range [0, 1]
const valueRescaled = clamp((valueSize - insetThumbOffset) / controlSize, 0, 1);
let newValue = (max - min) * valueRescaled + min;
newValue = roundValueToStep(newValue, step, min);
newValue = clamp(newValue, min, max);
if (!range) {
return {
value: newValue,
thumbIndex: 0,
didSwap: false
};
}
const thumbIndex = pressedThumbIndexRef.current;
if (thumbIndex < 0) {
return null;
}
const collisionResult = resolveThumbCollision({
behavior: thumbCollisionBehavior,
values,
currentValues: latestValuesRef.current ?? values,
initialValues: pressedValuesRef.current,
pressedIndex: thumbIndex,
nextValue: newValue,
min,
max,
step,
minStepsBetweenValues
});
if (thumbCollisionBehavior === 'swap' && collisionResult.didSwap) {
updatePressedThumb(collisionResult.thumbIndex);
} else {
pressedThumbIndexRef.current = collisionResult.thumbIndex;
}
return collisionResult;
});
const startPressing = useStableCallback(fingerCoords => {
pressedValuesRef.current = range ? values.slice() : null;
latestValuesRef.current = values;
const pressedThumbIndex = pressedThumbIndexRef.current;
let closestThumbIndex = pressedThumbIndex;
if (pressedThumbIndex > -1 && pressedThumbIndex < values.length) {
if (values[pressedThumbIndex] === max) {
let candidateIndex = pressedThumbIndex;
while (candidateIndex > 0 && values[candidateIndex - 1] === max) {
candidateIndex -= 1;
}
closestThumbIndex = candidateIndex;
}
} else {
// pressed on control
const axis = !vertical ? 'x' : 'y';
let minDistance;
closestThumbIndex = -1;
for (let i = 0; i < thumbRefs.current.length; i += 1) {
const thumbEl = thumbRefs.current[i];
if (isElement(thumbEl)) {
const midpoint = getMidpoint(thumbEl);
const distance = Math.abs(fingerCoords[axis] - midpoint[axis]);
if (minDistance === undefined || distance <= minDistance) {
closestThumbIndex = i;
minDistance = distance;
}
}
}
}
if (closestThumbIndex > -1 && closestThumbIndex !== pressedThumbIndex) {
updatePressedThumb(closestThumbIndex);
}
if (inset) {
const thumbEl = thumbRefs.current[closestThumbIndex];
if (isElement(thumbEl)) {
const thumbRect = thumbEl.getBoundingClientRect();
const side = !vertical ? 'width' : 'height';
insetThumbOffsetRef.current = thumbRect[side] / 2;
}
}
});
const focusThumb = useStableCallback(thumbIndex => {
thumbRefs.current?.[thumbIndex]?.querySelector('input[type="range"]')?.focus({
preventScroll: true
});
});
const handleTouchMove = useStableCallback(nativeEvent => {
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
if (fingerCoords == null) {
return;
}
moveCountRef.current += 1;
// Cancel move in case some other element consumed a pointerup event and it was not fired.
if (nativeEvent.type === 'pointermove' && nativeEvent.buttons === 0) {
handleTouchEnd(nativeEvent);
return;
}
const finger = getFingerState(fingerCoords);
if (finger == null) {
return;
}
if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) {
if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
setDragging(true);
}
setValue(finger.value, createChangeEventDetails(REASONS.drag, nativeEvent, undefined, {
activeThumbIndex: finger.thumbIndex
}));
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
if (finger.didSwap) {
focusThumb(finger.thumbIndex);
}
}
});
function handleTouchEnd(nativeEvent) {
setActive(-1);
setDragging(false);
pressedInputRef.current = null;
pressedThumbCenterOffsetRef.current = null;
pressedThumbIndexRef.current = -1;
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
const finger = fingerCoords != null ? getFingerState(fingerCoords) : null;
if (finger != null) {
const commitReason = lastChangeReasonRef.current;
validation.commit(lastChangedValueRef.current ?? finger.value);
onValueCommitted(lastChangedValueRef.current ?? finger.value, createGenericEventDetails(commitReason, nativeEvent));
}
if ('pointerType' in nativeEvent && controlRef.current?.hasPointerCapture(nativeEvent.pointerId)) {
controlRef.current?.releasePointerCapture(nativeEvent.pointerId);
}
touchIdRef.current = null;
pressedValuesRef.current = null;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
stopListening();
}
const handleTouchStart = useStableCallback(nativeEvent => {
if (disabled) {
return;
}
const touch = nativeEvent.changedTouches[0];
if (touch != null) {
touchIdRef.current = touch.identifier;
}
const fingerCoords = getFingerCoords(nativeEvent, touchIdRef);
if (fingerCoords != null) {
startPressing(fingerCoords);
const finger = getFingerState(fingerCoords);
if (finger == null) {
return;
}
focusThumb(finger.thumbIndex);
setValue(finger.value, createChangeEventDetails(REASONS.trackPress, nativeEvent, undefined, {
activeThumbIndex: finger.thumbIndex
}));
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
if (finger.didSwap) {
focusThumb(finger.thumbIndex);
}
}
moveCountRef.current = 0;
const doc = ownerDocument(controlRef.current);
doc.addEventListener('touchmove', handleTouchMove, {
passive: true
});
doc.addEventListener('touchend', handleTouchEnd, {
passive: true
});
});
const stopListening = useStableCallback(() => {
const doc = ownerDocument(controlRef.current);
doc.removeEventListener('pointermove', handleTouchMove);
doc.removeEventListener('pointerup', handleTouchEnd);
doc.removeEventListener('touchmove', handleTouchMove);
doc.removeEventListener('touchend', handleTouchEnd);
pressedValuesRef.current = null;
});
const focusFrame = useAnimationFrame();
React.useEffect(() => {
const control = controlRef.current;
if (!control) {
return () => stopListening();
}
control.addEventListener('touchstart', handleTouchStart, {
passive: true
});
return () => {
control.removeEventListener('touchstart', handleTouchStart);
focusFrame.cancel();
stopListening();
};
}, [stopListening, handleTouchStart, controlRef, focusFrame]);
React.useEffect(() => {
if (disabled) {
stopListening();
}
}, [disabled, stopListening]);
const element = useRenderElement('div', componentProps, {
state,
ref: [forwardedRef, registerFieldControlRef, controlRef, setStylesRef],
props: [{
['data-base-ui-slider-control']: renderBeforeHydration ? '' : undefined,
onPointerDown(event) {
const control = controlRef.current;
if (!control || disabled || event.defaultPrevented || !isElement(event.target) ||
// Only handle left clicks
event.button !== 0) {
return;
}
const fingerCoords = getFingerCoords(event, touchIdRef);
if (fingerCoords != null) {
startPressing(fingerCoords);
const finger = getFingerState(fingerCoords);
if (finger == null) {
return;
}
const pressedOnFocusedThumb = contains(thumbRefs.current[finger.thumbIndex], activeElement(ownerDocument(control)));
if (pressedOnFocusedThumb) {
event.preventDefault();
} else {
focusFrame.request(() => {
focusThumb(finger.thumbIndex);
});
}
setDragging(true);
const pressedOnAnyThumb = pressedThumbCenterOffsetRef.current != null;
if (!pressedOnAnyThumb) {
setValue(finger.value, createChangeEventDetails(REASONS.trackPress, event.nativeEvent, undefined, {
activeThumbIndex: finger.thumbIndex
}));
latestValuesRef.current = Array.isArray(finger.value) ? finger.value : [finger.value];
if (finger.didSwap) {
focusThumb(finger.thumbIndex);
}
}
}
if (event.nativeEvent.pointerId) {
control.setPointerCapture(event.nativeEvent.pointerId);
}
moveCountRef.current = 0;
const doc = ownerDocument(controlRef.current);
doc.addEventListener('pointermove', handleTouchMove, {
passive: true
});
doc.addEventListener('pointerup', handleTouchEnd, {
once: true
});
},
tabIndex: -1
}, elementProps],
stateAttributesMapping: sliderStateAttributesMapping
});
return element;
});
if (process.env.NODE_ENV !== "production") SliderControl.displayName = "SliderControl";