Skip to content

Commit 9c9432a

Browse files
fix(Slider): allow upper handle to reach max with uneven steps (#18836)
Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com>
1 parent 66fe546 commit 9c9432a

File tree

2 files changed

+75
-53
lines changed

2 files changed

+75
-53
lines changed

packages/react/src/components/Slider/Slider.tsx

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,6 @@ export interface SliderProps
299299
warnText?: React.ReactNode;
300300
}
301301

302-
interface CalcValueProps {
303-
clientX?: number;
304-
value?: number;
305-
useRawValue?: boolean;
306-
}
307-
308302
interface CalcLeftPercentProps {
309303
clientX?: number;
310304
value?: number;
@@ -1042,65 +1036,67 @@ class Slider extends PureComponent<SliderProps> {
10421036
return 0;
10431037
};
10441038

1045-
calcSteppedValuePercent = ({ leftPercent, range }) => {
1046-
const { step = 1 } = this.props;
1047-
const totalSteps = range / step;
1048-
1049-
let steppedValue = Math.round(leftPercent * totalSteps) * step;
1050-
const steppedPercent = this.clamp(steppedValue / range, 0, 1);
1051-
1052-
steppedValue = this.clamp(
1053-
steppedValue + this.props.min,
1054-
this.props.min,
1055-
this.props.max
1056-
);
1057-
1058-
return [steppedValue, steppedPercent];
1039+
/**
1040+
* Calculates the discrete value (snapped to the nearest step) along
1041+
* with the corresponding handle position percentage.
1042+
*/
1043+
calcDiscreteValueAndPercent = ({
1044+
leftPercent,
1045+
}: {
1046+
/** The percentage representing the position on the track. */
1047+
leftPercent: number;
1048+
}) => {
1049+
const { step = 1, min, max } = this.props;
1050+
const numSteps =
1051+
Math.floor((max - min) / step) + ((max - min) % step === 0 ? 1 : 2);
1052+
/** Index of the step that corresponds to `leftPercent`. */
1053+
const stepIndex = Math.round(leftPercent * (numSteps - 1));
1054+
const discreteValue =
1055+
stepIndex === numSteps - 1 ? max : min + step * stepIndex;
1056+
/** Percentage corresponding to the step index. */
1057+
const discretePercent = stepIndex / (numSteps - 1);
1058+
1059+
return { discreteValue, discretePercent };
10591060
};
10601061

10611062
/**
1062-
* Calculates a new Slider `value` and `left` (thumb offset) given a `clientX`,
1063-
* `value`, or neither of those.
1064-
* - If `clientX` is specified, it will be used in
1065-
* conjunction with the Slider's bounding rectangle to calculate the new
1066-
* values.
1067-
* - If `clientX` is not specified and `value` is, it will be used to
1068-
* calculate new values as though it were the current value of the Slider.
1069-
* - If neither `clientX` nor `value` are specified, `this.props.value` will
1070-
* be used to calculate the new values as though it were the current value
1071-
* of the Slider.
1072-
*
1073-
* @param {object} params
1074-
* @param {number} [params.clientX] Optional clientX value expected to be from
1075-
* an event fired by one of the Slider's `DRAG_EVENT_TYPES` events.
1076-
* @param {number} [params.value] Optional value use during calculations if
1077-
* clientX is not provided.
1078-
* @param {boolean} [params.useRawValue=false] `true` to use the given value as-is.
1063+
* Calculates the slider's value and handle position based on either a
1064+
* mouse/touch event or an explicit value.
10791065
*/
1080-
calcValue = ({ clientX, value, useRawValue = false }: CalcValueProps) => {
1066+
calcValue = ({
1067+
clientX,
1068+
value,
1069+
useRawValue,
1070+
}: {
1071+
/** The x-coordinate from a mouse/touch event. */
1072+
clientX?: number;
1073+
/** Value to base the calculations on (if no `clientX`). */
1074+
value?: number;
1075+
/** Whether to bypass the stepping logic and use the raw value. */
1076+
useRawValue?: boolean;
1077+
}) => {
10811078
const range = this.props.max - this.props.min;
1082-
1083-
// @todo solve for rtl.
1084-
const leftPercent = this.calcLeftPercent({
1079+
const leftPercentRaw = this.calcLeftPercent({
10851080
clientX,
10861081
value,
10871082
range,
10881083
});
1084+
/** `leftPercentRaw` clamped between 0 and 1. */
1085+
const leftPercent = Math.min(1, Math.max(0, leftPercentRaw));
10891086

10901087
if (useRawValue) {
1091-
// Adjusts only for min/max of thumb position
10921088
return {
10931089
value,
1094-
left: Math.min(1, Math.max(0, leftPercent)) * 100,
1090+
left: leftPercent * 100,
10951091
};
10961092
}
10971093

1098-
const [steppedValue, steppedPercent] = this.calcSteppedValuePercent({
1099-
leftPercent,
1100-
range,
1101-
});
1094+
// Use the discrete value and percentage for snapping.
1095+
const { discreteValue, discretePercent } = this.calcDiscreteValueAndPercent(
1096+
{ leftPercent }
1097+
);
11021098

1103-
return { value: steppedValue, left: steppedPercent * 100 };
1099+
return { value: discreteValue, left: discretePercent * 100 };
11041100
};
11051101

11061102
calcDistanceToHandle = (handle: HandlePosition, clientX) => {

packages/react/src/components/Slider/__test__/Slider-test.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,30 @@ describe('Slider', () => {
972972
expect(onChange).not.toHaveBeenCalled();
973973
});
974974

975+
it('should allow upper handle to reach `max` when `max` is not divisible by `step`', () => {
976+
const { mouseDown, mouseMove, mouseUp } = fireEvent;
977+
const { container } = renderTwoHandleSlider({
978+
value: 0,
979+
unstable_valueUpper: 400,
980+
min: 0,
981+
max: 435,
982+
step: 50,
983+
onChange,
984+
});
985+
const upperHandle = screen.getAllByRole('slider')[1];
986+
987+
// Simulate dragging the upper handle far to the right.
988+
mouseDown(upperHandle, { clientX: 10 });
989+
mouseMove(container.firstChild, { clientX: 1000 });
990+
mouseUp(upperHandle);
991+
992+
// `onChange` should be called with the upper value equal to `max`.
993+
expect(onChange).toHaveBeenLastCalledWith({
994+
value: 0,
995+
valueUpper: 435,
996+
});
997+
});
998+
975999
describe('Error handling, expected behavior from event handlers', () => {
9761000
it('handles non-number typed into input field', async () => {
9771001
const { type, tab } = userEvent;
@@ -1185,6 +1209,8 @@ describe('Slider', () => {
11851209
});
11861210

11871211
it("should round the slider's value when using small step values", async () => {
1212+
const { keyboard, click } = userEvent;
1213+
11881214
renderTwoHandleSlider({
11891215
value: 0,
11901216
min: 0,
@@ -1194,15 +1220,15 @@ describe('Slider', () => {
11941220
});
11951221
const leftHandle = screen.getAllByRole('slider')[0];
11961222

1197-
await userEvent.click(leftHandle);
1198-
await userEvent.keyboard('{ArrowRight}');
1199-
await userEvent.keyboard('{ArrowRight}');
1200-
await userEvent.keyboard('{ArrowRight}');
1223+
await click(leftHandle);
1224+
await keyboard('{ArrowRight}');
1225+
await keyboard('{ArrowRight}');
1226+
await keyboard('{ArrowRight}');
12011227

12021228
// Retrieve the last call to `onChange`.
12031229
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
12041230

1205-
// Assert that the value was correctly correctly (i.e., not
1231+
// Assert that the value was updated correctly (i.e., not
12061232
// 0.30000000000000004).
12071233
expect(lastCall.value).toBe(0.3);
12081234
});

0 commit comments

Comments
 (0)