Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Slider] Use un-rounded values to position thumbs #1219

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
4 changes: 4 additions & 0 deletions docs/reference/generated/slider-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/generated/slider-thumb.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
20 changes: 11 additions & 9 deletions packages/react/src/slider/control/SliderControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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],
};
Expand Down
12 changes: 4 additions & 8 deletions packages/react/src/slider/control/SliderControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,37 +21,33 @@ const SliderControl = React.forwardRef(function SliderControl(
const { render: renderProp, className, ...otherProps } = props;

const {
areValuesEqual,
disabled,
dragging,
getFingerNewValue,
handleValueChange,
getFingerState,
setValue,
minStepsBetweenValues,
onValueCommitted,
state,
percentageValues,
registerSliderControl,
setActive,
setDragging,
setValueState,
step,
thumbRefs,
} = useSliderRootContext();

const { getRootProps } = useSliderControl({
areValuesEqual,
disabled,
dragging,
getFingerNewValue,
handleValueChange,
getFingerState,
setValue,
minStepsBetweenValues,
onValueCommitted,
percentageValues,
registerSliderControl,
rootRef: forwardedRef,
setActive,
setDragging,
setValueState,
step,
thumbRefs,
});
Expand Down
144 changes: 65 additions & 79 deletions packages/react/src/slider/control/useSliderControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,56 @@ 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<any>,
): 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,
registerSliderControl,
rootRef: externalRef,
setActive,
setDragging,
setValueState,
step,
thumbRefs,
} = parameters;
Expand All @@ -44,18 +69,17 @@ export function useSliderControl(

// A number that uniquely identifies the current finger in the touch session.
const touchIdRef = React.useRef<number | null>(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;
}

Expand All @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -260,16 +250,14 @@ export function useSliderControl(
});
},
[
areValuesEqual,
disabled,
getFingerNewValue,
getFingerState,
handleRootRef,
handleTouchMove,
handleTouchEnd,
handleValueChange,
setValue,
percentageValues,
setActive,
setValueState,
thumbRefs,
],
);
Expand All @@ -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'
> {
Expand Down
Loading
Loading