From b9812b3fae3134ea67096982e3cfd8e2072ef829 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 2 Jan 2025 15:30:30 +0800 Subject: [PATCH] Add new state for unrounded percentageValues --- .../src/slider/control/SliderControl.test.tsx | 4 +- .../src/slider/control/useSliderControl.ts | 20 +-- .../slider/indicator/SliderIndicator.test.tsx | 4 +- packages/react/src/slider/root/SliderRoot.tsx | 6 +- .../react/src/slider/root/useSliderRoot.ts | 138 ++++++++++++------ .../src/slider/thumb/SliderThumb.test.tsx | 4 +- .../src/slider/track/SliderTrack.test.tsx | 4 +- .../react/src/slider/utils/getSliderValue.ts | 12 +- .../slider/utils/replaceArrayItemAtIndex.ts | 7 + .../react/src/slider/utils/setValueIndex.ts | 15 -- .../src/slider/value/SliderValue.test.tsx | 4 +- 11 files changed, 131 insertions(+), 87 deletions(-) create mode 100644 packages/react/src/slider/utils/replaceArrayItemAtIndex.ts delete mode 100644 packages/react/src/slider/utils/setValueIndex.ts diff --git a/packages/react/src/slider/control/SliderControl.test.tsx b/packages/react/src/slider/control/SliderControl.test.tsx index 9af2b0b692..07d4982407 100644 --- a/packages/react/src/slider/control/SliderControl.test.tsx +++ b/packages/react/src/slider/control/SliderControl.test.tsx @@ -12,8 +12,9 @@ const testRootContext: SliderRootContext = { disabled: false, getFingerState: () => ({ value: 0, - closestThumbIndex: 0, percentageValue: 0, + percentageValues: [0], + thumbIndex: 0, }), setValue: NOOP, largeStep: 10, @@ -42,6 +43,7 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, step: 1, tabIndex: null, diff --git a/packages/react/src/slider/control/useSliderControl.ts b/packages/react/src/slider/control/useSliderControl.ts index 8cf1b0fa34..fc865654c5 100644 --- a/packages/react/src/slider/control/useSliderControl.ts +++ b/packages/react/src/slider/control/useSliderControl.ts @@ -73,14 +73,14 @@ export function useSliderControl( return; } - focusThumb(finger.closestThumbIndex, controlRef, setActive); + focusThumb(finger.thumbIndex, controlRef, setActive); if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) { if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { setDragging(true); } - setValue(finger.value, finger.closestThumbIndex, nativeEvent); + setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent); } }); @@ -130,9 +130,9 @@ export function useSliderControl( return; } - focusThumb(finger.closestThumbIndex, controlRef, setActive); + focusThumb(finger.thumbIndex, controlRef, setActive); - setValue(finger.value, finger.closestThumbIndex, nativeEvent); + setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent); } moveCountRef.current = 0; @@ -203,20 +203,16 @@ export function useSliderControl( return; } - focusThumb(finger.closestThumbIndex, controlRef, setActive); + focusThumb(finger.thumbIndex, controlRef, setActive); // if the event lands on a thumb, don't change the value, just get the // percentageValue difference represented by the distance between the click origin // and the coordinates of the value on the track area if (thumbRefs.current.includes(event.target as HTMLElement)) { - const targetThumbIndex = (event.target as HTMLElement).getAttribute('data-index'); - - const offset = - percentageValues[Number(targetThumbIndex)] / 100 - finger.percentageValue; - - offsetRef.current = offset; + offsetRef.current = + percentageValues[finger.thumbIndex] / 100 - finger.percentageValue; } else { - setValue(finger.value, finger.closestThumbIndex, event.nativeEvent); + setValue(finger.value, finger.percentageValues, finger.thumbIndex, event.nativeEvent); } } diff --git a/packages/react/src/slider/indicator/SliderIndicator.test.tsx b/packages/react/src/slider/indicator/SliderIndicator.test.tsx index b7faffef3d..49ac011d9c 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.test.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.test.tsx @@ -12,8 +12,9 @@ const testRootContext: SliderRootContext = { disabled: false, getFingerState: () => ({ value: 0, - closestThumbIndex: 0, percentageValue: 0, + percentageValues: [0], + thumbIndex: 0, }), setValue: NOOP, largeStep: 10, @@ -42,6 +43,7 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, step: 1, tabIndex: null, diff --git a/packages/react/src/slider/root/SliderRoot.tsx b/packages/react/src/slider/root/SliderRoot.tsx index 15523aff68..ad3e1cad8e 100644 --- a/packages/react/src/slider/root/SliderRoot.tsx +++ b/packages/react/src/slider/root/SliderRoot.tsx @@ -157,7 +157,7 @@ export namespace SliderRoot { /** * The raw number value of the slider. */ - values: ReadonlyArray; + values: readonly number[]; } export interface Props @@ -182,7 +182,7 @@ export namespace SliderRoot { * * To render a controlled slider, use the `value` prop instead. */ - defaultValue?: number | ReadonlyArray; + defaultValue?: number | readonly number[]; /** * Whether the component should ignore user interaction. * @default false @@ -200,7 +200,7 @@ export namespace SliderRoot { * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | readonly number[]; } } diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts index 7c5b0d9646..cfe3f6960d 100644 --- a/packages/react/src/slider/root/useSliderRoot.ts +++ b/packages/react/src/slider/root/useSliderRoot.ts @@ -18,8 +18,8 @@ import { useFieldControlValidation } from '../../field/control/useFieldControlVa import { asc } from '../utils/asc'; import { getSliderValue } from '../utils/getSliderValue'; import { percentToValue } from '../utils/percentToValue'; +import { replaceArrayItemAtIndex } from '../utils/replaceArrayItemAtIndex'; import { roundValueToStep } from '../utils/roundValueToStep'; -import { setValueIndex } from '../utils/setValueIndex'; import { ThumbMetadata } from '../thumb/useSliderThumb'; function areValuesEqual( @@ -35,7 +35,7 @@ function areValuesEqual( return false; } -function findClosest(values: number[], currentValue: number) { +function findClosest(values: readonly number[], currentValue: number) { const { index: closestIndex } = values.reduce<{ distance: number; index: number } | null>( (acc, value: number, index: number) => { @@ -208,14 +208,41 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo [inputValidationRef], ); + const range = Array.isArray(valueUnwrapped); + + const values = React.useMemo(() => { + return (range ? valueUnwrapped.slice().sort(asc) : [valueUnwrapped]).map((val) => + val == null ? min : clamp(val, min, max), + ); + }, [max, min, range, valueUnwrapped]); + + function initializePercentageValues() { + // console.log('initializePercentageValues'); + const vals = []; + for (let i = 0; i < values.length; i += 1) { + vals.push(valueToPercent(values[i], min, max)); + } + return vals; + } + + const [percentageValues, setPercentageValues] = React.useState( + initializePercentageValues, + ); + // console.log('percentageValues', percentageValues); + const setValue = useEventCallback( - (newValue: number | readonly number[], thumbIndex: number, event: Event) => { + ( + newValue: number | readonly number[], + newPercentageValues: readonly number[], + thumbIndex: number, + event: Event, + ) => { if (areValuesEqual(newValue, valueUnwrapped)) { return; } setValueUnwrapped(newValue); - + setPercentageValues(newPercentageValues); // 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 @@ -232,14 +259,6 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo }, ); - const range = Array.isArray(valueUnwrapped); - - const values = React.useMemo(() => { - return (range ? valueUnwrapped.slice().sort(asc) : [valueUnwrapped]).map((val) => - val == null ? min : clamp(val, min, max), - ); - }, [max, min, range, valueUnwrapped]); - const handleRootRef = useForkRef(rootRef, sliderRef); const handleInputChange = useEventCallback( @@ -258,7 +277,20 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo } if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) { - setValue(newValue, index, event.nativeEvent); + if (Array.isArray(newValue)) { + setValue( + newValue, + replaceArrayItemAtIndex( + percentageValues, + index, + valueToPercent(newValue[index], min, max), + ), + index, + event.nativeEvent, + ); + } else { + setValue(newValue, [valueToPercent(newValue, min, max)], index, event.nativeEvent); + } setDirty(newValue !== validityData.initialValue); setTouched(true); commitValidation(newValue); @@ -272,12 +304,14 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo const getFingerState = useEventCallback( ( fingerPosition: FingerPosition | null, - /** * When `true`, closestThumbIndexRef is updated. * It's `true` when called by touchstart or pointerdown. */ shouldCaptureThumbIndex: boolean = false, + /** + * The pixel distance between the finger origin and the center of the thumb. + */ offset: number = 0, ) => { if (fingerPosition == null) { @@ -294,30 +328,29 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo const isVertical = orientation === 'vertical'; const { width, height, bottom, left } = sliderControl.getBoundingClientRect(); - let percent; - if (isVertical) { - percent = (bottom - fingerPosition.y) / height + offset; - } else { - percent = (fingerPosition.x - left) / width + offset * (isRtl ? -1 : 1); - } + // percent is a value between 0 and 1, e.g. "41%" is `0.41` + const valueRescaled = isVertical + ? (bottom - fingerPosition.y) / height + offset + : (fingerPosition.x - left) / width + offset * (isRtl ? -1 : 1); - percent = Math.min(percent, 1); + let percentageValue = Math.min(valueRescaled, 1); if (isRtl && !isVertical) { - percent = 1 - percent; - } - - let newValue; - newValue = percentToValue(percent, min, max); - if (step) { - newValue = roundValueToStep(newValue, step, min); + percentageValue = 1 - percentageValue; } + let newValue = percentToValue(percentageValue, min, max); + newValue = roundValueToStep(newValue, step, min); newValue = clamp(newValue, min, max); if (!range) { - return { value: newValue, percentageValue: percent, closestThumbIndex: 0 }; + return { + value: newValue, + percentageValue, + percentageValues: [percentageValue * 100], + thumbIndex: 0, + }; } if (shouldCaptureThumbIndex) { @@ -333,13 +366,16 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo values[closestThumbIndex + 1] - minStepsBetweenValues || Infinity, ); - newValue = setValueIndex({ - values, - newValue, - index: closestThumbIndex, - }); - - return { value: newValue, percentageValue: percent, closestThumbIndex }; + return { + value: replaceArrayItemAtIndex(values, closestThumbIndex, newValue), + percentageValue, + percentageValues: replaceArrayItemAtIndex( + percentageValues, + closestThumbIndex, + percentageValue * 100, + ), + thumbIndex: closestThumbIndex, + }; }, ); @@ -386,11 +422,12 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo name, onValueCommitted, orientation, - percentageValues: values.map((v) => valueToPercent(v, min, max)), + percentageValues, range, registerSliderControl, setActive, setDragging, + setPercentageValues, setThumbMap, setValue, step, @@ -414,10 +451,12 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo name, onValueCommitted, orientation, + percentageValues, range, registerSliderControl, setActive, setDragging, + setPercentageValues, setThumbMap, setValue, step, @@ -448,7 +487,7 @@ export namespace useSliderRoot { /** * The default value. Use when the component is not controlled. */ - defaultValue?: number | ReadonlyArray; + defaultValue?: number | readonly number[]; /** * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side. * @default 'ltr' @@ -526,7 +565,7 @@ export namespace useSliderRoot { * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | readonly number[]; } export interface ReturnValue { @@ -548,17 +587,23 @@ export namespace useSliderRoot { disabled: boolean; getFingerState: ( fingerPosition: FingerPosition | null, - move?: boolean, + shouldCaptureThumbIndex?: boolean, offset?: number, ) => { value: number | number[]; percentageValue: number; - closestThumbIndex: number; + percentageValues: number[]; + thumbIndex: number; } | null; /** * Callback to invoke change handlers after internal value state is updated. */ - setValue: (newValue: number | readonly number[], activeThumb: number, event: Event) => void; + setValue: ( + newValue: number | readonly number[], + newPercentageValue: readonly number[], + activeThumb: number, + event: Event, + ) => void; /** * The large step value of the slider when incrementing or decrementing while the shift key is held, * or when using Page-Up or Page-Down keys. Snaps to multiples of this value. @@ -589,9 +634,12 @@ export namespace useSliderRoot { * The value(s) of the slider as percentages */ percentageValues: readonly number[]; - setActive: (activeIndex: number) => void; - setDragging: (isDragging: boolean) => void; - setThumbMap: (map: Map | null>) => void; + setActive: React.Dispatch>; + setDragging: React.Dispatch>; + setPercentageValues: React.Dispatch>; + setThumbMap: React.Dispatch< + React.SetStateAction | null>> + >; /** * The step increment of the slider when incrementing or decrementing. It will snap * to multiples of this value. Decimal values are supported. diff --git a/packages/react/src/slider/thumb/SliderThumb.test.tsx b/packages/react/src/slider/thumb/SliderThumb.test.tsx index bca5ebe0b8..86e58c3368 100644 --- a/packages/react/src/slider/thumb/SliderThumb.test.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.test.tsx @@ -12,8 +12,9 @@ const testRootContext: SliderRootContext = { disabled: false, getFingerState: () => ({ value: 0, - closestThumbIndex: 0, percentageValue: 0, + percentageValues: [0], + thumbIndex: 0, }), setValue: NOOP, largeStep: 10, @@ -42,6 +43,7 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, step: 1, tabIndex: null, diff --git a/packages/react/src/slider/track/SliderTrack.test.tsx b/packages/react/src/slider/track/SliderTrack.test.tsx index f84ba363e9..f8e75b411e 100644 --- a/packages/react/src/slider/track/SliderTrack.test.tsx +++ b/packages/react/src/slider/track/SliderTrack.test.tsx @@ -12,8 +12,9 @@ const testRootContext: SliderRootContext = { disabled: false, getFingerState: () => ({ value: 0, - closestThumbIndex: 0, percentageValue: 0, + percentageValues: [0], + thumbIndex: 0, }), setValue: NOOP, largeStep: 10, @@ -42,6 +43,7 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, step: 1, tabIndex: null, diff --git a/packages/react/src/slider/utils/getSliderValue.ts b/packages/react/src/slider/utils/getSliderValue.ts index efe7d2019f..b031f84d92 100644 --- a/packages/react/src/slider/utils/getSliderValue.ts +++ b/packages/react/src/slider/utils/getSliderValue.ts @@ -1,5 +1,5 @@ import { clamp } from '../../utils/clamp'; -import { setValueIndex } from './setValueIndex'; +import { replaceArrayItemAtIndex } from './replaceArrayItemAtIndex'; interface GetSliderValueParameters { valueInput: number; @@ -18,14 +18,12 @@ export function getSliderValue(params: GetSliderValueParameters) { newValue = clamp(newValue, min, max); if (range) { - // Bound the new value to the thumb's neighbours. - newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity); - - newValue = setValueIndex({ + newValue = replaceArrayItemAtIndex( values, - newValue, index, - }); + // Bound the new value to the thumb's neighbours. + clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity), + ); } return newValue; diff --git a/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts b/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts new file mode 100644 index 0000000000..4864f9591e --- /dev/null +++ b/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts @@ -0,0 +1,7 @@ +import { asc } from './asc'; + +export function replaceArrayItemAtIndex(array: readonly number[], index: number, newValue: number) { + const output = array.slice(); + output[index] = newValue; + return output.sort(asc); +} diff --git a/packages/react/src/slider/utils/setValueIndex.ts b/packages/react/src/slider/utils/setValueIndex.ts deleted file mode 100644 index 291e096495..0000000000 --- a/packages/react/src/slider/utils/setValueIndex.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { asc } from './asc'; - -export function setValueIndex({ - values, - newValue, - index, -}: { - values: readonly number[]; - newValue: number; - index: number; -}) { - const output = values.slice(); - output[index] = newValue; - return output.sort(asc); -} diff --git a/packages/react/src/slider/value/SliderValue.test.tsx b/packages/react/src/slider/value/SliderValue.test.tsx index 5056c3715c..c060d813d8 100644 --- a/packages/react/src/slider/value/SliderValue.test.tsx +++ b/packages/react/src/slider/value/SliderValue.test.tsx @@ -14,8 +14,9 @@ const testRootContext: SliderRootContext = { disabled: false, getFingerState: () => ({ value: 0, - closestThumbIndex: 0, percentageValue: 0, + percentageValues: [0], + thumbIndex: 0, }), setValue: NOOP, largeStep: 10, @@ -44,6 +45,7 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, step: 1, tabIndex: null,