From 9e8b5912ec9cccd4e5331b8e864cc0813efa79ce Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 26 Dec 2024 05:05:45 +0800 Subject: [PATCH] WIP --- .../src/slider/control/SliderControl.test.tsx | 1 - .../src/slider/control/SliderControl.tsx | 14 +- .../src/slider/control/useSliderControl.ts | 23 +- .../slider/indicator/SliderIndicator.test.tsx | 1 - .../src/slider/indicator/SliderIndicator.tsx | 4 +- .../slider/indicator/useSliderIndicator.ts | 17 +- packages/react/src/slider/root/SliderRoot.tsx | 43 +++- .../react/src/slider/root/useSliderRoot.ts | 221 ++++++++++-------- .../src/slider/thumb/SliderThumb.test.tsx | 1 - .../react/src/slider/thumb/SliderThumb.tsx | 2 - .../react/src/slider/thumb/useSliderThumb.ts | 71 ++++-- .../src/slider/track/SliderTrack.test.tsx | 1 - packages/react/src/slider/utils/rescale.ts | 5 + .../src/slider/utils/toValidatedValue.ts | 39 ++++ packages/react/src/slider/utils/wrapValue.ts | 10 + .../src/slider/value/SliderValue.test.tsx | 1 - .../react/src/slider/value/SliderValue.tsx | 19 +- .../react/src/slider/value/useSliderValue.ts | 18 +- 18 files changed, 327 insertions(+), 164 deletions(-) create mode 100644 packages/react/src/slider/utils/rescale.ts create mode 100644 packages/react/src/slider/utils/toValidatedValue.ts create mode 100644 packages/react/src/slider/utils/wrapValue.ts diff --git a/packages/react/src/slider/control/SliderControl.test.tsx b/packages/react/src/slider/control/SliderControl.test.tsx index 9af2b0b692..158576e432 100644 --- a/packages/react/src/slider/control/SliderControl.test.tsx +++ b/packages/react/src/slider/control/SliderControl.test.tsx @@ -38,7 +38,6 @@ const testRootContext: SliderRootContext = { dirty: false, touched: false, }, - percentageValues: [0], registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, diff --git a/packages/react/src/slider/control/SliderControl.tsx b/packages/react/src/slider/control/SliderControl.tsx index 46e449ee6f..6486250e97 100644 --- a/packages/react/src/slider/control/SliderControl.tsx +++ b/packages/react/src/slider/control/SliderControl.tsx @@ -24,32 +24,32 @@ const SliderControl = React.forwardRef(function SliderControl( disabled, dragging, getFingerState, - setValue, - minStepsBetweenValues, + minimumAdjacentDifference, onValueCommitted, - state, - percentageValues, registerSliderControl, setActive, setDragging, + setValue, + state, step, thumbRefs, + values, } = useSliderRootContext(); const { getRootProps } = useSliderControl({ disabled, dragging, getFingerState, - setValue, - minStepsBetweenValues, + minimumAdjacentDifference, onValueCommitted, - percentageValues, registerSliderControl, rootRef: forwardedRef, setActive, setDragging, + setValue, step, thumbRefs, + values, }); const { renderElement } = useComponentRenderer({ diff --git a/packages/react/src/slider/control/useSliderControl.ts b/packages/react/src/slider/control/useSliderControl.ts index cfa23db030..566e2df96f 100644 --- a/packages/react/src/slider/control/useSliderControl.ts +++ b/packages/react/src/slider/control/useSliderControl.ts @@ -24,8 +24,8 @@ export function useSliderControl( getFingerState, setValue, onValueCommitted, - minStepsBetweenValues, - percentageValues, + minimumAdjacentDifference, + values, registerSliderControl, rootRef: externalRef, setActive, @@ -68,19 +68,19 @@ export function useSliderControl( } const finger = getFingerState(fingerPosition, false, offsetRef.current); - + // console.log(finger); if (finger == null) { return; } focusThumb(finger.closestThumbIndex, controlRef, setActive); - if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) { + if (validateMinimumDistance(finger.valueUnwrapped, minimumAdjacentDifference)) { if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { setDragging(true); } - setValue(finger.value, finger.closestThumbIndex, nativeEvent); + setValue(finger.percentageValue, finger.closestThumbIndex, nativeEvent); } }); @@ -132,7 +132,7 @@ export function useSliderControl( focusThumb(finger.closestThumbIndex, controlRef, setActive); - setValue(finger.value, finger.closestThumbIndex, nativeEvent); + setValue(finger.percentageValue, finger.closestThumbIndex, nativeEvent); } moveCountRef.current = 0; @@ -211,12 +211,11 @@ export function useSliderControl( 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; + const offset = values[Number(targetThumbIndex)] - finger.percentageValue; offsetRef.current = offset; } else { - setValue(finger.value, finger.closestThumbIndex, event.nativeEvent); + setValue(finger.percentageValue, finger.closestThumbIndex, event.nativeEvent); } } @@ -235,7 +234,7 @@ export function useSliderControl( handleTouchMove, handleTouchEnd, setValue, - percentageValues, + values, setActive, thumbRefs, ], @@ -257,9 +256,9 @@ export namespace useSliderControl { | 'dragging' | 'getFingerState' | 'setValue' - | 'minStepsBetweenValues' + | 'minimumAdjacentDifference' | 'onValueCommitted' - | 'percentageValues' + | 'values' | 'registerSliderControl' | 'setActive' | 'setDragging' diff --git a/packages/react/src/slider/indicator/SliderIndicator.test.tsx b/packages/react/src/slider/indicator/SliderIndicator.test.tsx index b7faffef3d..779c59cda3 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.test.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.test.tsx @@ -38,7 +38,6 @@ const testRootContext: SliderRootContext = { dirty: false, touched: false, }, - percentageValues: [0], registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, diff --git a/packages/react/src/slider/indicator/SliderIndicator.tsx b/packages/react/src/slider/indicator/SliderIndicator.tsx index 7c09a0ae60..20a7f739ac 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.tsx @@ -20,13 +20,13 @@ const SliderIndicator = React.forwardRef(function SliderIndicator( ) { const { render, className, ...otherProps } = props; - const { direction, disabled, orientation, state, percentageValues } = useSliderRootContext(); + const { direction, disabled, orientation, state, values } = useSliderRootContext(); const { getRootProps } = useSliderIndicator({ direction, disabled, orientation, - percentageValues, + values, }); const { renderElement } = useComponentRenderer({ diff --git a/packages/react/src/slider/indicator/useSliderIndicator.ts b/packages/react/src/slider/indicator/useSliderIndicator.ts index c065d2eef9..23a6df5e91 100644 --- a/packages/react/src/slider/indicator/useSliderIndicator.ts +++ b/packages/react/src/slider/indicator/useSliderIndicator.ts @@ -25,25 +25,25 @@ function getRangeStyles(orientation: useSliderRoot.Orientation, offset: number, export function useSliderIndicator( parameters: useSliderIndicator.Parameters, ): useSliderIndicator.ReturnValue { - const { orientation, percentageValues } = parameters; + const { orientation, values } = parameters; let internalStyles; - if (percentageValues.length > 1) { - const trackOffset = percentageValues[0]; - const trackLeap = percentageValues[percentageValues.length - 1] - trackOffset; + if (values.length > 1) { + const trackOffset = values[0] * 100; + const trackLeap = values[values.length - 1] * 100 - trackOffset; internalStyles = getRangeStyles(orientation, trackOffset, trackLeap); } else if (orientation === 'vertical') { internalStyles = { bottom: 0, - height: `${percentageValues[0]}%`, + height: `${values[0] * 100}%`, width: 'inherit', }; } else { internalStyles = { insetInlineStart: 0, - width: `${percentageValues[0]}%`, + width: `${values[0] * 100}%`, height: 'inherit', }; } @@ -67,10 +67,7 @@ export function useSliderIndicator( export namespace useSliderIndicator { export interface Parameters - extends Pick< - useSliderRoot.ReturnValue, - 'direction' | 'disabled' | 'orientation' | 'percentageValues' - > {} + extends Pick {} export interface ReturnValue { getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; diff --git a/packages/react/src/slider/root/SliderRoot.tsx b/packages/react/src/slider/root/SliderRoot.tsx index 15523aff68..5dc378251f 100644 --- a/packages/react/src/slider/root/SliderRoot.tsx +++ b/packages/react/src/slider/root/SliderRoot.tsx @@ -5,6 +5,7 @@ import { NOOP } from '../../utils/noop'; import type { BaseUIComponentProps } from '../../utils/types'; import { useBaseUiId } from '../../utils/useBaseUiId'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; +import { valueToPercent } from '../../utils/valueToPercent'; import type { FieldRoot } from '../../field/root/FieldRoot'; import { CompositeList } from '../../composite/list/CompositeList'; import { useDirection } from '../../direction-provider/DirectionContext'; @@ -26,7 +27,7 @@ const SliderRoot = React.forwardRef(function SliderRoot( const { 'aria-labelledby': ariaLabelledby, className, - defaultValue, + defaultValue: defaultValueProp, disabled: disabledProp = false, id: idProp, format, @@ -41,7 +42,7 @@ const SliderRoot = React.forwardRef(function SliderRoot( orientation = 'horizontal', step = 1, tabIndex: externalTabIndex, - value, + value: valueProp, ...otherProps } = props; @@ -51,6 +52,40 @@ const SliderRoot = React.forwardRef(function SliderRoot( const { labelId, state: fieldState, disabled: fieldDisabled } = useFieldRootContext(); const disabled = fieldDisabled || disabledProp; + const defaultValue = React.useMemo(() => { + if (valueProp === undefined) { + if (Array.isArray(defaultValueProp)) { + const percentages = []; + for (let i = 0; i < defaultValueProp.length; i += 1) { + percentages.push(valueToPercent(defaultValueProp[i], min, max) / 100); + } + return percentages; + } + if (defaultValueProp !== undefined) { + return valueToPercent(defaultValueProp, min, max) / 100; + } + } + + return undefined; + }, [defaultValueProp, valueProp, min, max]); + + const value = React.useMemo(() => { + if (defaultValueProp === undefined) { + if (Array.isArray(valueProp)) { + const percentages = []; + for (let i = 0; i < valueProp.length; i += 1) { + percentages.push(valueToPercent(valueProp[i], min, max)); + } + return percentages; + } + if (valueProp !== undefined) { + return valueToPercent(valueProp, min, max); + } + } + + return undefined; + }, [defaultValueProp, valueProp, min, max]); + const { getRootProps, ...slider } = useSliderRoot({ 'aria-labelledby': ariaLabelledby ?? labelId ?? '', defaultValue, @@ -182,7 +217,7 @@ export namespace SliderRoot { * * To render a controlled slider, use the `value` prop instead. */ - defaultValue?: number | ReadonlyArray; + defaultValue?: number | number[]; /** * Whether the component should ignore user interaction. * @default false @@ -200,7 +235,7 @@ export namespace SliderRoot { * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | number[]; } } diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts index 16d07d2a53..bc6eda9a7b 100644 --- a/packages/react/src/slider/root/useSliderRoot.ts +++ b/packages/react/src/slider/root/useSliderRoot.ts @@ -9,17 +9,18 @@ import { useControlled } from '../../utils/useControlled'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useEventCallback } from '../../utils/useEventCallback'; import { useForkRef } from '../../utils/useForkRef'; -import { valueToPercent } from '../../utils/valueToPercent'; import type { CompositeMetadata } from '../../composite/list/CompositeList'; import type { TextDirection } from '../../direction-provider/DirectionContext'; import { useField } from '../../field/useField'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation'; -import { percentToValue, roundValueToStep } from '../utils'; +import type { ThumbMetadata } from '../thumb/useSliderThumb'; import { asc } from '../utils/asc'; -import { setValueIndex } from '../utils/setValueIndex'; import { getSliderValue } from '../utils/getSliderValue'; -import { ThumbMetadata } from '../thumb/useSliderThumb'; +import { rescale } from '../utils/rescale'; +import { setValueIndex } from '../utils/setValueIndex'; +import { toValidatedValues } from '../utils/toValidatedValue'; +import { wrapValue } from '../utils/wrapValue'; function areValuesEqual( newValue: number | readonly number[], @@ -35,23 +36,22 @@ function areValuesEqual( } function findClosest(values: number[], currentValue: number) { - const { index: closestIndex } = - values.reduce<{ distance: number; index: number } | null>( - (acc, value: number, index: number) => { - const distance = Math.abs(currentValue - value); - - if (acc === null || distance < acc.distance || distance === acc.distance) { - return { - distance, - index, - }; - } - - return acc; - }, - null, - ) ?? {}; - return closestIndex; + const result = values.reduce<{ distance: number; index: number } | null>( + (acc, value: number, index: number) => { + const distance = Math.abs(currentValue - value); + + if (acc === null || distance < acc.distance || distance === acc.distance) { + return { + distance, + index, + }; + } + + return acc; + }, + null, + ); + return result?.index ?? -1; } export function focusThumb( @@ -83,12 +83,12 @@ export function focusThumb( export function validateMinimumDistance( values: number | readonly number[], - step: number, - minStepsBetweenValues: number, + minimumAdjacentDifference: number, ) { if (!Array.isArray(values)) { return true; } + console.log('validateMinimumDistance', values, minimumAdjacentDifference); const distances = values.reduce((acc: number[], val, index, vals) => { if (index === vals.length - 1) { @@ -99,8 +99,9 @@ export function validateMinimumDistance( return acc; }, []); - - return Math.min(...distances) >= step * minStepsBetweenValues; + console.log('distances', distances); + console.log('result', Math.min(...distances) >= minimumAdjacentDifference); + return Math.min(...distances) >= minimumAdjacentDifference; } export function trackFinger( @@ -130,8 +131,6 @@ export function trackFinger( }; } -/** - */ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRoot.ReturnValue { const { 'aria-labelledby': ariaLabelledby, @@ -167,6 +166,7 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo default: defaultValue ?? min, name: 'Slider', }); + // console.log(valueUnwrapped); const sliderRef = React.useRef(null); const controlRef: React.RefObject = React.useRef(null); @@ -207,61 +207,84 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo [inputValidationRef], ); - const setValue = useEventCallback( - (newValue: number | readonly number[], thumbIndex: number, event: Event) => { - if (areValuesEqual(newValue, valueUnwrapped)) { - return; - } + const range = Array.isArray(valueUnwrapped); - setValueUnwrapped(newValue); + const setValue = useEventCallback((percentageValue: number, thumbIndex: number, event: Event) => { + const newPercentageValue = Array.isArray(valueUnwrapped) + ? setValueIndex({ + values: valueUnwrapped, + newValue: percentageValue, + index: thumbIndex, + }) + : percentageValue; + + if (areValuesEqual(newPercentageValue, valueUnwrapped)) { + return; + } + // console.log('newValue', newValue); - // 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 - // Clone the event to not override `target` of the original event. - // @ts-ignore The nativeEvent is function, not object - const clonedEvent = new event.constructor(event.type, event); + setValueUnwrapped(newPercentageValue); - Object.defineProperty(clonedEvent, 'target', { - writable: true, - value: { value: newValue, name }, - }); + // 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 + // Clone the event to not override `target` of the original event. + // @ts-ignore The nativeEvent is function, not object + const clonedEvent = new event.constructor(event.type, event); - onValueChange(newValue, clonedEvent, thumbIndex); - }, - ); + const newValue = toValidatedValues(newPercentageValue, step, min, max); - const range = Array.isArray(valueUnwrapped); + Object.defineProperty(clonedEvent, 'target', { + writable: true, + value: { value: newValue, name }, + }); + + onValueChange(newValue, clonedEvent, thumbIndex); + }); const values = React.useMemo(() => { - return (range ? valueUnwrapped.slice().sort(asc) : [valueUnwrapped]).map((val) => - val == null ? min : clamp(val, min, max), - ); - }, [max, min, range, valueUnwrapped]); + if (!Array.isArray(valueUnwrapped)) { + return [valueUnwrapped]; + } + + const sorted = valueUnwrapped.slice().sort(asc); + const vals = []; + for (let i = 0; i < sorted.length; i += 1) { + vals.push(sorted[i]); + } + return vals; + }, [valueUnwrapped]); const handleRootRef = useForkRef(rootRef, sliderRef); + const minimumAdjacentDifference = rescale(minStepsBetweenValues * step, min, max); + const handleInputChange = useEventCallback( (valueInput: number, index: number, event: React.KeyboardEvent | React.ChangeEvent) => { - const newValue = getSliderValue({ + console.log('valueInput', valueInput); + // valueInput - unwrapped percentage + const newValueUnwrapped = getSliderValue({ valueInput, - min, - max, + min: 0, + max: 1, index, range, values, }); + console.log('newValueUnwrapped', newValueUnwrapped); + + // let newValueWrapped = if (range) { focusThumb(index, sliderRef); } - if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) { - setValue(newValue, index, event.nativeEvent); - setDirty(newValue !== validityData.initialValue); + if (validateMinimumDistance(newValueUnwrapped, minimumAdjacentDifference)) { + setValue(valueInput, index, event.nativeEvent); + setDirty(newValueUnwrapped !== validityData.initialValue); setTouched(true); - commitValidation(newValue); - onValueCommitted(newValue, event.nativeEvent); + commitValidation(valueInput); + onValueCommitted(valueInput, event.nativeEvent); } }, ); @@ -302,47 +325,57 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo if (isRtl && !isVertical) { percent = 1 - percent; } - - let newValue; - newValue = percentToValue(percent, min, max); - if (step) { - newValue = roundValueToStep(newValue, step, min); - } - - newValue = clamp(newValue, min, max); - let closestThumbIndex = 0; + // e.g. percent === 0.429931 if (!range) { - return { value: newValue, percentageValue: percent, closestThumbIndex }; + return { + value: wrapValue(percent, step, min, max), + valueUnwrapped: percent, + percentageValue: percent, + closestThumbIndex: 0, + }; } + // handle range + let closestThumbIndex = 0; if (!move) { - closestThumbIndex = findClosest(values, newValue)!; - } else { - closestThumbIndex = previousIndexRef.current!; + closestThumbIndex = findClosest(values, percent); + } else if (previousIndexRef.current) { + closestThumbIndex = previousIndexRef.current; } // Bound the new value to the thumb's neighbours. - newValue = clamp( - newValue, - values[closestThumbIndex - 1] + minStepsBetweenValues || -Infinity, - values[closestThumbIndex + 1] - minStepsBetweenValues || Infinity, + const clampedPercentageValue = clamp( + percent, + values[closestThumbIndex - 1] + minimumAdjacentDifference || -Infinity, + values[closestThumbIndex + 1] - minimumAdjacentDifference || Infinity, ); - const previousValue = newValue; - newValue = setValueIndex({ + const previousValue = clampedPercentageValue; + + const newPercentageValues = setValueIndex({ values, - newValue, + newValue: clampedPercentageValue, index: closestThumbIndex, }); // Potentially swap the index if needed. if (!move) { - closestThumbIndex = newValue.indexOf(previousValue); + closestThumbIndex = newPercentageValues.indexOf(previousValue); previousIndexRef.current = closestThumbIndex; } - return { value: newValue, percentageValue: percent, closestThumbIndex }; + const wrappedValues = []; + for (let i = 0; i < newPercentageValues.length; i += 1) { + wrappedValues.push(wrapValue(newPercentageValues[i], step, min, max)); + } + + return { + value: wrappedValues, + valueUnwrapped: newPercentageValues, + percentageValue: percent, + closestThumbIndex, + }; }, ); @@ -385,11 +418,11 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo largeStep, max, min, + minimumAdjacentDifference, minStepsBetweenValues, name, onValueCommitted, orientation, - percentageValues: values.map((v) => valueToPercent(v, min, max)), range, registerSliderControl, setActive, @@ -413,6 +446,7 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo largeStep, max, min, + minimumAdjacentDifference, minStepsBetweenValues, name, onValueCommitted, @@ -451,7 +485,7 @@ export namespace useSliderRoot { /** * The default value. Use when the component is not controlled. */ - defaultValue?: number | ReadonlyArray; + defaultValue?: number | number[]; /** * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side. * @default 'ltr' @@ -529,7 +563,7 @@ export namespace useSliderRoot { * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | number[]; } export interface ReturnValue { @@ -558,10 +592,6 @@ export namespace useSliderRoot { percentageValue: number; closestThumbIndex: number; } | null; - /** - * Callback to invoke change handlers after internal value state is updated. - */ - setValue: (newValue: number | 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. @@ -576,6 +606,11 @@ export namespace useSliderRoot { * The minimum allowed value of the slider. */ min: number; + /** + * The minimum difference between adjacent thumbs. + * (minStepsBetweenValues * step) rescaled to fit within the range: [0, 1] + */ + minimumAdjacentDifference: number; /** * The minimum steps between values in a range slider. */ @@ -588,12 +623,12 @@ export namespace useSliderRoot { */ orientation: Orientation; registerSliderControl: (element: HTMLElement | null) => void; - /** - * The value(s) of the slider as percentages - */ - percentageValues: readonly number[]; setActive: (activeIndex: number) => void; setDragging: (isDragging: boolean) => void; + /** + * Callback to invoke change handlers after internal value state is updated. + */ + setValue: (percentageValue: number, activeThumb: number, event: Event) => void; setThumbMap: (map: Map | null>) => void; /** * The step increment of the slider when incrementing or decrementing. It will snap @@ -604,7 +639,7 @@ export namespace useSliderRoot { thumbMap: Map | null>; thumbRefs: React.MutableRefObject<(HTMLElement | null)[]>; /** - * The value(s) of the slider + * The value(s) of the slider converted to a percenetage */ values: readonly number[]; } diff --git a/packages/react/src/slider/thumb/SliderThumb.test.tsx b/packages/react/src/slider/thumb/SliderThumb.test.tsx index bca5ebe0b8..927d6c606a 100644 --- a/packages/react/src/slider/thumb/SliderThumb.test.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.test.tsx @@ -38,7 +38,6 @@ const testRootContext: SliderRootContext = { dirty: false, touched: false, }, - percentageValues: [0], registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, diff --git a/packages/react/src/slider/thumb/SliderThumb.tsx b/packages/react/src/slider/thumb/SliderThumb.tsx index a7b53f4433..53717c419d 100644 --- a/packages/react/src/slider/thumb/SliderThumb.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.tsx @@ -72,7 +72,6 @@ const SliderThumb = React.forwardRef(function SliderThumb( name, orientation, state, - percentageValues, step, tabIndex: contextTabIndex, values, @@ -107,7 +106,6 @@ const SliderThumb = React.forwardRef(function SliderThumb( onFocus: onFocusProp ?? NOOP, onKeyDown: onKeyDownProp ?? NOOP, orientation, - percentageValues, rootRef: mergedRef, step, tabIndex: tabIndexProp ?? contextTabIndex, diff --git a/packages/react/src/slider/thumb/useSliderThumb.ts b/packages/react/src/slider/thumb/useSliderThumb.ts index c7b697ee21..8738a4437c 100644 --- a/packages/react/src/slider/thumb/useSliderThumb.ts +++ b/packages/react/src/slider/thumb/useSliderThumb.ts @@ -4,6 +4,7 @@ import { formatNumber } from '../../utils/formatNumber'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { GenericHTMLProps } from '../../utils/types'; import { useForkRef } from '../../utils/useForkRef'; +import { valueToPercent } from '../../utils/valueToPercent'; import { visuallyHidden } from '../../utils/visuallyHidden'; import { ARROW_DOWN, @@ -17,8 +18,12 @@ import { useCompositeListItem } from '../../composite/list/useCompositeListItem' import { useFieldControlValidation } from '../../field/control/useFieldControlValidation'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; import { getSliderValue } from '../utils/getSliderValue'; +import { toValidatedNumber, toValidatedValues } from '../utils/toValidatedValue'; import type { useSliderRoot } from '../root/useSliderRoot'; +const PAGE_UP = 'PageUp'; +const PAGE_DOWN = 'PageDown'; + export interface ThumbMetadata { inputId: string | undefined; } @@ -30,7 +35,17 @@ function getNewValue( min: number, max: number, ): number { - return direction === 1 ? Math.min(thumbValue + step, max) : Math.max(thumbValue - step, min); + console.group('getNewValue'); + console.log('thumbValue', thumbValue); + const stepValueAsPercentage = step / (max - min); + console.log('stepValueAsPercentage', stepValueAsPercentage); + const newValue = + direction === 1 + ? Math.min(thumbValue + stepValueAsPercentage, 1) + : Math.max(thumbValue - stepValueAsPercentage, 0); + console.log('return value', newValue); + console.groupEnd(); + return newValue; } function getDefaultAriaValueText( @@ -73,7 +88,6 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider minStepsBetweenValues, name, orientation, - percentageValues, rootRef: externalRef, step, tabIndex: externalTabIndex, @@ -107,7 +121,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider const thumbValue = sliderValues[index]; // for SSR, don't wait for the index if there's only one thumb - const percent = percentageValues.length === 1 ? percentageValues[0] : percentageValues[index]; + const percent = sliderValues.length === 1 ? sliderValues[0] : thumbValue; const isRtl = direction === 'rtl'; @@ -123,7 +137,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider [{ horizontal: 'insetInlineStart', vertical: 'bottom', - }[orientation]]: `${percent}%`, + }[orientation]]: `${percent * 100}%`, [isVertical ? 'left' : 'top']: '50%', transform: `translate(${(isVertical || !isRtl ? -1 : 1) * 50}%, ${(isVertical ? 1 : -1) * 50}%)`, // So the non active thumb doesn't show its label on hover. @@ -154,8 +168,26 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider ); }, onKeyDown(event: React.KeyboardEvent) { + if ( + ![ + ARROW_UP, + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + PAGE_UP, + PAGE_DOWN, + HOME, + END, + ].includes(event.key) + ) { + return; + } + let newValue = null; const isRange = sliderValues.length > 1; + console.log('thumbValue', thumbValue); + // thumbValue is a percentage, e.g. 0.51 + // all the calculations below must be done switch (event.key) { case ARROW_UP: newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, 1, min, max); @@ -181,14 +213,14 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider max, ); break; - case 'PageUp': + case PAGE_UP: newValue = getNewValue(thumbValue, largeStep, 1, min, max); break; - case 'PageDown': + case PAGE_DOWN: newValue = getNewValue(thumbValue, largeStep, -1, min, max); break; case END: - newValue = max; + newValue = 1; if (isRange) { newValue = Number.isFinite(sliderValues[index + 1]) @@ -197,7 +229,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider } break; case HOME: - newValue = min; + newValue = 0; if (isRange) { newValue = Number.isFinite(sliderValues[index - 1]) @@ -249,17 +281,25 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider cssWritingMode = isRtl ? 'vertical-rl' : 'vertical-lr'; } + const value = toValidatedNumber(thumbValue, step, min, max); + // console.log('value', value); + return mergeReactProps(getInputValidationProps(externalProps), { 'aria-label': getAriaLabel != null ? getAriaLabel(index) : ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': orientation, 'aria-valuemax': max, 'aria-valuemin': min, - 'aria-valuenow': thumbValue, + 'aria-valuenow': value, 'aria-valuetext': getAriaValueText != null - ? getAriaValueText(formatNumber(thumbValue, [], format ?? undefined), thumbValue, index) - : ariaValuetext || getDefaultAriaValueText(sliderValues, index, format ?? undefined), + ? getAriaValueText(formatNumber(value, [], format ?? undefined), value, index) + : ariaValuetext || + getDefaultAriaValueText( + toValidatedValues(sliderValues.slice(), step, min, max), + index, + format ?? undefined, + ), 'data-index': index, disabled, id: inputId, @@ -267,7 +307,11 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider min, name, onChange(event: React.ChangeEvent) { - handleInputChange(event.target.valueAsNumber, index, event); + handleInputChange( + valueToPercent(event.target.valueAsNumber, min, max) / 100, + index, + event, + ); }, ref: mergedInputRef, step, @@ -280,7 +324,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider }, tabIndex: -1, type: 'range', - value: thumbValue ?? '', + value: Number.isNaN(value) ? '' : (value ?? ''), }); }, [ @@ -332,7 +376,6 @@ export namespace useSliderThumb { | 'minStepsBetweenValues' | 'name' | 'orientation' - | 'percentageValues' | 'step' | 'values' > { diff --git a/packages/react/src/slider/track/SliderTrack.test.tsx b/packages/react/src/slider/track/SliderTrack.test.tsx index f84ba363e9..5facc02ff3 100644 --- a/packages/react/src/slider/track/SliderTrack.test.tsx +++ b/packages/react/src/slider/track/SliderTrack.test.tsx @@ -38,7 +38,6 @@ const testRootContext: SliderRootContext = { dirty: false, touched: false, }, - percentageValues: [0], registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, diff --git a/packages/react/src/slider/utils/rescale.ts b/packages/react/src/slider/utils/rescale.ts new file mode 100644 index 0000000000..1a63019114 --- /dev/null +++ b/packages/react/src/slider/utils/rescale.ts @@ -0,0 +1,5 @@ +// rescaling or min-max normalization scales a numerical range to fit within [0, 1] +// https://en.wikipedia.org/wiki/Feature_scaling#Rescaling_(min-max_normalization) +export function rescale(value: number, min: number, max: number) { + return (value - min) / (max - min); +} diff --git a/packages/react/src/slider/utils/toValidatedValue.ts b/packages/react/src/slider/utils/toValidatedValue.ts new file mode 100644 index 0000000000..d252d003be --- /dev/null +++ b/packages/react/src/slider/utils/toValidatedValue.ts @@ -0,0 +1,39 @@ +import { clamp } from '../../utils/clamp'; +import { formatNumber } from '../../utils/formatNumber'; +import { percentToValue, roundValueToStep } from '../utils'; + +export function toValidatedNumber(value: number, step: number, min: number, max: number) { + return clamp(roundValueToStep(percentToValue(value, min, max), step, min), min, max); +} + +export function toValidatedValues( + value: number | number[], + step: number, + min: number, + max: number, +) { + if (Array.isArray(value)) { + const vals = []; + for (let i = 0; i < value.length; i += 1) { + vals.push(toValidatedNumber(value[i], step, min, max)); + } + return vals; + } + return toValidatedNumber(value, step, min, max); +} + +// useSlideValue only +export function toFormattedValue( + value: number | number[], + format: Intl.NumberFormatOptions | null, +) { + if (Array.isArray(value)) { + const vals = []; + for (let i = 0; i < value.length; i += 1) { + vals.push(format != null ? formatNumber(value[i], [], format) : String(value[i])); + } + return vals; + } + + return format != null ? [formatNumber(value, [], format)] : [String(value)]; +} diff --git a/packages/react/src/slider/utils/wrapValue.ts b/packages/react/src/slider/utils/wrapValue.ts new file mode 100644 index 0000000000..d140d9a6fb --- /dev/null +++ b/packages/react/src/slider/utils/wrapValue.ts @@ -0,0 +1,10 @@ +import { clamp } from '../../utils/clamp'; +import { percentToValue, roundValueToStep } from '../utils'; + +export function wrapValue(percentageUnwrapped: number, step: number, min: number, max: number) { + return clamp( + roundValueToStep(percentToValue(percentageUnwrapped, min, max), step, min), + min, + max, + ); +} diff --git a/packages/react/src/slider/value/SliderValue.test.tsx b/packages/react/src/slider/value/SliderValue.test.tsx index 5056c3715c..6bf4494b1b 100644 --- a/packages/react/src/slider/value/SliderValue.test.tsx +++ b/packages/react/src/slider/value/SliderValue.test.tsx @@ -40,7 +40,6 @@ const testRootContext: SliderRootContext = { dirty: false, touched: false, }, - percentageValues: [0], registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, diff --git a/packages/react/src/slider/value/SliderValue.tsx b/packages/react/src/slider/value/SliderValue.tsx index 71514660c9..8e6c39e35c 100644 --- a/packages/react/src/slider/value/SliderValue.tsx +++ b/packages/react/src/slider/value/SliderValue.tsx @@ -6,6 +6,7 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useSliderRootContext } from '../root/SliderRootContext'; import { sliderStyleHookMapping } from '../root/styleHooks'; import type { SliderRoot } from '../root/SliderRoot'; +import { toFormattedValue, toValidatedValues } from '../utils/toValidatedValue'; import { useSliderValue } from './useSliderValue'; /** * Displays the current value of the slider as text. @@ -19,22 +20,28 @@ const SliderValue = React.forwardRef(function SliderValue( ) { const { 'aria-live': ariaLive = 'off', render, className, children, ...otherProps } = props; - const { thumbMap, state, values, format } = useSliderRootContext(); + const { thumbMap, state, values, format, step, min, max } = useSliderRootContext(); const { getRootProps, formattedValues } = useSliderValue({ 'aria-live': ariaLive, format: format ?? null, + max, + min, + step, thumbMap, values, }); + const validatedValues = toValidatedValues(values.slice(), step, min, max); + // console.log(validatedValues); + const defaultDisplayValue = React.useMemo(() => { const arr = []; - for (let i = 0; i < values.length; i += 1) { - arr.push(formattedValues[i] || values[i]); + for (let i = 0; i < validatedValues.length; i += 1) { + arr.push(formattedValues[i] || validatedValues[i]); } return arr.join(' – '); - }, [values, formattedValues]); + }, [validatedValues, formattedValues]); const { renderElement } = useComponentRenderer({ propGetter: getRootProps, @@ -44,7 +51,9 @@ const SliderValue = React.forwardRef(function SliderValue( ref: forwardedRef, extraProps: { children: - typeof children === 'function' ? children(formattedValues, values) : defaultDisplayValue, + typeof children === 'function' + ? children(formattedValues, validatedValues) + : defaultDisplayValue, ...otherProps, }, customStyleHookMapping: sliderStyleHookMapping, diff --git a/packages/react/src/slider/value/useSliderValue.ts b/packages/react/src/slider/value/useSliderValue.ts index f0385b617f..e8c9c3f93d 100644 --- a/packages/react/src/slider/value/useSliderValue.ts +++ b/packages/react/src/slider/value/useSliderValue.ts @@ -1,11 +1,11 @@ 'use client'; import * as React from 'react'; -import { formatNumber } from '../../utils/formatNumber'; import { mergeReactProps } from '../../utils/mergeReactProps'; import type { useSliderRoot } from '../root/useSliderRoot'; +import { toFormattedValue, toValidatedValues } from '../utils/toValidatedValue'; export function useSliderValue(parameters: useSliderValue.Parameters): useSliderValue.ReturnValue { - const { 'aria-live': ariaLive, format: formatParam, thumbMap, values } = parameters; + const { 'aria-live': ariaLive, format, max, min, step, thumbMap, values } = parameters; const outputFor = React.useMemo(() => { let htmlFor = ''; @@ -17,13 +17,10 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider return htmlFor.trim() === '' ? undefined : htmlFor.trim(); }, [thumbMap]); - const formattedValues = React.useMemo(() => { - const arr = []; - for (let i = 0; i < values.length; i += 1) { - arr.push(formatNumber(values[i], [], formatParam ?? undefined)); - } - return arr; - }, [formatParam, values]); + const formattedValues = toFormattedValue( + toValidatedValues(values.slice(), step, min, max), + format, + ); const getRootProps = React.useCallback( (externalProps = {}) => { @@ -47,7 +44,8 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider } export namespace useSliderValue { - export interface Parameters extends Pick { + export interface Parameters + extends Pick { 'aria-live': React.AriaAttributes['aria-live']; /** * Options to format the input value.