diff --git a/docs/reference/generated/slider-root.json b/docs/reference/generated/slider-root.json index 804dc5ff26..d432e1c472 100644 --- a/docs/reference/generated/slider-root.json +++ b/docs/reference/generated/slider-root.json @@ -26,6 +26,10 @@ "type": "(value, event) => void", "description": "Callback function that is fired when the `pointerup` is triggered." }, + "tabIndex": { + "type": "number", + "description": "Optional tab index attribute for the thumb components." + }, "step": { "type": "number", "default": "1", diff --git a/docs/reference/generated/slider-thumb.json b/docs/reference/generated/slider-thumb.json index 558237081c..88568a6eda 100644 --- a/docs/reference/generated/slider-thumb.json +++ b/docs/reference/generated/slider-thumb.json @@ -18,6 +18,11 @@ "type": "function(formattedValue: string, value: number, index: number) => string", "description": "Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider.\nThis is important for screen reader users." }, + "tabIndex": { + "type": "number", + "default": "null", + "description": "Optional tab index attribute for the thumb components." + }, "className": { "type": "string | (state) => string", "description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state." diff --git a/packages/react/src/menu/trigger/useMenuTrigger.ts b/packages/react/src/menu/trigger/useMenuTrigger.ts index 40f7d9cc31..b51f0bcdac 100644 --- a/packages/react/src/menu/trigger/useMenuTrigger.ts +++ b/packages/react/src/menu/trigger/useMenuTrigger.ts @@ -6,8 +6,11 @@ import { useForkRef } from '../../utils/useForkRef'; import { GenericHTMLProps } from '../../utils/types'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { ownerDocument } from '../../utils/owner'; +import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds'; export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTrigger.ReturnValue { + const BOUNDARY_OFFSET = 2; + const { disabled = false, rootRef: externalRef, @@ -71,18 +74,21 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr const mouseUpTarget = mouseEvent.target as Element | null; - const triggerRect = triggerRef.current.getBoundingClientRect(); + if ( + contains(triggerRef.current, mouseUpTarget) || + contains(positionerRef.current, mouseUpTarget) || + mouseUpTarget === triggerRef.current + ) { + return; + } - const isInsideTrigger = - mouseEvent.clientX >= triggerRect.left && - mouseEvent.clientX <= triggerRect.right && - mouseEvent.clientY >= triggerRect.top && - mouseEvent.clientY <= triggerRect.bottom; + const bounds = getPseudoElementBounds(triggerRef.current); if ( - isInsideTrigger || - contains(positionerRef.current, mouseUpTarget) || - contains(triggerRef.current, mouseUpTarget) + mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET && + mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET && + mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET && + mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET ) { return; } diff --git a/packages/react/src/select/trigger/useSelectTrigger.ts b/packages/react/src/select/trigger/useSelectTrigger.ts index f6521ed4a7..a41b50017b 100644 --- a/packages/react/src/select/trigger/useSelectTrigger.ts +++ b/packages/react/src/select/trigger/useSelectTrigger.ts @@ -7,10 +7,13 @@ import { useForkRef } from '../../utils/useForkRef'; import { useSelectRootContext } from '../root/SelectRootContext'; import { ownerDocument } from '../../utils/owner'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; +import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds'; export function useSelectTrigger( parameters: useSelectTrigger.Parameters, ): useSelectTrigger.ReturnValue { + const BOUNDARY_OFFSET = 2; + const { disabled = false, rootRef: externalRef } = parameters; const { @@ -107,18 +110,22 @@ export function useSelectTrigger( const mouseUpTarget = mouseEvent.target as Element | null; - const triggerRect = triggerRef.current.getBoundingClientRect(); + // Early return if clicked on trigger element or its children + if ( + contains(triggerRef.current, mouseUpTarget) || + contains(positionerElement, mouseUpTarget) || + mouseUpTarget === triggerRef.current + ) { + return; + } - const isInsideTrigger = - mouseEvent.clientX >= triggerRect.left && - mouseEvent.clientX <= triggerRect.right && - mouseEvent.clientY >= triggerRect.top && - mouseEvent.clientY <= triggerRect.bottom; + const bounds = getPseudoElementBounds(triggerRef.current); if ( - isInsideTrigger || - contains(positionerElement, mouseUpTarget) || - contains(triggerRef.current, mouseUpTarget) + mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET && + mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET && + mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET && + mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET ) { return; } diff --git a/packages/react/src/slider/control/SliderControl.test.tsx b/packages/react/src/slider/control/SliderControl.test.tsx index da57da3d3a..4e55a7a590 100644 --- a/packages/react/src/slider/control/SliderControl.test.tsx +++ b/packages/react/src/slider/control/SliderControl.test.tsx @@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop'; const testRootContext: SliderRootContext = { active: -1, - areValuesEqual: () => true, - changeValue: NOOP, - direction: 'ltr', + handleInputChange: NOOP, dragging: false, disabled: false, - getFingerNewValue: () => ({ - newValue: 0, - activeIndex: 0, - newPercentageValue: 0, + getFingerState: () => ({ + value: 0, + valueRescaled: 0, + percentageValues: [0], + thumbIndex: 0, }), - handleValueChange: NOOP, + setValue: NOOP, largeStep: 10, thumbMap: new Map(), max: 100, min: 0, minStepsBetweenValues: 0, + name: '', + onValueCommitted: NOOP, orientation: 'horizontal', state: { activeThumbIndex: -1, @@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, - setValueState: NOOP, step: 1, + tabIndex: null, thumbRefs: { current: [] }, values: [0], }; diff --git a/packages/react/src/slider/control/SliderControl.tsx b/packages/react/src/slider/control/SliderControl.tsx index 21e5529fa0..46e449ee6f 100644 --- a/packages/react/src/slider/control/SliderControl.tsx +++ b/packages/react/src/slider/control/SliderControl.tsx @@ -21,11 +21,10 @@ const SliderControl = React.forwardRef(function SliderControl( const { render: renderProp, className, ...otherProps } = props; const { - areValuesEqual, disabled, dragging, - getFingerNewValue, - handleValueChange, + getFingerState, + setValue, minStepsBetweenValues, onValueCommitted, state, @@ -33,17 +32,15 @@ const SliderControl = React.forwardRef(function SliderControl( registerSliderControl, setActive, setDragging, - setValueState, step, thumbRefs, } = useSliderRootContext(); const { getRootProps } = useSliderControl({ - areValuesEqual, disabled, dragging, - getFingerNewValue, - handleValueChange, + getFingerState, + setValue, minStepsBetweenValues, onValueCommitted, percentageValues, @@ -51,7 +48,6 @@ const SliderControl = React.forwardRef(function SliderControl( rootRef: forwardedRef, setActive, setDragging, - setValueState, step, thumbRefs, }); diff --git a/packages/react/src/slider/control/useSliderControl.ts b/packages/react/src/slider/control/useSliderControl.ts index 28470dbae2..ef6fd13269 100644 --- a/packages/react/src/slider/control/useSliderControl.ts +++ b/packages/react/src/slider/control/useSliderControl.ts @@ -7,23 +7,49 @@ import { useForkRef } from '../../utils/useForkRef'; import { useEventCallback } from '../../utils/useEventCallback'; import { focusThumb, - trackFinger, - type useSliderRoot, validateMinimumDistance, + type FingerPosition, + type useSliderRoot, } from '../root/useSliderRoot'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation'; const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; +function trackFinger( + event: TouchEvent | PointerEvent | React.PointerEvent, + touchIdRef: React.RefObject, +): FingerPosition | null { + // The event is TouchEvent + if (touchIdRef.current !== undefined && (event as TouchEvent).changedTouches) { + const touchEvent = event as TouchEvent; + for (let i = 0; i < touchEvent.changedTouches.length; i += 1) { + const touch = touchEvent.changedTouches[i]; + if (touch.identifier === touchIdRef.current) { + return { + x: touch.clientX, + y: touch.clientY, + }; + } + } + + return null; + } + + // The event is PointerEvent + return { + x: (event as PointerEvent).clientX, + y: (event as PointerEvent).clientY, + }; +} + export function useSliderControl( parameters: useSliderControl.Parameters, ): useSliderControl.ReturnValue { const { - areValuesEqual, disabled, dragging, - getFingerNewValue, - handleValueChange, + getFingerState, + setValue, onValueCommitted, minStepsBetweenValues, percentageValues, @@ -31,7 +57,6 @@ export function useSliderControl( rootRef: externalRef, setActive, setDragging, - setValueState, step, thumbRefs, } = parameters; @@ -44,18 +69,17 @@ export function useSliderControl( // A number that uniquely identifies the current finger in the touch session. const touchIdRef = React.useRef(null); - const moveCountRef = React.useRef(0); - - // offset distance between: - // 1. pointerDown coordinates and - // 2. the exact intersection of the center of the thumb and the track + /** + * The difference between the value at the finger origin and the value at + * the center of the thumb scaled down to fit the range [0, 1] + */ const offsetRef = React.useRef(0); const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => { - const finger = trackFinger(nativeEvent, touchIdRef); + const fingerPosition = trackFinger(nativeEvent, touchIdRef); - if (!finger) { + if (fingerPosition == null) { return; } @@ -69,57 +93,42 @@ export function useSliderControl( return; } - const newFingerValue = getFingerNewValue({ - finger, - move: true, - offset: offsetRef.current, - }); + const finger = getFingerState(fingerPosition, false, offsetRef.current); - if (!newFingerValue) { + if (finger == null) { return; } - const { newValue, activeIndex } = newFingerValue; - - focusThumb({ sliderRef: controlRef, activeIndex, setActive }); - - if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) { - setValueState(newValue); + focusThumb(finger.thumbIndex, controlRef, setActive); + if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) { if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { setDragging(true); } - if (handleValueChange && !areValuesEqual(newValue)) { - handleValueChange(newValue, activeIndex, nativeEvent); - } + setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent); } }); const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => { - const finger = trackFinger(nativeEvent, touchIdRef); + const fingerPosition = trackFinger(nativeEvent, touchIdRef); setDragging(false); - if (!finger) { + if (fingerPosition == null) { return; } - const newFingerValue = getFingerNewValue({ - finger, - move: true, - }); + const finger = getFingerState(fingerPosition, false); - if (!newFingerValue) { + if (finger == null) { return; } setActive(-1); - commitValidation(newFingerValue.newValue); + commitValidation(finger.value); - if (onValueCommitted) { - onValueCommitted(newFingerValue.newValue, nativeEvent); - } + onValueCommitted(finger.value, nativeEvent); touchIdRef.current = null; @@ -138,25 +147,18 @@ export function useSliderControl( touchIdRef.current = touch.identifier; } - const finger = trackFinger(nativeEvent, touchIdRef); + const fingerPosition = trackFinger(nativeEvent, touchIdRef); - if (finger !== false) { - const newFingerValue = getFingerNewValue({ - finger, - }); + if (fingerPosition != null) { + const finger = getFingerState(fingerPosition, true); - if (!newFingerValue) { + if (finger == null) { return; } - const { newValue, activeIndex } = newFingerValue; - focusThumb({ sliderRef: controlRef, activeIndex, setActive }); + focusThumb(finger.thumbIndex, controlRef, setActive); - setValueState(newValue); - - if (handleValueChange && !areValuesEqual(newValue)) { - handleValueChange(newValue, activeIndex, nativeEvent); - } + setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent); } moveCountRef.current = 0; @@ -218,36 +220,24 @@ export function useSliderControl( // Avoid text selection event.preventDefault(); - const finger = trackFinger(event, touchIdRef); + const fingerPosition = trackFinger(event, touchIdRef); - if (finger !== false) { - const newFingerValue = getFingerNewValue({ - finger, - }); + if (fingerPosition != null) { + const finger = getFingerState(fingerPosition, true); - if (!newFingerValue) { + if (finger == null) { return; } - const { newValue, activeIndex, newPercentageValue } = newFingerValue; - - focusThumb({ sliderRef: controlRef, activeIndex, 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 - newPercentageValue; - - offsetRef.current = offset; + offsetRef.current = percentageValues[finger.thumbIndex] / 100 - finger.valueRescaled; } else { - setValueState(newValue); - - if (handleValueChange && !areValuesEqual(newValue)) { - handleValueChange(newValue, activeIndex, event); - } + setValue(finger.value, finger.percentageValues, finger.thumbIndex, event.nativeEvent); } } @@ -260,16 +250,14 @@ export function useSliderControl( }); }, [ - areValuesEqual, disabled, - getFingerNewValue, + getFingerState, handleRootRef, handleTouchMove, handleTouchEnd, - handleValueChange, + setValue, percentageValues, setActive, - setValueState, thumbRefs, ], ); @@ -286,18 +274,16 @@ export namespace useSliderControl { export interface Parameters extends Pick< useSliderRoot.ReturnValue, - | 'areValuesEqual' | 'disabled' | 'dragging' - | 'getFingerNewValue' - | 'handleValueChange' + | 'getFingerState' + | 'setValue' | 'minStepsBetweenValues' | 'onValueCommitted' | 'percentageValues' | 'registerSliderControl' | 'setActive' | 'setDragging' - | 'setValueState' | 'step' | 'thumbRefs' > { diff --git a/packages/react/src/slider/indicator/SliderIndicator.test.tsx b/packages/react/src/slider/indicator/SliderIndicator.test.tsx index fa84166aba..04eb7806f9 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.test.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.test.tsx @@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop'; const testRootContext: SliderRootContext = { active: -1, - areValuesEqual: () => true, - changeValue: NOOP, - direction: 'ltr', + handleInputChange: NOOP, dragging: false, disabled: false, - getFingerNewValue: () => ({ - newValue: 0, - activeIndex: 0, - newPercentageValue: 0, + getFingerState: () => ({ + value: 0, + valueRescaled: 0, + percentageValues: [0], + thumbIndex: 0, }), - handleValueChange: NOOP, + setValue: NOOP, largeStep: 10, thumbMap: new Map(), max: 100, min: 0, minStepsBetweenValues: 0, + name: '', + onValueCommitted: NOOP, orientation: 'horizontal', state: { activeThumbIndex: -1, @@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, - setValueState: NOOP, step: 1, + tabIndex: null, thumbRefs: { current: [] }, values: [0], }; diff --git a/packages/react/src/slider/indicator/SliderIndicator.tsx b/packages/react/src/slider/indicator/SliderIndicator.tsx index 7c09a0ae60..653d6d67b8 100644 --- a/packages/react/src/slider/indicator/SliderIndicator.tsx +++ b/packages/react/src/slider/indicator/SliderIndicator.tsx @@ -20,10 +20,9 @@ const SliderIndicator = React.forwardRef(function SliderIndicator( ) { const { render, className, ...otherProps } = props; - const { direction, disabled, orientation, state, percentageValues } = useSliderRootContext(); + const { disabled, orientation, state, percentageValues } = useSliderRootContext(); const { getRootProps } = useSliderIndicator({ - direction, disabled, orientation, percentageValues, diff --git a/packages/react/src/slider/indicator/useSliderIndicator.ts b/packages/react/src/slider/indicator/useSliderIndicator.ts index e8271ccb91..b8cef2d682 100644 --- a/packages/react/src/slider/indicator/useSliderIndicator.ts +++ b/packages/react/src/slider/indicator/useSliderIndicator.ts @@ -71,10 +71,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 22e18422f0..b0d56ac5b0 100644 --- a/packages/react/src/slider/root/SliderRoot.tsx +++ b/packages/react/src/slider/root/SliderRoot.tsx @@ -1,11 +1,12 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import { NOOP } from '../../utils/noop'; import type { BaseUIComponentProps } from '../../utils/types'; +import { useBaseUiId } from '../../utils/useBaseUiId'; import { useComponentRenderer } from '../../utils/useComponentRenderer'; import type { FieldRoot } from '../../field/root/FieldRoot'; import { CompositeList } from '../../composite/list/CompositeList'; -import { useDirection } from '../../direction-provider/DirectionContext'; import { sliderStyleHookMapping } from './styleHooks'; import { useSliderRoot } from './useSliderRoot'; import { SliderRootContext } from './SliderRootContext'; @@ -26,45 +27,43 @@ const SliderRoot = React.forwardRef(function SliderRoot( className, defaultValue, disabled: disabledProp = false, - id, + id: idProp, format, - largeStep, + largeStep = 10, render, - max, - min, - minStepsBetweenValues, - name, - onValueChange, - onValueCommitted, + max = 100, + min = 0, + minStepsBetweenValues = 0, + name: nameProp, + onValueChange: onValueChangeProp, + onValueCommitted: onValueCommittedProp, orientation = 'horizontal', - step, - tabIndex, + step = 1, + tabIndex: externalTabIndex, value, ...otherProps } = props; - const direction = useDirection(); + const id = useBaseUiId(idProp); const { labelId, state: fieldState, disabled: fieldDisabled } = useFieldRootContext(); const disabled = fieldDisabled || disabledProp; const { getRootProps, ...slider } = useSliderRoot({ - 'aria-labelledby': ariaLabelledby ?? labelId, + 'aria-labelledby': ariaLabelledby ?? labelId ?? '', defaultValue, - direction, disabled, - id, + id: id ?? '', largeStep, max, min, minStepsBetweenValues, - name, - onValueChange, - onValueCommitted, + name: nameProp ?? '', + onValueChange: onValueChangeProp ?? NOOP, + onValueCommitted: onValueCommittedProp ?? NOOP, orientation, rootRef: forwardedRef, step, - tabIndex, value, }); @@ -100,8 +99,9 @@ const SliderRoot = React.forwardRef(function SliderRoot( ...slider, format, state, + tabIndex: externalTabIndex ?? null, }), - [slider, format, state], + [slider, format, state, externalTabIndex], ); const { renderElement } = useComponentRenderer({ @@ -154,23 +154,24 @@ export namespace SliderRoot { /** * The raw number value of the slider. */ - values: ReadonlyArray; + values: readonly number[]; } export interface Props - extends Pick< - useSliderRoot.Parameters, - | 'disabled' - | 'max' - | 'min' - | 'minStepsBetweenValues' - | 'name' - | 'onValueChange' - | 'onValueCommitted' - | 'orientation' - | 'largeStep' - | 'step' - | 'value' + extends Partial< + Pick< + useSliderRoot.Parameters, + | 'disabled' + | 'max' + | 'min' + | 'minStepsBetweenValues' + | 'name' + | 'onValueChange' + | 'onValueCommitted' + | 'orientation' + | 'largeStep' + | 'step' + > >, Omit, 'defaultValue' | 'onChange' | 'values'> { /** @@ -178,7 +179,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 @@ -188,11 +189,15 @@ export namespace SliderRoot { * Options to format the input value. */ format?: Intl.NumberFormatOptions; + /** + * Optional tab index attribute for the thumb components. + */ + tabIndex?: number; /** * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | readonly number[]; } } @@ -317,7 +322,7 @@ SliderRoot.propTypes /* remove-proptypes */ = { */ step: PropTypes.number, /** - * @ignore + * Optional tab index attribute for the thumb components. */ tabIndex: PropTypes.number, /** diff --git a/packages/react/src/slider/root/SliderRootContext.ts b/packages/react/src/slider/root/SliderRootContext.ts index a2b3c5f3dd..521bad010a 100644 --- a/packages/react/src/slider/root/SliderRootContext.ts +++ b/packages/react/src/slider/root/SliderRootContext.ts @@ -6,6 +6,7 @@ import type { useSliderRoot } from './useSliderRoot'; export interface SliderRootContext extends Omit { format?: Intl.NumberFormatOptions; state: SliderRoot.State; + tabIndex: number | null; } export const SliderRootContext = React.createContext(undefined); diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts index 6860b7a608..5dcdc0687b 100644 --- a/packages/react/src/slider/root/useSliderRoot.ts +++ b/packages/react/src/slider/root/useSliderRoot.ts @@ -7,21 +7,34 @@ import { mergeReactProps } from '../../utils/mergeReactProps'; import { ownerDocument } from '../../utils/owner'; import { useControlled } from '../../utils/useControlled'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; +import { useEventCallback } from '../../utils/useEventCallback'; import { useForkRef } from '../../utils/useForkRef'; -import { useBaseUiId } from '../../utils/useBaseUiId'; import { valueToPercent } from '../../utils/valueToPercent'; import type { CompositeMetadata } from '../../composite/list/CompositeList'; -import type { TextDirection } from '../../direction-provider/DirectionContext'; +import { useDirection } 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 { asc } from '../utils/asc'; -import { setValueIndex } from '../utils/setValueIndex'; import { getSliderValue } from '../utils/getSliderValue'; +import { replaceArrayItemAtIndex } from '../utils/replaceArrayItemAtIndex'; +import { roundValueToStep } from '../utils/roundValueToStep'; import { ThumbMetadata } from '../thumb/useSliderThumb'; -function findClosest(values: number[], currentValue: number) { +function areValuesEqual( + newValue: number | readonly number[], + oldValue: number | readonly number[], +) { + if (typeof newValue === 'number' && typeof oldValue === 'number') { + return newValue === oldValue; + } + if (Array.isArray(newValue) && Array.isArray(oldValue)) { + return areArraysEqual(newValue, oldValue); + } + return false; +} + +function findClosest(values: readonly number[], currentValue: number) { const { index: closestIndex } = values.reduce<{ distance: number; index: number } | null>( (acc, value: number, index: number) => { @@ -41,25 +54,30 @@ function findClosest(values: number[], currentValue: number) { return closestIndex; } -export function focusThumb({ - sliderRef, - activeIndex, - setActive, -}: { - sliderRef: React.RefObject; - activeIndex: number; - setActive?: (num: number) => void; -}) { +export function focusThumb( + thumbIndex: number, + sliderRef: React.RefObject, + setActive?: useSliderRoot.ReturnValue['setActive'], +) { + if (!sliderRef.current) { + return; + } + const doc = ownerDocument(sliderRef.current); + if ( - !sliderRef.current?.contains(doc.activeElement) || - Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex + !sliderRef.current.contains(doc.activeElement) || + Number(doc?.activeElement?.getAttribute('data-index')) !== thumbIndex ) { - sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus(); + ( + sliderRef.current.querySelector( + `[type="range"][data-index="${thumbIndex}"]`, + ) as HTMLInputElement + ).focus(); } if (setActive) { - setActive(activeIndex); + setActive(thumbIndex); } } @@ -85,42 +103,14 @@ export function validateMinimumDistance( return Math.min(...distances) >= step * minStepsBetweenValues; } -export function trackFinger( - event: TouchEvent | PointerEvent | React.PointerEvent, - touchIdRef: React.RefObject, -) { - // The event is TouchEvent - if (touchIdRef.current !== undefined && (event as TouchEvent).changedTouches) { - const touchEvent = event as TouchEvent; - for (let i = 0; i < touchEvent.changedTouches.length; i += 1) { - const touch = touchEvent.changedTouches[i]; - if (touch.identifier === touchIdRef.current) { - return { - x: touch.clientX, - y: touch.clientY, - }; - } - } - - return false; - } - - // The event is PointerEvent - return { - x: (event as PointerEvent).clientX, - y: (event as PointerEvent).clientY, - }; -} - /** */ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRoot.ReturnValue { const { 'aria-labelledby': ariaLabelledby, defaultValue, - direction = 'ltr', disabled = false, - id: idProp, + id, largeStep = 10, max = 100, min = 0, @@ -131,10 +121,10 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo orientation = 'horizontal', rootRef, step = 1, - tabIndex, value: valueProp, } = parameters; + const direction = useDirection(); const { setControlId, setTouched, setDirty, validityData } = useFieldRootContext(); const { @@ -143,7 +133,9 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo commitValidation, } = useFieldControlValidation(); - const [valueState, setValueState] = useControlled({ + // The internal value is potentially unsorted, e.g. to support frozen arrays + // https://github.com/mui/material-ui/pull/28472 + const [valueUnwrapped, setValueUnwrapped] = useControlled({ controlled: valueProp, default: defaultValue ?? min, name: 'Slider', @@ -153,8 +145,6 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo const controlRef: React.RefObject = React.useRef(null); const thumbRefs = React.useRef<(HTMLElement | null)[]>([]); - const id = useBaseUiId(idProp); - const [thumbMap, setThumbMap] = React.useState( () => new Map | null>(), ); @@ -169,7 +159,7 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo useField({ id, commitValidation, - value: valueState, + value: valueUnwrapped, controlRef, }); @@ -190,115 +180,109 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo [inputValidationRef], ); - const handleValueChange = React.useCallback( - (value: number | number[], thumbIndex: number, event: Event | React.SyntheticEvent) => { - if (!onValueChange) { + const range = Array.isArray(valueUnwrapped); + + const values = React.useMemo(() => { + if (!range) { + return [clamp(valueUnwrapped as number, min, max)]; + } + return valueUnwrapped.slice().sort(asc); + }, [max, min, range, valueUnwrapped]); + + function 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, + ); + + const setValue = useEventCallback( + ( + newValue: number | 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 // Clone the event to not override `target` of the original event. - const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event; // @ts-ignore The nativeEvent is function, not object - const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent); + const clonedEvent = new event.constructor(event.type, event); Object.defineProperty(clonedEvent, 'target', { writable: true, - value: { value, name }, + value: { value: newValue, name }, }); - onValueChange(value, clonedEvent, thumbIndex); + onValueChange(newValue, clonedEvent, thumbIndex); }, - [name, onValueChange], ); - const range = Array.isArray(valueState); - - const values = React.useMemo(() => { - return (range ? valueState.slice().sort(asc) : [valueState]).map((val) => - val == null ? min : clamp(val, min, max), - ); - }, [max, min, range, valueState]); - const handleRootRef = useForkRef(rootRef, sliderRef); - const areValuesEqual = React.useCallback( - (newValue: number | ReadonlyArray): boolean => { - if (typeof newValue === 'number' && typeof valueState === 'number') { - return newValue === valueState; - } - if (typeof newValue === 'object' && typeof valueState === 'object') { - return areArraysEqual(newValue, valueState); - } - return false; - }, - [valueState], - ); - - const changeValue = React.useCallback( + const handleInputChange = useEventCallback( (valueInput: number, index: number, event: React.KeyboardEvent | React.ChangeEvent) => { - const newValue = getSliderValue({ - valueInput, - min, - max, - index, - range, - values, - }); + const newValue = getSliderValue(valueInput, index, min, max, range, values); if (range) { - focusThumb({ sliderRef, activeIndex: index }); + focusThumb(index, sliderRef); } if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) { - setValueState(newValue); - setDirty(newValue !== validityData.initialValue); - - if (handleValueChange && !areValuesEqual(newValue) && event) { - handleValueChange(newValue, index, event); + 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); - - if (onValueCommitted && event) { - onValueCommitted(newValue, event.nativeEvent); - } + onValueCommitted(newValue, event.nativeEvent); } }, - [ - min, - max, - range, - step, - minStepsBetweenValues, - values, - setValueState, - setDirty, - validityData.initialValue, - handleValueChange, - areValuesEqual, - onValueCommitted, - setTouched, - commitValidation, - ], ); - const previousIndexRef = React.useRef(null); - - const getFingerNewValue = React.useCallback( - ({ - finger, - move = false, - offset = 0, - }: { - finger: { x: number; y: number }; - // `move` is used to distinguish between when this is called by touchstart vs touchmove/end - move?: boolean; - offset?: number; - }) => { + const closestThumbIndexRef = React.useRef(null); + + const getFingerState = useEventCallback( + ( + fingerPosition: FingerPosition | null, + /** + * When `true`, closestThumbIndexRef is updated. + * It's `true` when called by touchstart or pointerdown. + */ + shouldCaptureThumbIndex: boolean = false, + /** + * The difference between the value at the finger origin and the value at + * the center of the thumb scaled down to fit the range [0, 1] + */ + offset: number = 0, + ): FingerState | null => { + if (fingerPosition == null) { + return null; + } + const { current: sliderControl } = controlRef; if (!sliderControl) { @@ -308,68 +292,61 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo const isRtl = direction === 'rtl'; const isVertical = orientation === 'vertical'; - const { width, height, bottom, left } = sliderControl!.getBoundingClientRect(); - let percent; + const { width, height, bottom, left } = sliderControl.getBoundingClientRect(); - if (isVertical) { - percent = (bottom - finger.y) / height + offset; - } else { - percent = (finger.x - left) / width + offset * (isRtl ? -1 : 1); - } + // the value at the finger origin scaled down to fit the range [0, 1] + let valueRescaled = isVertical + ? (bottom - fingerPosition.y) / height + offset + : (fingerPosition.x - left) / width + offset * (isRtl ? -1 : 1); - percent = Math.min(percent, 1); + valueRescaled = clamp(valueRescaled, 0, 1); if (isRtl && !isVertical) { - percent = 1 - percent; - } - - let newValue; - newValue = percentToValue(percent, min, max); - if (step) { - newValue = roundValueToStep(newValue, step, min); + valueRescaled = 1 - valueRescaled; } + let newValue = (max - min) * valueRescaled + min; + newValue = roundValueToStep(newValue, step, min); newValue = clamp(newValue, min, max); - let activeIndex = 0; if (!range) { - return { newValue, activeIndex, newPercentageValue: percent }; + return { + value: newValue, + valueRescaled, + percentageValues: [valueRescaled * 100], + thumbIndex: 0, + }; } - if (!move) { - activeIndex = findClosest(values, newValue)!; - } else { - activeIndex = previousIndexRef.current!; + if (shouldCaptureThumbIndex) { + closestThumbIndexRef.current = findClosest(values, newValue) ?? 0; } + const closestThumbIndex = closestThumbIndexRef.current ?? 0; + // Bound the new value to the thumb's neighbours. newValue = clamp( newValue, - values[activeIndex - 1] + minStepsBetweenValues || -Infinity, - values[activeIndex + 1] - minStepsBetweenValues || Infinity, + values[closestThumbIndex - 1] + minStepsBetweenValues || -Infinity, + values[closestThumbIndex + 1] - minStepsBetweenValues || Infinity, ); - const previousValue = newValue; - newValue = setValueIndex({ - values, - newValue, - index: activeIndex, - }); - - // Potentially swap the index if needed. - if (!move) { - activeIndex = newValue.indexOf(previousValue); - previousIndexRef.current = activeIndex; - } - - return { newValue, activeIndex, newPercentageValue: percent }; + return { + value: replaceArrayItemAtIndex(values, closestThumbIndex, newValue), + valueRescaled, + percentageValues: replaceArrayItemAtIndex( + percentageValues, + closestThumbIndex, + valueRescaled * 100, + ), + thumbIndex: closestThumbIndex, + }; }, - [direction, max, min, minStepsBetweenValues, orientation, range, step, values], ); useEnhancedEffect(() => { const activeEl = activeElement(ownerDocument(sliderRef.current)); - if (disabled && sliderRef.current!.contains(activeEl)) { + if (disabled && sliderRef.current?.contains(activeEl)) { // This is necessary because Firefox and Safari will keep focus // on a disabled element: // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js @@ -396,15 +373,12 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo return React.useMemo( () => ({ getRootProps, - active, - areValuesEqual, 'aria-labelledby': ariaLabelledby, - changeValue, - direction, + active, disabled, dragging, - getFingerNewValue, - handleValueChange, + getFingerState, + handleInputChange, largeStep, max, min, @@ -412,15 +386,15 @@ 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, - setValueState, + setValue, step, - tabIndex, thumbMap, thumbRefs, values, @@ -428,14 +402,11 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo [ getRootProps, active, - areValuesEqual, ariaLabelledby, - changeValue, - direction, disabled, dragging, - getFingerNewValue, - handleValueChange, + getFingerState, + handleInputChange, largeStep, max, min, @@ -443,14 +414,15 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo name, onValueCommitted, orientation, + percentageValues, range, registerSliderControl, setActive, setDragging, + setPercentageValues, setThumbMap, - setValueState, + setValue, step, - tabIndex, thumbMap, thumbRefs, values, @@ -458,6 +430,18 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo ); } +export interface FingerPosition { + x: number; + y: number; +} + +interface FingerState { + value: number | number[]; + valueRescaled: number; + percentageValues: number[]; + thumbIndex: number; +} + export namespace useSliderRoot { export type Orientation = 'horizontal' | 'vertical'; @@ -465,46 +449,41 @@ export namespace useSliderRoot { /** * The id of the slider element. */ - id?: string; + id: string; /** * The id of the element containing a label for the slider. */ - 'aria-labelledby'?: string; + 'aria-labelledby': string; /** * The default value. Use when the component is not controlled. */ - defaultValue?: number | ReadonlyArray; - /** - * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side. - * @default 'ltr' - */ - direction: TextDirection; + defaultValue?: number | readonly number[]; /** * Whether the component should ignore user interaction. * @default false */ - disabled?: boolean; + disabled: boolean; /** * The maximum allowed value of the slider. * Should not be equal to min. * @default 100 */ - max?: number; + max: number; /** * The minimum allowed value of the slider. * Should not be equal to max. * @default 0 */ - min?: number; + min: number; /** * The minimum steps between values in a range slider. * @default 0 */ - minStepsBetweenValues?: number; + minStepsBetweenValues: number; /** * Identifies the field when a form is submitted. */ - name?: string; + name: string; /** * Callback function that is fired when the slider's value changed. * @@ -513,7 +492,7 @@ export namespace useSliderRoot { * You can pull out the new value by accessing `event.target.value` (any). * @param {number} activeThumbIndex Index of the currently moved thumb. */ - onValueChange?: (value: number | number[], event: Event, activeThumbIndex: number) => void; + onValueChange: (value: number | number[], event: Event, activeThumbIndex: number) => void; /** * Callback function that is fired when the `pointerup` is triggered. * @@ -521,37 +500,33 @@ export namespace useSliderRoot { * @param {Event} event The corresponding event that initiated the change. * **Warning**: This is a generic event not a change event. */ - onValueCommitted?: (value: number | number[], event: Event) => void; + onValueCommitted: (value: number | number[], event: Event) => void; /** * The component orientation. * @default 'horizontal' */ - orientation?: Orientation; + orientation: Orientation; /** * The ref attached to the root of the Slider. */ - rootRef?: React.Ref; + rootRef: React.Ref; /** * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down. * @default 10 */ - largeStep?: number; + largeStep: number; /** * The granularity with which the slider can step through values. (A "discrete" slider.) * The `min` prop serves as the origin for the valid values. * We recommend (max - min) to be evenly divisible by the step. * @default 1 */ - step?: number; - /** - * Tab index attribute of the Thumb component's `input` element. - */ - tabIndex?: number; + step: number; /** * The value of the slider. * For ranged sliders, provide an array with two values. */ - value?: number | ReadonlyArray; + value?: number | readonly number[]; } export interface ReturnValue { @@ -562,30 +537,27 @@ export namespace useSliderRoot { * The index of the active thumb. */ active: number; - /** - * A function that compares a new value with the internal value of the slider. - * The internal value is potentially unsorted, e.g. to support frozen arrays: https://github.com/mui/material-ui/pull/28472 - */ - areValuesEqual: (newValue: number | ReadonlyArray) => boolean; 'aria-labelledby'?: string; - changeValue: ( + handleInputChange: ( valueInput: number, index: number, event: React.KeyboardEvent | React.ChangeEvent, ) => void; dragging: boolean; - direction: TextDirection; disabled: boolean; - getFingerNewValue: (args: { - finger: { x: number; y: number }; - move?: boolean; - offset?: number; - activeIndex?: number; - }) => { newValue: number | number[]; activeIndex: number; newPercentageValue: number } | null; - handleValueChange: ( - value: number | number[], + getFingerState: ( + fingerPosition: FingerPosition | null, + shouldCaptureThumbIndex?: boolean, + offset?: number, + ) => FingerState | null; + /** + * Callback to invoke change handlers after internal value state is updated. + */ + setValue: ( + newValue: number | number[], + newPercentageValues: readonly number[], activeThumb: number, - event: React.SyntheticEvent | Event, + event: Event, ) => void; /** * The large step value of the slider when incrementing or decrementing while the shift key is held, @@ -605,8 +577,8 @@ export namespace useSliderRoot { * The minimum steps between values in a range slider. */ minStepsBetweenValues: number; - name?: string; - onValueCommitted?: (value: number | number[], event: Event) => void; + name: string; + onValueCommitted: (value: number | number[], event: Event) => void; /** * The component orientation. * @default 'horizontal' @@ -617,10 +589,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; - setValueState: (newValue: number | number[]) => 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. @@ -629,7 +603,6 @@ export namespace useSliderRoot { step: number; thumbMap: Map | null>; thumbRefs: React.MutableRefObject<(HTMLElement | null)[]>; - tabIndex?: number; /** * The value(s) of the slider */ diff --git a/packages/react/src/slider/thumb/SliderThumb.test.tsx b/packages/react/src/slider/thumb/SliderThumb.test.tsx index b7549e245a..55b4d7c727 100644 --- a/packages/react/src/slider/thumb/SliderThumb.test.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.test.tsx @@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop'; const testRootContext: SliderRootContext = { active: -1, - areValuesEqual: () => true, - changeValue: NOOP, - direction: 'ltr', + handleInputChange: NOOP, dragging: false, disabled: false, - getFingerNewValue: () => ({ - newValue: 0, - activeIndex: 0, - newPercentageValue: 0, + getFingerState: () => ({ + value: 0, + valueRescaled: 0, + percentageValues: [0], + thumbIndex: 0, }), - handleValueChange: NOOP, + setValue: NOOP, largeStep: 10, thumbMap: new Map(), max: 100, min: 0, minStepsBetweenValues: 0, + name: '', + onValueCommitted: NOOP, orientation: 'horizontal', state: { activeThumbIndex: -1, @@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, - setValueState: NOOP, step: 1, + tabIndex: null, thumbRefs: { current: [] }, values: [0], }; diff --git a/packages/react/src/slider/thumb/SliderThumb.tsx b/packages/react/src/slider/thumb/SliderThumb.tsx index 7a09f0e956..0f3b166457 100644 --- a/packages/react/src/slider/thumb/SliderThumb.tsx +++ b/packages/react/src/slider/thumb/SliderThumb.tsx @@ -3,8 +3,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { getStyleHookProps } from '../../utils/getStyleHookProps'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { NOOP } from '../../utils/noop'; import { resolveClassName } from '../../utils/resolveClassName'; import { BaseUIComponentProps } from '../../utils/types'; +import { useBaseUiId } from '../../utils/useBaseUiId'; import { useForkRef } from '../../utils/useForkRef'; import type { SliderRoot } from '../root/SliderRoot'; import { useSliderRootContext } from '../root/SliderRootContext'; @@ -40,22 +42,28 @@ const SliderThumb = React.forwardRef(function SliderThumb( 'aria-valuetext': ariaValuetext, className, disabled: disabledProp = false, - getAriaLabel, - getAriaValueText, - id, - inputId, + getAriaLabel: getAriaLabelProp, + getAriaValueText: getAriaValueTextProp, + id: idProp, + inputId: inputIdProp, + onBlur: onBlurProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + tabIndex: tabIndexProp, ...otherProps } = props; + const id = useBaseUiId(idProp); + const inputId = useBaseUiId(inputIdProp); + const render = renderProp ?? defaultRender; const { active: activeIndex, 'aria-labelledby': ariaLabelledby, - changeValue, - direction, + handleInputChange, disabled: contextDisabled, - format, + format = null, largeStep, max, min, @@ -65,7 +73,7 @@ const SliderThumb = React.forwardRef(function SliderThumb( state, percentageValues, step, - tabIndex, + tabIndex: contextTabIndex, values, } = useSliderRootContext(); @@ -78,27 +86,29 @@ const SliderThumb = React.forwardRef(function SliderThumb( const { getRootProps, getThumbInputProps, disabled, index } = useSliderThumb({ active: activeIndex, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - 'aria-valuetext': ariaValuetext, - changeValue, - direction, + 'aria-label': ariaLabel ?? '', + 'aria-labelledby': ariaLabelledby ?? '', + 'aria-valuetext': ariaValuetext ?? '', + handleInputChange, disabled: disabledProp || contextDisabled, format, - getAriaLabel, - getAriaValueText, - id, - inputId, + getAriaLabel: getAriaLabelProp ?? null, + getAriaValueText: getAriaValueTextProp ?? null, + id: id ?? '', + inputId: inputId ?? '', largeStep, max, min, minStepsBetweenValues, name, + onBlur: onBlurProp ?? NOOP, + onFocus: onFocusProp ?? NOOP, + onKeyDown: onKeyDownProp ?? NOOP, orientation, percentageValues, rootRef: mergedRef, step, - tabIndex, + tabIndex: tabIndexProp ?? contextTabIndex, values, }); @@ -145,7 +155,7 @@ export namespace SliderThumb { export interface Props extends Partial>, - Omit, 'render'> { + Omit, 'render' | 'tabIndex'> { onPointerLeave?: React.PointerEventHandler; onPointerOver?: React.PointerEventHandler; onBlur?: React.FocusEventHandler; @@ -199,6 +209,7 @@ SliderThumb.propTypes /* remove-proptypes */ = { * Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb * @param {number} index The index of the input * @returns {string} + * @type {((index: number) => string) | null} */ getAriaLabel: PropTypes.func, /** @@ -208,6 +219,7 @@ SliderThumb.propTypes /* remove-proptypes */ = { * @param {number} value The thumb's numerical value. * @param {number} index The thumb's index. * @returns {string} + * @type {((formattedValue: string, value: number, index: number) => string) | null} */ getAriaValueText: PropTypes.func, /** @@ -248,4 +260,9 @@ SliderThumb.propTypes /* remove-proptypes */ = { PropTypes.func, PropTypes.node, ]), + /** + * Optional tab index attribute for the thumb components. + * @default null + */ + tabIndex: PropTypes.number, } as any; diff --git a/packages/react/src/slider/thumb/useSliderThumb.ts b/packages/react/src/slider/thumb/useSliderThumb.ts index 9171896545..b0e8c1042a 100644 --- a/packages/react/src/slider/thumb/useSliderThumb.ts +++ b/packages/react/src/slider/thumb/useSliderThumb.ts @@ -4,9 +4,17 @@ import { formatNumber } from '../../utils/formatNumber'; import { mergeReactProps } from '../../utils/mergeReactProps'; import { GenericHTMLProps } from '../../utils/types'; import { useForkRef } from '../../utils/useForkRef'; -import { useBaseUiId } from '../../utils/useBaseUiId'; import { visuallyHidden } from '../../utils/visuallyHidden'; +import { + ARROW_DOWN, + ARROW_UP, + ARROW_RIGHT, + ARROW_LEFT, + HOME, + END, +} from '../../composite/composite'; import { useCompositeListItem } from '../../composite/list/useCompositeListItem'; +import { useDirection } from '../../direction-provider/DirectionContext'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation'; import { useFieldRootContext } from '../../field/root/FieldRootContext'; import { getSliderValue } from '../utils/getSliderValue'; @@ -52,14 +60,13 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-valuetext': ariaValuetext, - changeValue, - direction, + handleInputChange, disabled, format, - getAriaLabel, - getAriaValueText, - id: idParam, - inputId: inputIdParam, + getAriaLabel = null, + getAriaValueText = null, + id: thumbId, + inputId, largeStep, max, min, @@ -69,10 +76,11 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider percentageValues, rootRef: externalRef, step, - tabIndex, + tabIndex: externalTabIndex, values: sliderValues, } = parameters; + const direction = useDirection(); const { setTouched } = useFieldRootContext(); const { getInputValidationProps, @@ -80,14 +88,10 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider commitValidation, } = useFieldControlValidation(); - const thumbId = useBaseUiId(idParam); - const thumbRef = React.useRef(null); const inputRef = React.useRef(null); const mergedInputRef = useForkRef(inputRef, inputValidationRef); - const inputId = useBaseUiId(inputIdParam); - const thumbMetadata = React.useMemo( () => ({ inputId, @@ -140,24 +144,17 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider } setTouched(true); commitValidation( - getSliderValue({ - valueInput: thumbValue, - min, - max, - index, - range: sliderValues.length > 1, - values: sliderValues, - }), + getSliderValue(thumbValue, index, min, max, sliderValues.length > 1, sliderValues), ); }, onKeyDown(event: React.KeyboardEvent) { let newValue = null; const isRange = sliderValues.length > 1; switch (event.key) { - case 'ArrowUp': + case ARROW_UP: newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, 1, min, max); break; - case 'ArrowRight': + case ARROW_RIGHT: newValue = getNewValue( thumbValue, event.shiftKey ? largeStep : step, @@ -166,10 +163,10 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider max, ); break; - case 'ArrowDown': + case ARROW_DOWN: newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, -1, min, max); break; - case 'ArrowLeft': + case ARROW_LEFT: newValue = getNewValue( thumbValue, event.shiftKey ? largeStep : step, @@ -184,7 +181,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider case 'PageDown': newValue = getNewValue(thumbValue, largeStep, -1, min, max); break; - case 'End': + case END: newValue = max; if (isRange) { @@ -193,7 +190,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider : max; } break; - case 'Home': + case HOME: newValue = min; if (isRange) { @@ -207,22 +204,21 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider } if (newValue !== null) { - changeValue(newValue, index, event); + handleInputChange(newValue, index, event); event.preventDefault(); } }, ref: mergedThumbRef, - style: { - ...getThumbStyle(), - }, - tabIndex: tabIndex ?? (disabled ? undefined : 0), + style: getThumbStyle(), + tabIndex: externalTabIndex ?? (disabled ? undefined : 0), }); }, [ - changeValue, commitValidation, disabled, + externalTabIndex, getThumbStyle, + handleInputChange, index, isRtl, largeStep, @@ -233,7 +229,6 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider setTouched, sliderValues, step, - tabIndex, thumbId, thumbValue, ], @@ -247,30 +242,29 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider } return mergeReactProps(getInputValidationProps(externalProps), { - 'aria-label': getAriaLabel ? getAriaLabel(index) : ariaLabel, + 'aria-label': getAriaLabel != null ? getAriaLabel(index) : ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': orientation, 'aria-valuemax': max, 'aria-valuemin': min, 'aria-valuenow': thumbValue, - 'aria-valuetext': getAriaValueText - ? getAriaValueText(formatNumber(thumbValue, [], format), thumbValue, index) - : (ariaValuetext ?? getDefaultAriaValueText(sliderValues, index, format)), + 'aria-valuetext': + getAriaValueText != null + ? getAriaValueText(formatNumber(thumbValue, [], format ?? undefined), thumbValue, index) + : ariaValuetext || getDefaultAriaValueText(sliderValues, index, format ?? undefined), 'data-index': index, disabled, id: inputId, max, min, name, - onChange(event: React.ChangeEvent) { - // @ts-ignore - changeValue(event.target.valueAsNumber, index, event); + onChange(event: React.ChangeEvent) { + handleInputChange(event.target.valueAsNumber, index, event); }, ref: mergedInputRef, step, style: { ...visuallyHidden, - direction: isRtl ? 'rtl' : 'ltr', // So that VoiceOver's focus indicator matches the thumb's dimensions width: '100%', height: '100%', @@ -285,7 +279,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider ariaLabel, ariaLabelledby, ariaValuetext, - changeValue, + handleInputChange, disabled, format, getAriaLabel, @@ -322,8 +316,7 @@ export namespace useSliderThumb { useSliderRoot.ReturnValue, | 'active' | 'aria-labelledby' - | 'changeValue' - | 'direction' + | 'handleInputChange' | 'largeStep' | 'max' | 'min' @@ -332,27 +325,28 @@ export namespace useSliderThumb { | 'orientation' | 'percentageValues' | 'step' - | 'tabIndex' | 'values' > { /** * The label for the input element. */ - 'aria-label'?: string; + 'aria-label': string; /** * A string value that provides a user-friendly name for the current value of the slider. */ - 'aria-valuetext'?: string; + 'aria-valuetext': string; /** * Options to format the input value. + * @default null */ - format?: Intl.NumberFormatOptions; + format: Intl.NumberFormatOptions | null; /** * Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb * @param {number} index The index of the input * @returns {string} + * @type {((index: number) => string) | null} */ - getAriaLabel?: (index: number) => string; + getAriaLabel?: ((index: number) => string) | null; /** * Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. * This is important for screen reader users. @@ -360,15 +354,21 @@ export namespace useSliderThumb { * @param {number} value The thumb's numerical value. * @param {number} index The thumb's index. * @returns {string} + * @type {((formattedValue: string, value: number, index: number) => string) | null} */ - getAriaValueText?: (formattedValue: string, value: number, index: number) => string; - id?: React.HTMLAttributes['id']; - inputId?: React.HTMLAttributes['id']; + getAriaValueText: ((formattedValue: string, value: number, index: number) => string) | null; + id: string; + inputId: string; disabled: boolean; - onBlur?: React.FocusEventHandler; - onFocus?: React.FocusEventHandler; - onKeyDown?: React.KeyboardEventHandler; + onBlur: React.FocusEventHandler; + onFocus: React.FocusEventHandler; + onKeyDown: React.KeyboardEventHandler; rootRef?: React.Ref; + /** + * Optional tab index attribute for the thumb components. + * @default null + */ + tabIndex: number | null; } export interface ReturnValue { diff --git a/packages/react/src/slider/track/SliderTrack.test.tsx b/packages/react/src/slider/track/SliderTrack.test.tsx index e53b90b198..4219709da6 100644 --- a/packages/react/src/slider/track/SliderTrack.test.tsx +++ b/packages/react/src/slider/track/SliderTrack.test.tsx @@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop'; const testRootContext: SliderRootContext = { active: -1, - areValuesEqual: () => true, - changeValue: NOOP, - direction: 'ltr', + handleInputChange: NOOP, dragging: false, disabled: false, - getFingerNewValue: () => ({ - newValue: 0, - activeIndex: 0, - newPercentageValue: 0, + getFingerState: () => ({ + value: 0, + valueRescaled: 0, + percentageValues: [0], + thumbIndex: 0, }), - handleValueChange: NOOP, + setValue: NOOP, largeStep: 10, thumbMap: new Map(), max: 100, min: 0, minStepsBetweenValues: 0, + name: '', + onValueCommitted: NOOP, orientation: 'horizontal', state: { activeThumbIndex: -1, @@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, - setValueState: NOOP, step: 1, + tabIndex: null, thumbRefs: { current: [] }, values: [0], }; diff --git a/packages/react/src/slider/utils/getSliderValue.ts b/packages/react/src/slider/utils/getSliderValue.ts index efe7d2019f..f82ef09aa5 100644 --- a/packages/react/src/slider/utils/getSliderValue.ts +++ b/packages/react/src/slider/utils/getSliderValue.ts @@ -1,31 +1,25 @@ import { clamp } from '../../utils/clamp'; -import { setValueIndex } from './setValueIndex'; - -interface GetSliderValueParameters { - valueInput: number; - index: number; - min: number; - max: number; - range: boolean; - values: readonly number[]; -} - -export function getSliderValue(params: GetSliderValueParameters) { - const { valueInput, index, min, max, range, values } = params; +import { replaceArrayItemAtIndex } from './replaceArrayItemAtIndex'; +export function getSliderValue( + valueInput: number, + index: number, + min: number, + max: number, + range: boolean, + values: readonly number[], +) { let newValue: number | number[] = valueInput; 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.ts b/packages/react/src/slider/utils/roundValueToStep.ts similarity index 86% rename from packages/react/src/slider/utils.ts rename to packages/react/src/slider/utils/roundValueToStep.ts index ed473cffd4..741f662f75 100644 --- a/packages/react/src/slider/utils.ts +++ b/packages/react/src/slider/utils/roundValueToStep.ts @@ -11,10 +11,6 @@ function getDecimalPrecision(num: number) { return decimalPart ? decimalPart.length : 0; } -export function percentToValue(percent: number, min: number, max: number) { - return (max - min) * percent + min; -} - export function roundValueToStep(value: number, step: number, min: number) { const nearest = Math.round((value - min) / step) * step + min; return Number(nearest.toFixed(getDecimalPrecision(step))); 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 cb340a21ea..3d6e62f2e0 100644 --- a/packages/react/src/slider/value/SliderValue.test.tsx +++ b/packages/react/src/slider/value/SliderValue.test.tsx @@ -8,22 +8,23 @@ import { NOOP } from '../../utils/noop'; const testRootContext: SliderRootContext = { active: -1, - areValuesEqual: () => true, - changeValue: NOOP, - direction: 'ltr', + handleInputChange: NOOP, dragging: false, disabled: false, - getFingerNewValue: () => ({ - newValue: 0, - activeIndex: 0, - newPercentageValue: 0, + getFingerState: () => ({ + value: 0, + valueRescaled: 0, + percentageValues: [0], + thumbIndex: 0, }), - handleValueChange: NOOP, + setValue: NOOP, largeStep: 10, thumbMap: new Map(), max: 100, min: 0, minStepsBetweenValues: 0, + name: '', + onValueCommitted: NOOP, orientation: 'horizontal', state: { activeThumbIndex: -1, @@ -43,9 +44,10 @@ const testRootContext: SliderRootContext = { registerSliderControl: NOOP, setActive: NOOP, setDragging: NOOP, + setPercentageValues: NOOP, setThumbMap: NOOP, - setValueState: NOOP, step: 1, + tabIndex: null, thumbRefs: { current: [] }, values: [0], }; diff --git a/packages/react/src/slider/value/SliderValue.tsx b/packages/react/src/slider/value/SliderValue.tsx index 7c596e455d..71514660c9 100644 --- a/packages/react/src/slider/value/SliderValue.tsx +++ b/packages/react/src/slider/value/SliderValue.tsx @@ -17,12 +17,13 @@ const SliderValue = React.forwardRef(function SliderValue( props: SliderValue.Props, forwardedRef: React.ForwardedRef, ) { - const { render, className, children, ...otherProps } = props; + const { 'aria-live': ariaLive = 'off', render, className, children, ...otherProps } = props; const { thumbMap, state, values, format } = useSliderRootContext(); const { getRootProps, formattedValues } = useSliderValue({ - format, + 'aria-live': ariaLive, + format: format ?? null, thumbMap, values, }); @@ -55,6 +56,10 @@ const SliderValue = React.forwardRef(function SliderValue( export namespace SliderValue { export interface Props extends Omit, 'children'> { + /** + * @default 'off' + */ + 'aria-live'?: React.AriaAttributes['aria-live']; children?: | null | ((formattedValues: readonly string[], values: readonly number[]) => React.ReactNode); @@ -68,6 +73,10 @@ SliderValue.propTypes /* remove-proptypes */ = { // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ + /** + * @default 'off' + */ + 'aria-live': PropTypes.oneOf(['assertive', 'off', 'polite']), /** * @ignore */ diff --git a/packages/react/src/slider/value/useSliderValue.ts b/packages/react/src/slider/value/useSliderValue.ts index 2c1b71f051..f0385b617f 100644 --- a/packages/react/src/slider/value/useSliderValue.ts +++ b/packages/react/src/slider/value/useSliderValue.ts @@ -5,7 +5,7 @@ import { mergeReactProps } from '../../utils/mergeReactProps'; import type { useSliderRoot } from '../root/useSliderRoot'; export function useSliderValue(parameters: useSliderValue.Parameters): useSliderValue.ReturnValue { - const { 'aria-live': ariaLive = 'off', format: formatParam, thumbMap, values } = parameters; + const { 'aria-live': ariaLive, format: formatParam, thumbMap, values } = parameters; const outputFor = React.useMemo(() => { let htmlFor = ''; @@ -20,9 +20,7 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider const formattedValues = React.useMemo(() => { const arr = []; for (let i = 0; i < values.length; i += 1) { - arr.push( - formatNumber(values[i], [], Array.isArray(formatParam) ? formatParam[i] : formatParam), - ); + arr.push(formatNumber(values[i], [], formatParam ?? undefined)); } return arr; }, [formatParam, values]); @@ -50,11 +48,12 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider export namespace useSliderValue { export interface Parameters extends Pick { - 'aria-live'?: React.AriaAttributes['aria-live']; + 'aria-live': React.AriaAttributes['aria-live']; /** * Options to format the input value. + * @default null */ - format?: Intl.NumberFormatOptions | Intl.NumberFormatOptions[]; + format: Intl.NumberFormatOptions | null; } export interface ReturnValue { diff --git a/packages/react/src/utils/getPseudoElementBounds.ts b/packages/react/src/utils/getPseudoElementBounds.ts new file mode 100644 index 0000000000..5838dae2bb --- /dev/null +++ b/packages/react/src/utils/getPseudoElementBounds.ts @@ -0,0 +1,39 @@ +interface ElementBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +export function getPseudoElementBounds(element: HTMLElement): ElementBounds { + const elementRect = element.getBoundingClientRect(); + const beforeStyles = window.getComputedStyle(element, '::before'); + const afterStyles = window.getComputedStyle(element, '::after'); + + const hasPseudoElements = beforeStyles.content !== 'none' || afterStyles.content !== 'none'; + + if (!hasPseudoElements) { + return elementRect; + } + + // Get dimensions of pseudo-elements + const beforeWidth = parseFloat(beforeStyles.width) || 0; + const beforeHeight = parseFloat(beforeStyles.height) || 0; + const afterWidth = parseFloat(afterStyles.width) || 0; + const afterHeight = parseFloat(afterStyles.height) || 0; + + // Calculate max dimensions including pseudo-elements + const totalWidth = Math.max(elementRect.width, beforeWidth, afterWidth); + const totalHeight = Math.max(elementRect.height, beforeHeight, afterHeight); + + // Calculate the differences to extend the bounds + const widthDiff = totalWidth - elementRect.width; + const heightDiff = totalHeight - elementRect.height; + + return { + left: elementRect.left - widthDiff / 2, + right: elementRect.right + widthDiff / 2, + top: elementRect.top - heightDiff / 2, + bottom: elementRect.bottom + heightDiff / 2, + }; +}