@itwin/itwinui-react
Version:
A react component library for iTwinUI
351 lines (350 loc) • 10.9 kB
JavaScript
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';