UNPKG

@itwin/itwinui-react

Version:

A react component library for iTwinUI

351 lines (350 loc) 10.9 kB
import cx from 'classnames'; import * as React from 'react'; import { getBoundedValue, useEventListener, Box } from '../../utils/index.js'; import { Track } from './Track.js'; import { Thumb } from './Thumb.js'; import { FloatingDelayGroup } from '@floating-ui/react'; import { defaultTooltipDelay } from '../Tooltip/Tooltip.js'; let getPercentageOfRectangle = (rect, pointerX, pointerY, orientation) => { if ('horizontal' === orientation) { let position = getBoundedValue(pointerX, rect.left, rect.right); return (position - rect.left) / rect.width; } let position = getBoundedValue(pointerY, rect.top, rect.bottom); return (rect.bottom - position) / rect.height; }; let getClosestValueIndex = (values, pointerValue) => { if (1 === values.length) return 0; let distances = values.map((value) => Math.abs(value - pointerValue)); let smallest = Math.min(...distances); return distances.indexOf(smallest); }; let getDefaultTrackDisplay = (trackDisplayMode, values) => { if ('auto' !== trackDisplayMode) return trackDisplayMode; return values.length % 2 ? 'even-segments' : 'odd-segments'; }; let roundValueToClosestStep = (value, step, min) => Math.round((value - min) / step) * step + min; let formatNumberValue = (value, step, numDecimals) => { if (Number.isInteger(step)) return value.toFixed(0); return value.toFixed(numDecimals); }; let focusThumb = (sliderContainer, activeIndex) => { let doc = sliderContainer.ownerDocument; if ( !sliderContainer.contains(doc.activeElement) || Number(doc.activeElement?.getAttribute('data-index')) !== activeIndex ) { let thumbToFocus = sliderContainer.querySelector( `[data-index="${activeIndex}"]`, ); thumbToFocus && thumbToFocus.focus(); } }; export const Slider = React.forwardRef((props, ref) => { let { min = 0, max = 100, values, step = 1, tooltipProps, disabled = false, tickLabels, minLabel, maxLabel, trackDisplayMode = 'auto', thumbMode = 'inhibit-crossing', onChange, onUpdate, thumbProps, className, trackContainerProps, minProps, maxProps, trackProps, tickProps, ticksProps, orientation = 'horizontal', ...rest } = props; let [currentValues, setCurrentValues] = React.useState(values); React.useEffect(() => { setCurrentValues(values); }, [values]); let [minValueLabel, setMinValueLabel] = React.useState( () => minLabel ?? min.toString(), ); React.useEffect(() => { setMinValueLabel(minLabel ?? min.toString()); }, [minLabel, min]); let [maxValueLabel, setMaxValueLabel] = React.useState( () => maxLabel ?? max.toString(), ); React.useEffect(() => { setMaxValueLabel(maxLabel ?? max.toString()); }, [maxLabel, max]); let [trackDisplay, setTrackDisplay] = React.useState(() => getDefaultTrackDisplay(trackDisplayMode, currentValues), ); React.useEffect(() => { setTrackDisplay(getDefaultTrackDisplay(trackDisplayMode, currentValues)); }, [trackDisplayMode, currentValues]); let containerRef = React.useRef(null); let getNumDecimalPlaces = React.useMemo(() => { let stepString = step.toString(); let decimalIndex = stepString.indexOf('.'); return stepString.length - (decimalIndex + 1); }, [step]); let getAllowableThumbRange = React.useCallback( (index) => { if ('inhibit-crossing' === thumbMode) { let minVal = 0 === index ? min : currentValues[index - 1] + step; let maxVal = index < currentValues.length - 1 ? currentValues[index + 1] - step : max; return [minVal, maxVal]; } return [min, max]; }, [max, min, step, thumbMode, currentValues], ); let [activeThumbIndex, setActiveThumbIndex] = React.useState(void 0); let updateThumbValue = React.useCallback( (event, callbackType) => { if (containerRef.current && void 0 !== activeThumbIndex) { let percent = getPercentageOfRectangle( containerRef.current.getBoundingClientRect(), event.clientX, event.clientY, orientation, ); let pointerValue = min + (max - min) * percent; pointerValue = roundValueToClosestStep(pointerValue, step, min); let [minVal, maxVal] = getAllowableThumbRange(activeThumbIndex); pointerValue = getBoundedValue(pointerValue, minVal, maxVal); if (pointerValue !== currentValues[activeThumbIndex]) { let newValues = [...currentValues]; newValues[activeThumbIndex] = pointerValue; setCurrentValues(newValues); 'onChange' === callbackType ? onChange?.(newValues) : onUpdate?.(newValues); } else if ('onChange' === callbackType) onChange?.(currentValues); } }, [ activeThumbIndex, min, max, step, getAllowableThumbRange, currentValues, onUpdate, onChange, orientation, ], ); let handlePointerMove = React.useCallback( (event) => { if (void 0 === activeThumbIndex) return; event.preventDefault(); event.stopPropagation(); updateThumbValue(event, 'onUpdate'); }, [activeThumbIndex, updateThumbValue], ); let onThumbValueChanged = React.useCallback( (index, value, keyboardReleased) => { if (currentValues[index] === value && !keyboardReleased) return; if (keyboardReleased) onChange?.(currentValues); else { let newValues = [...currentValues]; newValues[index] = value; onUpdate?.(newValues); setCurrentValues(newValues); } }, [currentValues, onUpdate, onChange], ); let onThumbActivated = React.useCallback((index) => { setActiveThumbIndex(index); }, []); let handlePointerUp = React.useCallback( (event) => { if (void 0 === activeThumbIndex) return; updateThumbValue(event, 'onChange'); setActiveThumbIndex(void 0); event.preventDefault(); event.stopPropagation(); }, [activeThumbIndex, updateThumbValue], ); let handlePointerDownOnSlider = React.useCallback( (event) => { if (containerRef.current) { let percent = getPercentageOfRectangle( containerRef.current.getBoundingClientRect(), event.clientX, event.clientY, orientation, ); let pointerValue = min + (max - min) * percent; pointerValue = roundValueToClosestStep(pointerValue, step, min); let closestValueIndex = getClosestValueIndex( currentValues, pointerValue, ); let [minVal, maxVal] = getAllowableThumbRange(closestValueIndex); pointerValue = getBoundedValue(pointerValue, minVal, maxVal); if (pointerValue === currentValues[closestValueIndex]) return; let newValues = [...currentValues]; newValues[closestValueIndex] = pointerValue; setCurrentValues(newValues); onChange?.(newValues); onUpdate?.(newValues); focusThumb(containerRef.current, closestValueIndex); event.preventDefault(); event.stopPropagation(); } }, [ min, max, step, currentValues, getAllowableThumbRange, onChange, onUpdate, orientation, ], ); useEventListener( 'pointermove', handlePointerMove, containerRef.current?.ownerDocument, ); useEventListener( 'pointerup', handlePointerUp, containerRef.current?.ownerDocument, ); let tickMarkArea = React.useMemo(() => { if (!tickLabels) return null; if (Array.isArray(tickLabels)) return React.createElement( Box, { as: 'div', ...ticksProps, className: cx('iui-slider-ticks', ticksProps?.className), }, tickLabels.map((label, index) => React.createElement( Box, { as: 'span', ...tickProps, key: index, className: cx('iui-slider-tick', tickProps?.className), }, label, ), ), ); return tickLabels; }, [tickLabels, tickProps, ticksProps]); let generateTooltipProps = React.useCallback( (index, val) => { let outProps = tooltipProps ? tooltipProps(index, val, step) : {}; return { ...outProps, content: outProps.content ? outProps.content : formatNumberValue(val, step, getNumDecimalPlaces), }; }, [getNumDecimalPlaces, step, tooltipProps], ); return React.createElement( Box, { ref: ref, className: cx('iui-slider-container', className), 'data-iui-orientation': orientation, 'data-iui-disabled': disabled ? 'true' : void 0, ...rest, }, minValueLabel && React.createElement( Box, { as: 'span', ...minProps, className: cx('iui-slider-min', minProps?.className), }, minValueLabel, ), React.createElement( FloatingDelayGroup, { delay: defaultTooltipDelay, }, React.createElement( Box, { ref: containerRef, ...trackContainerProps, className: cx( 'iui-slider', { 'iui-grabbing': void 0 !== activeThumbIndex, }, trackContainerProps?.className, ), onPointerDown: handlePointerDownOnSlider, }, currentValues.map((thumbValue, index) => { let [minVal, maxVal] = getAllowableThumbRange(index); let thisThumbProps = thumbProps?.(index); return React.createElement(Thumb, { key: thisThumbProps?.id ?? index, index: index, disabled: disabled, isActive: activeThumbIndex === index, onThumbActivated: onThumbActivated, onThumbValueChanged: onThumbValueChanged, minVal: minVal, maxVal: maxVal, value: thumbValue, tooltipProps: generateTooltipProps(index, thumbValue), thumbProps: thisThumbProps, step: step, sliderMin: min, sliderMax: max, }); }), React.createElement(Track, { trackDisplayMode: trackDisplay, sliderMin: min, sliderMax: max, values: currentValues, orientation: orientation, ...trackProps, }), ), ), tickMarkArea, maxValueLabel && React.createElement( Box, { as: 'span', ...maxProps, className: cx('iui-slider-max', maxProps?.className), }, maxValueLabel, ), ); }); if ('development' === process.env.NODE_ENV) Slider.displayName = 'Slider';