@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.
278 lines (273 loc) • 9.95 kB
JavaScript
'use client';
import * as React from 'react';
import { ownerDocument } from '@base-ui-components/utils/owner';
import { useControlled } from '@base-ui-components/utils/useControlled';
import { useEventCallback } from '@base-ui-components/utils/useEventCallback';
import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs';
import { useLatestRef } from '@base-ui-components/utils/useLatestRef';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { visuallyHidden } from '@base-ui-components/utils/visuallyHidden';
import { warn } from '@base-ui-components/utils/warn';
import { useBaseUiId } from "../../utils/useBaseUiId.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { clamp } from "../../utils/clamp.js";
import { areArraysEqual } from "../../utils/areArraysEqual.js";
import { activeElement } from "../../floating-ui-react/utils.js";
import { CompositeList } from "../../composite/list/CompositeList.js";
import { useField } from "../../field/useField.js";
import { useFieldControlValidation } from "../../field/control/useFieldControlValidation.js";
import { useFieldRootContext } from "../../field/root/FieldRootContext.js";
import { useFormContext } from "../../form/FormContext.js";
import { asc } from "../utils/asc.js";
import { getSliderValue } from "../utils/getSliderValue.js";
import { validateMinimumDistance } from "../utils/validateMinimumDistance.js";
import { sliderStyleHookMapping } from "./styleHooks.js";
import { SliderRootContext } from "./SliderRootContext.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
function areValuesEqual(newValue, oldValue) {
if (typeof newValue === 'number' && typeof oldValue === 'number') {
return newValue === oldValue;
}
if (Array.isArray(newValue) && Array.isArray(oldValue)) {
return areArraysEqual(newValue, oldValue);
}
return false;
}
/**
* Groups all parts of the slider.
* Renders a `<div>` element.
*
* Documentation: [Base UI Slider](https://base-ui.com/react/components/slider)
*/
export const SliderRoot = /*#__PURE__*/React.forwardRef(function SliderRoot(componentProps, forwardedRef) {
const {
'aria-labelledby': ariaLabelledbyProp,
className,
defaultValue,
disabled: disabledProp = false,
id: idProp,
inputRef: inputRefProp,
format,
largeStep = 10,
locale,
render,
max = 100,
min = 0,
minStepsBetweenValues = 0,
name: nameProp,
onValueChange: onValueChangeProp,
onValueCommitted: onValueCommittedProp,
orientation = 'horizontal',
step = 1,
tabIndex: externalTabIndex,
value: valueProp,
...elementProps
} = componentProps;
const id = useBaseUiId(idProp);
const onValueChange = useEventCallback(onValueChangeProp);
const onValueCommitted = useEventCallback(onValueCommittedProp);
const {
clearErrors
} = useFormContext();
const {
labelId,
state: fieldState,
disabled: fieldDisabled,
name: fieldName,
setTouched,
setDirty,
validityData,
validationMode
} = useFieldRootContext();
const fieldControlValidation = useFieldControlValidation();
const ariaLabelledby = ariaLabelledbyProp ?? labelId;
const disabled = fieldDisabled || disabledProp;
const name = fieldName ?? nameProp ?? '';
// The internal value is potentially unsorted, e.g. to support frozen arrays
// https://github.com/mui/material-ui/pull/28472
const [valueUnwrapped, setValueUnwrapped] = useControlled({
controlled: valueProp,
default: defaultValue ?? min,
name: 'Slider'
});
const sliderRef = React.useRef(null);
const controlRef = React.useRef(null);
const thumbRefs = React.useRef([]);
const inputRef = useMergedRefs(inputRefProp, fieldControlValidation.inputRef);
const lastChangedValueRef = React.useRef(null);
const formatOptionsRef = useLatestRef(format);
// 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 [thumbMap, setThumbMap] = React.useState(() => new Map());
useField({
id,
commitValidation: fieldControlValidation.commitValidation,
value: valueUnwrapped,
controlRef,
name,
getValue: () => valueUnwrapped
});
const registerFieldControlRef = useEventCallback(element => {
if (element) {
controlRef.current = element;
}
});
const range = Array.isArray(valueUnwrapped);
const values = React.useMemo(() => {
if (!range) {
return [clamp(valueUnwrapped, min, max)];
}
return valueUnwrapped.slice().sort(asc);
}, [max, min, range, valueUnwrapped]);
const setValue = useEventCallback((newValue, thumbIndex, event) => {
if (Number.isNaN(newValue) || areValuesEqual(newValue, valueUnwrapped)) {
return;
}
setValueUnwrapped(newValue);
// 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.
// @ts-ignore The nativeEvent is function, not object
const clonedEvent = new event.constructor(event.type, event);
Object.defineProperty(clonedEvent, 'target', {
writable: true,
value: {
value: newValue,
name
}
});
lastChangedValueRef.current = newValue;
onValueChange(newValue, clonedEvent, thumbIndex);
clearErrors(name);
fieldControlValidation.commitValidation(newValue, true);
});
// for keypresses only
const handleInputChange = useEventCallback((valueInput, index, event) => {
const newValue = getSliderValue(valueInput, index, min, max, range, values);
if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
setValue(newValue, index, event.nativeEvent);
setDirty(newValue !== validityData.initialValue);
setTouched(true);
const nextValue = lastChangedValueRef.current ?? newValue;
onValueCommitted(nextValue, event.nativeEvent);
clearErrors(name);
if (validationMode === 'onChange') {
fieldControlValidation.commitValidation(nextValue ?? newValue);
} else {
fieldControlValidation.commitValidation(nextValue ?? newValue, true);
}
}
});
const handleHiddenInputFocus = useEventCallback(() => {
// focus the first thumb if the hidden input receives focus
thumbRefs.current?.[0]?.focus();
});
useIsoLayoutEffect(() => {
if (valueProp === undefined || dragging) {
return;
}
if (min >= max) {
warn('Slider `max` must be greater than `min`');
}
}, [dragging, min, max, valueProp]);
useIsoLayoutEffect(() => {
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 state = React.useMemo(() => ({
...fieldState,
activeThumbIndex: active,
disabled,
dragging,
orientation,
max,
min,
minStepsBetweenValues,
step,
values
}), [fieldState, active, disabled, dragging, max, min, minStepsBetweenValues, orientation, step, values]);
const contextValue = React.useMemo(() => ({
active,
disabled,
dragging,
fieldControlValidation,
formatOptionsRef,
handleInputChange,
labelId: ariaLabelledby,
largeStep,
lastChangedValueRef,
locale,
max,
min,
minStepsBetweenValues,
onValueCommitted,
orientation,
range,
registerFieldControlRef,
setActive,
setDragging,
setValue,
state,
step,
tabIndex: externalTabIndex ?? null,
thumbMap,
thumbRefs,
values
}), [active, ariaLabelledby, disabled, dragging, externalTabIndex, fieldControlValidation, formatOptionsRef, handleInputChange, largeStep, lastChangedValueRef, locale, max, min, minStepsBetweenValues, onValueCommitted, orientation, range, registerFieldControlRef, setActive, setDragging, setValue, state, step, thumbMap, thumbRefs, values]);
const element = useRenderElement('div', componentProps, {
state,
ref: [forwardedRef, sliderRef],
props: [{
'aria-labelledby': ariaLabelledby,
id,
role: 'group'
}, fieldControlValidation.getValidationProps, elementProps],
customStyleHookMapping: sliderStyleHookMapping
});
return /*#__PURE__*/_jsx(SliderRootContext.Provider, {
value: contextValue,
children: /*#__PURE__*/_jsxs(CompositeList, {
elementsRef: thumbRefs,
onMapChange: setThumbMap,
children: [element, range ? values.map((value, index) => {
return /*#__PURE__*/_jsx("input", {
...fieldControlValidation.getInputValidationProps({
disabled,
name,
ref: inputRef,
value,
onFocus: handleHiddenInputFocus,
style: visuallyHidden,
tabIndex: -1,
'aria-hidden': true
})
}, `${name}-input-${index}`);
}) : /*#__PURE__*/_jsx("input", {
...fieldControlValidation.getInputValidationProps({
disabled,
name,
ref: inputRef,
value: valueUnwrapped,
onFocus: handleHiddenInputFocus,
style: visuallyHidden,
tabIndex: -1,
'aria-hidden': true
})
})]
})
});
});
if (process.env.NODE_ENV !== "production") SliderRoot.displayName = "SliderRoot";