From 41cd7f87f54e39100cc2983f320ce7333020ee99 Mon Sep 17 00:00:00 2001 From: Sasha-P Date: Wed, 29 Jul 2026 23:01:03 +0300 Subject: [PATCH] fix RangeSlider allow_cross --- .../src/fragments/RangeSlider.tsx | 684 ++++++++++--- .../sliders/test_rangeslider_allow_cross.py | 940 ++++++++++++++++++ .../sliders/test_sliders_keyboard_input.py | 5 +- 3 files changed, 1496 insertions(+), 133 deletions(-) create mode 100644 components/dash-core-components/tests/integration/sliders/test_rangeslider_allow_cross.py diff --git a/components/dash-core-components/src/fragments/RangeSlider.tsx b/components/dash-core-components/src/fragments/RangeSlider.tsx index 2abf67b977..0059b4f07f 100644 --- a/components/dash-core-components/src/fragments/RangeSlider.tsx +++ b/components/dash-core-components/src/fragments/RangeSlider.tsx @@ -15,6 +15,72 @@ import {RangeSliderProps} from '../types'; const MAX_MARKS = 500; +type SliderInteractionKind = 'keyboard' | 'pointer'; + +interface SliderInteraction { + kind: SliderInteractionKind; + thumbIndex: number | null; + startValue: number[]; +} + +type InputSide = 'max' | 'min'; + +interface InputEdit { + side: InputSide; + text: string; + startValue: number[]; + fixedValue?: number; + lastValidCandidate: number; +} + +interface ThumbValueChange { + candidate: number; + previousIndex: number; +} + +const numbersEqual = (left: number, right: number): boolean => + Object.is(left, right); + +const valuesEqual = (left: number[], right: number[]): boolean => + left.length === right.length && + left.every((item, index) => numbersEqual(item, right[index])); + +const canonicalizeValues = (values: number[]): number[] => + [...values].sort((left, right) => left - right); + +const findThumbValueChange = ( + previousValue: number[], + attemptedValue: number[] +): ThumbValueChange | undefined => { + const unmatchedPrevious = previousValue.map((value, index) => ({ + index, + value, + })); + let candidate: number | undefined; + + for (const attemptedCandidate of attemptedValue) { + const matchIndex = unmatchedPrevious.findIndex(previous => + numbersEqual(previous.value, attemptedCandidate) + ); + + if (matchIndex === -1) { + if (candidate !== undefined) { + return undefined; + } + candidate = attemptedCandidate; + } else { + unmatchedPrevious.splice(matchIndex, 1); + } + } + + return candidate !== undefined && unmatchedPrevious.length === 1 + ? { + candidate, + previousIndex: unmatchedPrevious[0].index, + } + : undefined; +}; + /** * A double slider with two handles. * Used for specifying a range of numerical values. @@ -36,7 +102,7 @@ export default function RangeSlider(props: RangeSliderProps) { disabled, dots, included, - allowCross, + allowCross = true, pushable, count, reverse, @@ -44,22 +110,40 @@ export default function RangeSlider(props: RangeSliderProps) { } = props; // For range slider, we expect an array of values - const [value, setValue] = useState(propValue || []); + const [value, setValue] = useState( + propValue ? [...propValue] : [] + ); + const [inputEdit, setInputEdit] = useState(null); // Track slider dimension (width for horizontal, height for vertical) for marks rendering const [sliderWidth, setSliderWidth] = useState(null); const sliderRef = useRef(null); const inputRef = useRef(null); + const valueRef = useRef(value); + const inputEditRef = useRef(null); + const interactionRef = useRef(null); + const thumbRefs = useRef>([]); + const pendingValueEchoRef = useRef(null); + + const setSliderProps = (newProps: Partial) => { + if (Array.isArray(newProps.value)) { + pendingValueEchoRef.current = [...newProps.value]; + } + setProps(newProps); + }; // Handle initial mount - equivalent to componentWillMount useEffect(() => { if (propValue && propValue.length > 0) { - setProps({drag_value: propValue}); - setValue(propValue); + const initialValue = [...propValue]; + setSliderProps({drag_value: initialValue}); + valueRef.current = initialValue; + setValue(initialValue); } else { // Default to range from min to max if no value provided const defaultValue = [min ?? (propValue ? propValue[0] : 0)]; + valueRef.current = defaultValue; setValue(defaultValue); } }, []); @@ -100,9 +184,46 @@ export default function RangeSlider(props: RangeSliderProps) { // Handle prop value changes - equivalent to componentWillReceiveProps useEffect(() => { - if (propValue && JSON.stringify(propValue) !== JSON.stringify(value)) { - setProps({drag_value: propValue}); - setValue(propValue); + if (propValue) { + const incomingValue = [...propValue]; + const pendingValueEcho = pendingValueEchoRef.current; + const isLocalValueEcho = + pendingValueEcho !== null && + valuesEqual(incomingValue, pendingValueEcho); + pendingValueEchoRef.current = null; + + if (!isLocalValueEcho) { + if (inputEditRef.current) { + inputEditRef.current = null; + setInputEdit(null); + } + + const currentInteraction = interactionRef.current; + if (currentInteraction) { + if ( + currentInteraction.kind === 'keyboard' && + currentInteraction.startValue.length !== + incomingValue.length + ) { + interactionRef.current = null; + } else { + currentInteraction.startValue = [...incomingValue]; + if ( + currentInteraction.thumbIndex !== null && + currentInteraction.thumbIndex >= + incomingValue.length + ) { + currentInteraction.thumbIndex = null; + } + } + } + } + + if (!valuesEqual(incomingValue, valueRef.current)) { + setSliderProps({drag_value: incomingValue}); + valueRef.current = incomingValue; + setValue(incomingValue); + } } }, [propValue]); @@ -163,6 +284,10 @@ export default function RangeSlider(props: RangeSliderProps) { }, [minMaxValues.min_mark, minMaxValues.max_mark, stepValue]); const valueIsValid = (val: number): boolean => { + if (!Number.isFinite(val)) { + return false; + } + // Check if value is within min/max bounds if (val < minMaxValues.min_mark || val > minMaxValues.max_mark) { return false; @@ -233,31 +358,355 @@ export default function RangeSlider(props: RangeSliderProps) { return constrained; }; - const handleValueChange = (newValue: number[]) => { - let adjustedValue = newValue; + const isKeyboardControlKey = (key: string): boolean => + [ + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'End', + 'Home', + 'PageDown', + 'PageUp', + ].includes(key); + + const updateInputEdit = (edit: InputEdit | null) => { + inputEditRef.current = edit; + setInputEdit(edit); + }; + + const updateCurrentValue = (newValue: number[]): boolean => { + const canonicalValue = canonicalizeValues(newValue); + + if (valuesEqual(canonicalValue, valueRef.current)) { + return false; + } + + valueRef.current = canonicalValue; + setValue(canonicalValue); + return true; + }; + + const publishValueChange = (newValue: number[]) => { + const canonicalValue = canonicalizeValues(newValue); + + if (!updateCurrentValue(canonicalValue)) { + return; + } + + if (updatemode === 'drag') { + setSliderProps({ + value: canonicalValue, + drag_value: canonicalValue, + }); + } else { + setSliderProps({drag_value: canonicalValue}); + } + }; + + const commitSliderInteraction = (interaction: SliderInteraction) => { + if ( + updatemode === 'mouseup' && + !valuesEqual(valueRef.current, interaction.startValue) + ) { + setSliderProps({value: [...valueRef.current]}); + } + }; + + const startSliderInteraction = ( + kind: SliderInteractionKind, + thumbIndex: number | null + ) => { + const currentInteraction = interactionRef.current; + + if (currentInteraction) { + if (currentInteraction.thumbIndex === null && thumbIndex !== null) { + currentInteraction.thumbIndex = thumbIndex; + } + + if (currentInteraction.kind === kind) { + return; + } + + // Keep a captured pointer gesture authoritative until release. + // If a pointer starts during a keyboard gesture, commit the + // keyboard value before starting the new pointer transaction. + if (currentInteraction.kind === 'pointer') { + return; + } + + interactionRef.current = null; + commitSliderInteraction(currentInteraction); + } + + interactionRef.current = { + kind, + thumbIndex, + startValue: [...valueRef.current], + }; + }; + + const finishSliderInteraction = (kind: SliderInteractionKind) => { + const currentInteraction = interactionRef.current; + + if (!currentInteraction || currentInteraction.kind !== kind) { + return; + } + + interactionRef.current = null; + commitSliderInteraction(currentInteraction); + }; + + const findClosestThumbFromPointer = ( + event: React.PointerEvent + ): number | null => { + const currentValue = valueRef.current; + if (currentValue.length === 0) { + return null; + } + + const bounds = event.currentTarget.getBoundingClientRect(); + const dimension = vertical ? bounds.height : bounds.width; + if (dimension <= 0) { + return null; + } + + const pointerOffset = vertical + ? event.clientY - bounds.top + : event.clientX - bounds.left; + const pointerRatio = pointerOffset / dimension; + const valuesIncreaseFromStart = vertical ? !!reverse : !reverse; + const valueRatio = valuesIncreaseFromStart + ? pointerRatio + : 1 - pointerRatio; + const pointerValue = + minMaxValues.min_mark + + valueRatio * (minMaxValues.max_mark - minMaxValues.min_mark); + + let closestIndex = 0; + let closestDistance = Math.abs(currentValue[0] - pointerValue); + for (let index = 1; index < currentValue.length; index++) { + const distance = Math.abs(currentValue[index] - pointerValue); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + + return closestIndex; + }; + + const buildValueForThumbCandidate = ( + activeThumbIndex: number, + candidate: number + ): number[] => { + const previousValue = valueRef.current; + + if (allowCross) { + const taggedValues = previousValue.map((item, index) => ({ + originalIndex: index, + value: index === activeThumbIndex ? candidate : item, + })); + taggedValues.sort( + (left, right) => + left.value - right.value || + left.originalIndex - right.originalIndex + ); + + const currentInteraction = interactionRef.current; + if (currentInteraction) { + currentInteraction.thumbIndex = taggedValues.findIndex( + item => item.originalIndex === activeThumbIndex + ); + } + + return taggedValues.map(item => item.value); + } + + const nextValue = [...previousValue]; + const lowerBound = + activeThumbIndex > 0 + ? previousValue[activeThumbIndex - 1] + : minMaxValues.min_mark; + const upperBound = + activeThumbIndex < previousValue.length - 1 + ? previousValue[activeThumbIndex + 1] + : minMaxValues.max_mark; + nextValue[activeThumbIndex] = Math.max( + lowerBound, + Math.min(upperBound, candidate) + ); + return nextValue; + }; + + const deriveValueFromAttempt = (attemptedValue: number[]): number[] => { + const previousValue = valueRef.current; + const currentInteraction = interactionRef.current; + + if (!currentInteraction) { + return canonicalizeValues(attemptedValue); + } + + const valueChange = findThumbValueChange(previousValue, attemptedValue); + if (!valueChange) { + return previousValue; + } + + const activeThumbIndex = + currentInteraction.thumbIndex ?? valueChange.previousIndex; + currentInteraction.thumbIndex = activeThumbIndex; + if ( + activeThumbIndex >= attemptedValue.length || + activeThumbIndex >= previousValue.length + ) { + return canonicalizeValues(attemptedValue); + } - // Snap to nearest marks if step is null and marks exist + let {candidate} = valueChange; if ( step === null && processedMarks && typeof processedMarks === 'object' ) { - const marks = processedMarks; - adjustedValue = newValue.map(val => snapToNearestMark(val, marks)); + candidate = snapToNearestMark(candidate, processedMarks); + } + + return buildValueForThumbCandidate(activeThumbIndex, candidate); + }; + + const handleValueChange = (attemptedValue: number[]) => { + const adjustedValue = deriveValueFromAttempt(attemptedValue); + publishValueChange(adjustedValue); + + const currentInteraction = interactionRef.current; + if (currentInteraction && currentInteraction.thumbIndex !== null) { + const activeThumb = + thumbRefs.current[currentInteraction.thumbIndex]; + + if (activeThumb && document.activeElement !== activeThumb) { + activeThumb.focus(); + } + } + }; + + const createInputEdit = (side: InputSide, text?: string): InputEdit => { + const currentValue = valueRef.current; + const activeIndex = side === 'min' ? 0 : currentValue.length - 1; + const activeValue = + currentValue[activeIndex] ?? + (side === 'min' ? minMaxValues.min_mark : minMaxValues.max_mark); + + return { + side, + text: text ?? String(activeValue), + startValue: [...currentValue], + fixedValue: + currentValue.length === 2 + ? currentValue[side === 'min' ? 1 : 0] + : undefined, + lastValidCandidate: activeValue, + }; + }; + + const beginInputEdit = (side: InputSide) => { + if (inputEditRef.current?.side !== side) { + updateInputEdit(createInputEdit(side)); + } + }; + + const buildDirectInputValue = ( + edit: InputEdit, + candidate: number + ): number[] => { + if (edit.fixedValue === undefined) { + return [candidate]; + } + + if (allowCross) { + return canonicalizeValues([candidate, edit.fixedValue]); + } + + return edit.side === 'min' + ? [Math.min(candidate, edit.fixedValue), edit.fixedValue] + : [edit.fixedValue, Math.max(candidate, edit.fixedValue)]; + }; + + const handleInputChange = (side: InputSide, text: string) => { + const currentEdit = + inputEditRef.current?.side === side + ? inputEditRef.current + : createInputEdit(side, text); + const candidate = parseFloat(text); + const nextEdit = { + ...currentEdit, + text, + lastValidCandidate: valueIsValid(candidate) + ? candidate + : currentEdit.lastValidCandidate, + }; + updateInputEdit(nextEdit); + + if (valueIsValid(candidate)) { + publishValueChange(buildDirectInputValue(nextEdit, candidate)); + } + }; + + const finishInputEdit = (side: InputSide) => { + const currentEdit = inputEditRef.current; + + if (!currentEdit || currentEdit.side !== side) { + return; } - setValue(adjustedValue); + const parsedCandidate = parseFloat(currentEdit.text); + const candidate = constrainToValidValue( + Number.isFinite(parsedCandidate) + ? parsedCandidate + : currentEdit.lastValidCandidate + ); + const finalValue = buildDirectInputValue(currentEdit, candidate); + const currentValueChanged = updateCurrentValue(finalValue); + if (updatemode === 'drag') { - setProps({value: adjustedValue, drag_value: adjustedValue}); + if (currentValueChanged) { + setSliderProps({ + value: finalValue, + drag_value: finalValue, + }); + } } else { - setProps({drag_value: adjustedValue}); + const propsToPublish: Partial = {}; + + if (!valuesEqual(finalValue, currentEdit.startValue)) { + propsToPublish.value = finalValue; + } + if (currentValueChanged) { + propsToPublish.drag_value = finalValue; + } + if ( + propsToPublish.value !== undefined || + propsToPublish.drag_value !== undefined + ) { + setSliderProps(propsToPublish); + } } + + updateInputEdit(null); }; - const handleValueCommit = (newValue: number[]) => { - if (updatemode === 'mouseup') { - setProps({value: newValue}); + const inputDisplayValue = (side: InputSide): string | number => { + if (inputEdit) { + if (inputEdit.side === side) { + return inputEdit.text; + } + + if (inputEdit.fixedValue !== undefined) { + return inputEdit.fixedValue; + } } + + const valueIndex = side === 'min' ? 0 : value.length - 1; + return isNaN(value[valueIndex]) ? '' : value[valueIndex]; }; const classNames = ['dash-slider-container', className].filter(Boolean); @@ -278,62 +727,15 @@ export default function RangeSlider(props: RangeSliderProps) { type="number" className="dash-input-container dash-range-slider-input dash-range-slider-min-input" style={{width: inputWidth}} - value={isNaN(value[0]) ? '' : value[0]} - onChange={e => { - const inputValue = e.target.value; - - // Parse the input value - const newMin = parseFloat(inputValue); - const newValue = [newMin, value[1]]; - setValue(newValue); - // Only update props if value is valid - if (valueIsValid(newMin)) { - if (updatemode === 'drag') { - setProps({ - value: newValue, - drag_value: newValue, - }); - } else { - setProps({ - drag_value: newValue, - }); - } - } - }} - onBlur={e => { - const inputValue = e.target.value; - let newMin: number; - - // If empty, default to current value or min_mark - if (inputValue === '') { - newMin = isNaN(value[0]) - ? minMaxValues.min_mark - : value[0]; - } else { - newMin = parseFloat(inputValue); - newMin = isNaN(newMin) - ? minMaxValues.min_mark - : newMin; - } - - // Constrain to not exceed the max value - newMin = Math.min( - value[1] ?? minMaxValues.max_mark, - newMin - ); - - // Snap to valid value (respecting step and marks) - const constrainedMin = - constrainToValidValue(newMin); - const newValue = [constrainedMin, value[1]]; - setValue(newValue); - if (updatemode === 'mouseup') { - setProps({value: newValue}); - } - }} + value={inputDisplayValue('min')} + onFocus={() => beginInputEdit('min')} + onChange={e => + handleInputChange('min', e.currentTarget.value) + } + onBlur={() => finishInputEdit('min')} pattern="^\\d*\\.?\\d*$" min={minMaxValues.min_mark} - max={isNaN(value[1]) ? max : value[1]} + max={allowCross || isNaN(value[1]) ? max : value[1]} step={step || undefined} disabled={disabled} /> @@ -344,67 +746,15 @@ export default function RangeSlider(props: RangeSliderProps) { type="number" className="dash-input-container dash-range-slider-input dash-range-slider-max-input" style={{width: inputWidth}} - value={ - isNaN(value[value.length - 1]) - ? '' - : value[value.length - 1] + value={inputDisplayValue('max')} + onFocus={() => beginInputEdit('max')} + onChange={e => + handleInputChange('max', e.currentTarget.value) } - onChange={e => { - const inputValue = e.target.value; - - // Parse the input value - const newMax = parseFloat(inputValue); - const newValue = [...value]; - newValue[newValue.length - 1] = newMax; - setValue(newValue); - // Only update props if value is valid - if (valueIsValid(newMax)) { - if (updatemode === 'drag') { - setProps({ - value: newValue, - drag_value: newValue, - }); - } else { - setProps({ - drag_value: newValue, - }); - } - } - }} - onBlur={e => { - const inputValue = e.target.value; - let newMax: number; - - // If empty, default to current value or max_mark - if (inputValue === '') { - newMax = isNaN(value[value.length - 1]) - ? minMaxValues.max_mark - : value[value.length - 1]; - } else { - newMax = parseFloat(inputValue); - newMax = isNaN(newMax) - ? minMaxValues.max_mark - : newMax; - } - // Constrain to not be less than the min value - newMax = Math.max( - value[0] ?? minMaxValues.min_mark, - newMax - ); - - // Snap to valid value (respecting step and marks) - const constrainedMax = - constrainToValidValue(newMax); - const newValue = [...value]; - newValue[newValue.length - 1] = constrainedMax; - setValue(newValue); - if (updatemode === 'mouseup') { - setProps({value: newValue}); - } - }} + onBlur={() => finishInputEdit('max')} pattern="^\\d*\\.?\\d*$" min={ - value.length === 1 + allowCross || value.length === 1 ? minMaxValues.min_mark : value[0] } @@ -433,7 +783,26 @@ export default function RangeSlider(props: RangeSliderProps) { }} value={value} onValueChange={handleValueChange} - onValueCommit={handleValueCommit} + onPointerDown={event => + startSliderInteraction( + 'pointer', + findClosestThumbFromPointer(event) + ) + } + onPointerUp={() => + finishSliderInteraction('pointer') + } + onPointerCancel={() => + finishSliderInteraction('pointer') + } + onLostPointerCapture={() => + finishSliderInteraction('pointer') + } + onKeyUp={e => { + if (isKeyboardControlKey(e.key)) { + finishSliderInteraction('keyboard'); + } + }} min={minMaxValues.min_mark} max={minMaxValues.max_mark} step={stepValue} @@ -480,6 +849,59 @@ export default function RangeSlider(props: RangeSliderProps) { { + thumbRefs.current[index] = thumb; + }} + onPointerDown={() => { + startSliderInteraction( + 'pointer', + index + ); + }} + onKeyDown={e => { + if ( + !disabled && + isKeyboardControlKey(e.key) + ) { + startSliderInteraction( + 'keyboard', + index + ); + if ( + e.key === 'Home' || + e.key === 'End' + ) { + e.preventDefault(); + publishValueChange( + buildValueForThumbCandidate( + index, + e.key === 'Home' + ? constrainToValidValue( + minMaxValues.min_mark + ) + : constrainToValidValue( + minMaxValues.max_mark + ) + ) + ); + const destinationIndex = + interactionRef.current + ?.thumbIndex; + if ( + destinationIndex !== + null && + destinationIndex !== + undefined && + destinationIndex !== + index + ) { + thumbRefs.current[ + destinationIndex + ]?.focus(); + } + } + } + }} > {tooltip && (