@chayns-components/core
Version:
A set of beautiful React components for developing your own applications with chayns.
385 lines (380 loc) • 14.9 kB
JavaScript
import { setRefreshScrollEnabled } from 'chayns-api';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTheme } from 'styled-components';
import { useElementSize } from '../../hooks/element';
import { useKeyboardFocusHighlighting } from '../../hooks/useKeyboardFocusHighlighting';
import { calculateGradientOffset, calculatePopupPosition, fillSlider, getThumbMaxWidth } from '../../utils/slider';
import { StyledHighlightedStep, StyledSlider, StyledSliderInput, StyledSliderThumb, StyledSliderThumbLabel } from './Slider.styles';
const Slider = ({
interval,
isDisabled,
maxEnabledValue,
maxValue,
minEnabledValue,
minValue,
onChange,
onSelect,
shouldHighlightSteps = false,
shouldEnableKeyboardHighlighting,
shouldShowThumbLabel = false,
step = 1,
thumbLabelFormatter,
value
}) => {
const [fromValue, setFromValue] = useState(minEnabledValue ?? minValue);
const [toValue, setToValue] = useState(maxEnabledValue ?? maxValue);
const [thumbWidth, setThumbWidth] = useState(20);
const [isBigSlider, setIsBigSlider] = useState(false);
const [isFromThumbFocused, setIsFromThumbFocused] = useState(false);
const [isToThumbFocused, setIsToThumbFocused] = useState(false);
const previousFromValueRef = useRef(fromValue);
const previousToValueRef = useRef(toValue);
const fromSliderRef = useRef(null);
const toSliderRef = useRef(null);
const fromSliderThumbRef = useRef(null);
const toSliderThumbRef = useRef(null);
const fromSliderThumbContentRef = useRef(null);
const toSliderThumbContentRef = useRef(null);
const sliderWrapperRef = useRef(null);
const sliderWrapperSize = useElementSize(sliderWrapperRef);
const theme = useTheme();
const shouldShowKeyboardHighlighting = useKeyboardFocusHighlighting(shouldEnableKeyboardHighlighting && !isDisabled);
const updateFromValue = useCallback(nextValue => {
previousFromValueRef.current = nextValue;
setFromValue(nextValue);
}, []);
const updateToValue = useCallback(nextValue => {
previousToValueRef.current = nextValue;
setToValue(nextValue);
}, []);
const normalizeIntervalValue = useCallback(nextValue => {
let newValue = Number(nextValue);
if (newValue > maxValue || newValue > maxValue - maxValue % step) {
newValue = maxValue;
} else if (newValue < minValue) {
newValue = minValue;
} else {
newValue = Math.round(newValue / step) * step;
}
return newValue;
}, [maxValue, minValue, step]);
const applyIntervalThumbChange = useCallback((thumb, rawValue) => {
if (!fromSliderRef.current || !toSliderRef.current) {
return;
}
const newValue = normalizeIntervalValue(rawValue);
const isFromThumb = thumb === 'from';
const previousValue = isFromThumb ? previousFromValueRef.current : previousToValueRef.current;
const hasChanged = newValue !== previousValue;
if (isFromThumb) {
updateFromValue(newValue);
} else {
updateToValue(newValue);
}
const from = Number(fromSliderRef.current.value);
const to = Number(toSliderRef.current.value);
if (hasChanged && typeof onChange === 'function') {
onChange(undefined, {
minValue: isFromThumb ? newValue : from,
maxValue: isFromThumb ? to : newValue
});
}
fillSlider({
toSlider: toSliderRef.current,
fromSlider: fromSliderRef.current,
...(isFromThumb ? {
fromValue: newValue
} : {
toValue: newValue
}),
theme
});
if (isFromThumb) {
fromSliderRef.current.value = String(newValue > to ? to : newValue);
} else {
toSliderRef.current.value = String(from <= newValue ? newValue : from);
}
}, [normalizeIntervalValue, onChange, theme, updateFromValue, updateToValue]);
useEffect(() => {
if (shouldShowThumbLabel) {
setThumbWidth(getThumbMaxWidth({
maxNumber: maxValue,
thumbLabelFormatter
}));
}
}, [maxValue, shouldShowThumbLabel, thumbLabelFormatter]);
/**
* This function sets the value
*/
useEffect(() => {
if (typeof value === 'number' && value >= minValue && value <= maxValue && (typeof minEnabledValue !== 'number' || value >= minEnabledValue) && (typeof maxEnabledValue !== 'number' || value <= maxEnabledValue)) {
updateFromValue(value);
}
}, [maxEnabledValue, maxValue, minEnabledValue, minValue, updateFromValue, value]);
useEffect(() => {
if (fromValue > toValue) {
updateFromValue(toValue);
}
if (toValue < fromValue) {
updateToValue(fromValue);
}
}, [fromValue, toValue, updateFromValue, updateToValue]);
const handleMouseUp = useCallback(() => {
if (isDisabled) {
return;
}
const from = Number(fromSliderRef.current?.value);
const to = Number(toSliderRef.current?.value);
if (typeof onSelect === 'function') {
onSelect(interval ? undefined : from, interval ? {
maxValue: to,
minValue: from
} : undefined);
}
}, [interval, isDisabled, onSelect]);
const handleControlFromSlider = useCallback(event => {
applyIntervalThumbChange('from', Number(event.target.value));
}, [applyIntervalThumbChange]);
const handleControlToSlider = useCallback(event => {
if (isDisabled) {
return;
}
applyIntervalThumbChange('to', Number(event.target.value));
}, [applyIntervalThumbChange, isDisabled]);
useEffect(() => {
if (!fromSliderRef.current || !toSliderRef.current || !interval) {
return;
}
updateFromValue(interval.minValue);
updateToValue(interval.maxValue);
fromSliderRef.current.value = String(interval.minValue);
toSliderRef.current.value = String(interval.maxValue);
fillSlider({
fromSlider: fromSliderRef.current,
toSlider: toSliderRef.current,
theme
});
// Note: An interval can't be in the deps because of rerender
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
/**
* This function updates the value
*/
const handleInputChange = useCallback(event => {
if (isDisabled) {
return;
}
// If interval mode is active, delegate to the "from" handler and return early
if (interval) {
handleControlFromSlider(event);
return;
}
// Respect optionally enabled bounds in addition to absolute min/max
const effectiveMin = typeof minEnabledValue === 'number' ? Math.max(minValue, minEnabledValue) : minValue;
const effectiveMax = typeof maxEnabledValue === 'number' ? Math.min(maxValue, maxEnabledValue) : maxValue;
let newValue = Number(event.target.value);
// Clamp to effective range first
if (Number.isNaN(newValue)) {
newValue = effectiveMin;
}
if (newValue < effectiveMin) newValue = effectiveMin;else if (newValue > effectiveMax - effectiveMax % step) newValue = effectiveMax;else newValue = Math.round(newValue / step) * step;
if (typeof onChange === 'function' && newValue !== previousFromValueRef.current) {
onChange(newValue);
}
updateFromValue(newValue);
}, [handleControlFromSlider, interval, isDisabled, maxEnabledValue, maxValue, minEnabledValue, minValue, onChange, step, updateFromValue]);
const fromSliderThumbPosition = useMemo(() => {
if (typeof fromSliderRef.current?.offsetWidth === 'number' && typeof sliderWrapperSize?.width === 'number') {
return calculateGradientOffset({
maxValue,
minValue,
sliderWidth: fromSliderRef.current.offsetWidth,
thumbWidth: 20,
value: fromValue,
wrapperWidth: sliderWrapperSize.width
});
}
return 0;
}, [fromValue, maxValue, minValue, sliderWrapperSize?.width]);
const toSliderThumbPosition = useMemo(() => {
if (typeof toSliderRef.current?.offsetWidth === 'number' && typeof sliderWrapperSize?.width === 'number') {
return calculateGradientOffset({
maxValue,
minValue,
sliderWidth: toSliderRef.current.offsetWidth,
thumbWidth: 20,
value: toValue,
wrapperWidth: sliderWrapperSize.width
});
}
return 0;
}, [maxValue, minValue, sliderWrapperSize?.width, toValue]);
const toSliderThumbContentPosition = useMemo(() => calculatePopupPosition({
min: minValue,
max: maxValue,
sliderValue: toValue,
popupWidth: thumbWidth
}), [maxValue, minValue, thumbWidth, toValue]);
const fromSliderThumbContentPosition = useMemo(() => calculatePopupPosition({
min: minValue,
max: maxValue,
sliderValue: fromValue,
popupWidth: thumbWidth
}), [fromValue, maxValue, minValue, thumbWidth]);
const handleTouchStart = useCallback(() => {
if (isDisabled) {
return;
}
void setRefreshScrollEnabled(false);
if (shouldShowThumbLabel) {
setIsBigSlider(true);
}
}, [isDisabled, shouldShowThumbLabel]);
const handleTouchEnd = useCallback(() => {
if (isDisabled) {
return;
}
void setRefreshScrollEnabled(true);
const from = Number(fromSliderRef.current?.value);
const to = Number(toSliderRef.current?.value);
if (typeof onSelect === 'function') {
onSelect(interval ? undefined : from, interval ? {
maxValue: to,
minValue: from
} : undefined);
}
if (shouldShowThumbLabel) {
setIsBigSlider(false);
}
}, [interval, isDisabled, onSelect, shouldShowThumbLabel]);
const highlightedStepElements = useMemo(() => {
const sliderWidth = fromSliderRef.current?.offsetWidth ?? 0;
const wrapperWidth = sliderWrapperSize?.width ?? 0;
if (!shouldHighlightSteps || interval || sliderWidth === 0 || wrapperWidth === 0) {
return null;
}
const elements = [];
for (let i = minValue; i <= maxValue; i += step) {
const isStepDisabled = typeof minEnabledValue === 'number' && i < minEnabledValue || typeof maxEnabledValue === 'number' && i > maxEnabledValue;
const offset = (wrapperWidth - sliderWidth) / 2;
const stepWidth = sliderWidth / (maxValue - minValue) * step;
elements.push(/*#__PURE__*/React.createElement(StyledHighlightedStep, {
key: `step--${i}`,
$isDisabled: isStepDisabled,
$isFilled: i < fromValue,
$leftPosition: offset + stepWidth * i
}));
}
return elements;
}, [fromValue, interval, maxEnabledValue, maxValue, minEnabledValue, minValue, shouldHighlightSteps, sliderWrapperSize?.width, step]);
const fromInputBackground = useMemo(() => {
if (interval) return undefined;
const gradientPoints = [];
const getPercentage = x => (x - minValue) / (maxValue - minValue) * 100;
if (typeof minEnabledValue === 'number') {
gradientPoints.push('rgb(215, 215, 215) 0%');
gradientPoints.push(`rgb(215, 215, 215) ${getPercentage(minEnabledValue)}%`);
gradientPoints.push(`${theme['409'] ?? ''} ${getPercentage(minEnabledValue)}%`);
} else {
gradientPoints.push(`${theme['409'] ?? ''} 0%`);
}
gradientPoints.push(`${theme['409'] ?? ''} ${getPercentage(fromValue)}%`);
gradientPoints.push(`${theme['403'] ?? ''} ${getPercentage(fromValue)}%`);
if (typeof maxEnabledValue === 'number') {
gradientPoints.push(`${theme['403'] ?? ''} ${getPercentage(maxEnabledValue)}%`);
gradientPoints.push(`rgb(215, 215, 215) ${getPercentage(maxEnabledValue)}%`);
gradientPoints.push('rgb(215, 215, 215) 100%');
} else {
gradientPoints.push(`${theme['403'] ?? ''} 100%`);
}
return `linear-gradient(to right, ${gradientPoints.join(', ')})`;
}, [fromValue, interval, maxEnabledValue, maxValue, minEnabledValue, minValue, theme]);
return useMemo(() => /*#__PURE__*/React.createElement(StyledSlider, {
ref: sliderWrapperRef,
$isDisabled: isDisabled
}, highlightedStepElements, /*#__PURE__*/React.createElement(StyledSliderInput, {
animate: {
height: isBigSlider ? 30 : 10
},
initial: {
height: 10
},
exit: {
height: 10
},
$thumbWidth: 40,
ref: fromSliderRef,
$isInterval: !!interval,
type: "range",
value: fromValue,
step: step,
max: maxValue,
min: minValue,
onTouchStart: handleTouchStart,
onTouchEnd: handleTouchEnd,
onChange: handleInputChange,
onMouseUp: handleMouseUp,
$background: fromInputBackground,
$shouldShowKeyboardHighlighting: shouldShowKeyboardHighlighting,
onFocus: () => {
setIsFromThumbFocused(true);
setIsToThumbFocused(false);
},
onBlur: () => {
setIsFromThumbFocused(false);
}
}), /*#__PURE__*/React.createElement(StyledSliderThumb, {
ref: fromSliderThumbRef,
$position: fromSliderThumbPosition,
$isBigSlider: isBigSlider,
$shouldShowFocusRing: shouldShowKeyboardHighlighting && isFromThumbFocused
}, shouldShowThumbLabel && /*#__PURE__*/React.createElement(StyledSliderThumbLabel, {
$width: thumbWidth,
$isBigSlider: isBigSlider,
$position: fromSliderThumbContentPosition,
ref: fromSliderThumbContentRef
}, typeof thumbLabelFormatter === 'function' ? thumbLabelFormatter(fromValue) : fromValue)), interval && /*#__PURE__*/React.createElement(StyledSliderThumb, {
ref: toSliderThumbRef,
$position: toSliderThumbPosition,
$isBigSlider: isBigSlider,
$shouldShowFocusRing: shouldShowKeyboardHighlighting && isToThumbFocused
}, shouldShowThumbLabel && /*#__PURE__*/React.createElement(StyledSliderThumbLabel, {
$width: thumbWidth,
$isBigSlider: isBigSlider,
$position: toSliderThumbContentPosition,
ref: toSliderThumbContentRef
}, typeof thumbLabelFormatter === 'function' ? thumbLabelFormatter(toValue) : toValue)), interval && /*#__PURE__*/React.createElement(StyledSliderInput, {
animate: {
height: isBigSlider ? 30 : 10
},
initial: {
height: 10
},
exit: {
height: 10
},
$thumbWidth: 40,
ref: toSliderRef,
$isInterval: !!interval,
type: "range",
value: toValue,
step: step,
max: maxValue,
min: minValue,
onTouchStart: handleTouchStart,
onTouchEnd: handleTouchEnd,
onChange: handleControlToSlider,
onMouseUp: handleMouseUp,
$shouldShowKeyboardHighlighting: shouldShowKeyboardHighlighting,
onFocus: () => {
setIsToThumbFocused(true);
setIsFromThumbFocused(false);
},
onBlur: () => {
setIsToThumbFocused(false);
}
})), [fromInputBackground, fromSliderThumbContentPosition, fromSliderThumbPosition, fromValue, handleControlToSlider, handleInputChange, handleMouseUp, handleTouchEnd, handleTouchStart, highlightedStepElements, interval, isBigSlider, isDisabled, isFromThumbFocused, isToThumbFocused, maxValue, minValue, shouldShowThumbLabel, shouldShowKeyboardHighlighting, thumbLabelFormatter, thumbWidth, toSliderThumbContentPosition, toSliderThumbPosition, toValue]);
};
Slider.displayName = 'Slider';
export default Slider;
//# sourceMappingURL=Slider.js.map