@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.
352 lines (344 loc) • 11.2 kB
JavaScript
'use client';
import * as React from 'react';
import { activeElement } from '@floating-ui/react/utils';
import { areArraysEqual } from '../../utils/areArraysEqual.js';
import { clamp } from '../../utils/clamp.js';
import { mergeReactProps } from '../../utils/mergeReactProps.js';
import { ownerDocument } from '../../utils/owner.js';
import { useControlled } from '../../utils/useControlled.js';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect.js';
import { useForkRef } from '../../utils/useForkRef.js';
import { useBaseUiId } from '../../utils/useBaseUiId.js';
import { valueToPercent } from '../../utils/valueToPercent.js';
import { useField } from '../../field/useField.js';
import { useFieldRootContext } from '../../field/root/FieldRootContext.js';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation.js';
import { percentToValue, roundValueToStep } from '../utils.js';
import { asc } from '../utils/asc.js';
import { setValueIndex } from '../utils/setValueIndex.js';
import { getSliderValue } from '../utils/getSliderValue.js';
function findClosest(values, currentValue) {
const {
index: closestIndex
} = values.reduce((acc, value, index) => {
const distance = Math.abs(currentValue - value);
if (acc === null || distance < acc.distance || distance === acc.distance) {
return {
distance,
index
};
}
return acc;
}, null) ?? {};
return closestIndex;
}
export function focusThumb({
sliderRef,
activeIndex,
setActive
}) {
const doc = ownerDocument(sliderRef.current);
if (!sliderRef.current?.contains(doc.activeElement) || Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex) {
sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
}
if (setActive) {
setActive(activeIndex);
}
}
export function validateMinimumDistance(values, step, minStepsBetweenValues) {
if (!Array.isArray(values)) {
return true;
}
const distances = values.reduce((acc, val, index, vals) => {
if (index === vals.length - 1) {
return acc;
}
acc.push(Math.abs(val - vals[index + 1]));
return acc;
}, []);
return Math.min(...distances) >= step * minStepsBetweenValues;
}
export function trackFinger(event, touchIdRef) {
// The event is TouchEvent
if (touchIdRef.current !== undefined && 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 false;
}
// The event is PointerEvent
return {
x: event.clientX,
y: event.clientY
};
}
/**
*/
export function useSliderRoot(parameters) {
const {
'aria-labelledby': ariaLabelledby,
defaultValue,
direction = 'ltr',
disabled = false,
id: idProp,
largeStep = 10,
max = 100,
min = 0,
minStepsBetweenValues = 0,
name,
onValueChange,
onValueCommitted,
orientation = 'horizontal',
rootRef,
step = 1,
tabIndex,
value: valueProp
} = parameters;
const {
setControlId,
setTouched,
setDirty,
validityData
} = useFieldRootContext();
const {
getValidationProps,
inputRef: inputValidationRef,
commitValidation
} = useFieldControlValidation();
const [valueState, setValueState] = useControlled({
controlled: valueProp,
default: defaultValue ?? min,
name: 'Slider'
});
const sliderRef = React.useRef(null);
const controlRef = React.useRef(null);
const thumbRefs = React.useRef([]);
const id = useBaseUiId(idProp);
useEnhancedEffect(() => {
setControlId(id);
return () => {
setControlId(undefined);
};
}, [id, setControlId]);
useField({
id,
commitValidation,
value: valueState,
controlRef
});
// We can't use the :active browser pseudo-classes.
// - The active state isn't triggered when clicking on the rail.
// - The active state isn't transferred when inversing a range slider.
const [active, setActive] = React.useState(-1);
const [dragging, setDragging] = React.useState(false);
const registerSliderControl = React.useCallback(element => {
if (element) {
controlRef.current = element;
inputValidationRef.current = element.querySelector('input[type="range"]');
}
}, [inputValidationRef]);
// Map with index (DOM position) as the key and the id attribute of each thumb <input> element as the value
const [inputIdMap, setInputMap] = React.useState(() => new Map());
const deregisterInputId = React.useCallback(index => {
setInputMap(prevMap => {
const nextMap = new Map(prevMap);
nextMap.delete(index);
return nextMap;
});
}, []);
const registerInputId = React.useCallback((index, inputId) => {
if (index > -1 && inputId !== undefined) {
setInputMap(prevMap => new Map(prevMap).set(index, inputId));
}
return {
deregister: deregisterInputId
};
}, [deregisterInputId]);
const handleValueChange = React.useCallback((value, thumbIndex, event) => {
if (!onValueChange) {
return;
}
// Redefine target to allow name and value to be read.
// This allows seamless integration with the most popular form libraries.
// https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
// Clone the event to not override `target` of the original event.
const nativeEvent = event.nativeEvent || event;
// @ts-ignore The nativeEvent is function, not object
const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
Object.defineProperty(clonedEvent, 'target', {
writable: true,
value: {
value,
name
}
});
onValueChange(value, clonedEvent, thumbIndex);
}, [name, onValueChange]);
const range = Array.isArray(valueState);
const values = React.useMemo(() => {
return (range ? valueState.slice().sort(asc) : [valueState]).map(val => val == null ? min : clamp(val, min, max));
}, [max, min, range, valueState]);
const handleRootRef = useForkRef(rootRef, sliderRef);
const areValuesEqual = React.useCallback(newValue => {
if (typeof newValue === 'number' && typeof valueState === 'number') {
return newValue === valueState;
}
if (typeof newValue === 'object' && typeof valueState === 'object') {
return areArraysEqual(newValue, valueState);
}
return false;
}, [valueState]);
const changeValue = React.useCallback((valueInput, index, event) => {
const newValue = getSliderValue({
valueInput,
min,
max,
index,
range,
values
});
if (range) {
focusThumb({
sliderRef,
activeIndex: index
});
}
if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
setValueState(newValue);
setDirty(newValue !== validityData.initialValue);
if (handleValueChange && !areValuesEqual(newValue) && event) {
handleValueChange(newValue, index, event);
}
setTouched(true);
commitValidation(newValue);
if (onValueCommitted && event) {
onValueCommitted(newValue, event.nativeEvent);
}
}
}, [min, max, range, step, minStepsBetweenValues, values, setValueState, setDirty, validityData.initialValue, handleValueChange, areValuesEqual, onValueCommitted, setTouched, commitValidation]);
const previousIndexRef = React.useRef(null);
const getFingerNewValue = React.useCallback(({
finger,
move = false,
offset = 0
}) => {
const {
current: sliderControl
} = controlRef;
if (!sliderControl) {
return null;
}
const isRtl = direction === 'rtl';
const isVertical = orientation === 'vertical';
const {
width,
height,
bottom,
left
} = sliderControl.getBoundingClientRect();
let percent;
if (isVertical) {
percent = (bottom - finger.y) / height + offset;
} else {
percent = (finger.x - left) / width + offset * (isRtl ? -1 : 1);
}
percent = Math.min(percent, 1);
if (isRtl && !isVertical) {
percent = 1 - percent;
}
let newValue;
newValue = percentToValue(percent, min, max);
if (step) {
newValue = roundValueToStep(newValue, step, min);
}
newValue = clamp(newValue, min, max);
let activeIndex = 0;
if (!range) {
return {
newValue,
activeIndex,
newPercentageValue: percent
};
}
if (!move) {
activeIndex = findClosest(values, newValue);
} else {
activeIndex = previousIndexRef.current;
}
// Bound the new value to the thumb's neighbours.
newValue = clamp(newValue, values[activeIndex - 1] + minStepsBetweenValues || -Infinity, values[activeIndex + 1] - minStepsBetweenValues || Infinity);
const previousValue = newValue;
newValue = setValueIndex({
values,
newValue,
index: activeIndex
});
// Potentially swap the index if needed.
if (!move) {
activeIndex = newValue.indexOf(previousValue);
previousIndexRef.current = activeIndex;
}
return {
newValue,
activeIndex,
newPercentageValue: percent
};
}, [direction, max, min, minStepsBetweenValues, orientation, range, step, values]);
useEnhancedEffect(() => {
const activeEl = activeElement(ownerDocument(sliderRef.current));
if (disabled && sliderRef.current.contains(activeEl)) {
// This is necessary because Firefox and Safari will keep focus
// on a disabled element:
// https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
// @ts-ignore
activeEl.blur();
}
}, [disabled]);
if (disabled && active !== -1) {
setActive(-1);
}
const getRootProps = React.useCallback((externalProps = {}) => mergeReactProps(getValidationProps(externalProps), {
'aria-labelledby': ariaLabelledby,
id,
ref: handleRootRef,
role: 'group'
}), [ariaLabelledby, getValidationProps, handleRootRef, id]);
return React.useMemo(() => ({
getRootProps,
active,
areValuesEqual,
'aria-labelledby': ariaLabelledby,
changeValue,
direction,
disabled,
dragging,
getFingerNewValue,
handleValueChange,
inputIdMap,
largeStep,
max,
min,
minStepsBetweenValues,
name,
onValueCommitted,
orientation,
percentageValues: values.map(v => valueToPercent(v, min, max)),
range,
registerInputId,
registerSliderControl,
setActive,
setDragging,
setValueState,
step,
tabIndex,
thumbRefs,
values
}), [getRootProps, active, areValuesEqual, ariaLabelledby, changeValue, direction, disabled, dragging, getFingerNewValue, handleValueChange, inputIdMap, largeStep, max, min, minStepsBetweenValues, name, onValueCommitted, orientation, range, registerInputId, registerSliderControl, setActive, setDragging, setValueState, step, tabIndex, thumbRefs, values]);
}