UNPKG

@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
'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]); }