Skip to content

Commit 3276fb2

Browse files
authored
fix: improve wheel step precision (#733)
1 parent 45ebc94 commit 3276fb2

2 files changed

Lines changed: 142 additions & 15 deletions

File tree

src/InputNumber.tsx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ const getDecimalIfValidate = (value: ValueType) => {
5353
return decimal.isInvalidate() ? null : decimal;
5454
};
5555

56+
const WHEEL_STEP_DISTANCE = 100;
57+
const WHEEL_LINE_HEIGHT = 40;
58+
const WHEEL_PAGE_HEIGHT = 800;
59+
const WHEEL_DELTA_RESET_INTERVAL = 200;
60+
61+
const getWheelDeltaY = (event: WheelEvent) => {
62+
switch (event.deltaMode) {
63+
case 1:
64+
return event.deltaY * WHEEL_LINE_HEIGHT;
65+
case 2:
66+
return event.deltaY * WHEEL_PAGE_HEIGHT;
67+
default:
68+
return event.deltaY;
69+
}
70+
};
71+
5672
type SemanticName = 'root' | 'actions' | 'input' | 'action' | 'prefix' | 'suffix';
5773
export interface InputNumberProps<T extends ValueType = ValueType>
5874
extends Omit<
@@ -189,6 +205,8 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((props, r
189205
const userTypingRef = React.useRef(false);
190206
const compositionRef = React.useRef(false);
191207
const shiftKeyRef = React.useRef(false);
208+
const wheelDeltaRef = React.useRef(0);
209+
const wheelTimestampRef = React.useRef(0);
192210

193211
// ============================= Refs =============================
194212
const rootRef = React.useRef<HTMLDivElement>(null);
@@ -567,12 +585,36 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((props, r
567585
shiftKeyRef.current = false;
568586
};
569587

588+
const onInternalWheel = useEvent((event: WheelEvent) => {
589+
const wheelDelta = getWheelDeltaY(event);
590+
if (!wheelDelta) {
591+
return;
592+
}
593+
594+
const eventTimestamp = event.timeStamp || Date.now();
595+
if (eventTimestamp - wheelTimestampRef.current > WHEEL_DELTA_RESET_INTERVAL) {
596+
wheelDeltaRef.current = 0;
597+
}
598+
wheelTimestampRef.current = eventTimestamp;
599+
600+
if (wheelDeltaRef.current && Math.sign(wheelDeltaRef.current) !== Math.sign(wheelDelta)) {
601+
wheelDeltaRef.current = 0;
602+
}
603+
604+
wheelDeltaRef.current += wheelDelta;
605+
606+
if (Math.abs(wheelDeltaRef.current) >= WHEEL_STEP_DISTANCE) {
607+
// moving mouse wheel rises wheel event with deltaY < 0
608+
// scroll value grows from top to bottom, as screen Y coordinate
609+
onInternalStep(wheelDeltaRef.current < 0, 'wheel');
610+
wheelDeltaRef.current -= Math.sign(wheelDeltaRef.current) * WHEEL_STEP_DISTANCE;
611+
}
612+
});
613+
570614
React.useEffect(() => {
571615
if (changeOnWheel && focus) {
572-
const onWheel = (event) => {
573-
// moving mouse wheel rises wheel event with deltaY < 0
574-
// scroll value grows from top to bottom, as screen Y coordinate
575-
onInternalStep(event.deltaY < 0, 'wheel');
616+
const onWheel = (event: WheelEvent) => {
617+
onInternalWheel(event);
576618
event.preventDefault();
577619
};
578620
const input = inputRef.current;
@@ -581,10 +623,14 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((props, r
581623
// That's why we should subscribe with DOM listener
582624
// https://stackoverflow.com/questions/63663025/react-onwheel-handler-cant-preventdefault-because-its-a-passive-event-listenev
583625
input.addEventListener('wheel', onWheel, { passive: false });
584-
return () => input.removeEventListener('wheel', onWheel);
626+
return () => {
627+
input.removeEventListener('wheel', onWheel);
628+
wheelDeltaRef.current = 0;
629+
wheelTimestampRef.current = 0;
630+
};
585631
}
586632
}
587-
});
633+
}, [changeOnWheel, focus, onInternalWheel]);
588634

589635
// >>> Focus & Blur
590636
const onBlur = () => {
@@ -595,6 +641,8 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((props, r
595641
setFocus(false);
596642

597643
userTypingRef.current = false;
644+
wheelDeltaRef.current = 0;
645+
wheelTimestampRef.current = 0;
598646
};
599647

600648
// >>> Mouse events

tests/wheel.test.tsx

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('InputNumber.Wheel', () => {
77
const onChange = jest.fn();
88
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
99
fireEvent.focus(container.firstChild);
10-
fireEvent.wheel(container.querySelector('input'), { deltaY: -1 });
10+
fireEvent.wheel(container.querySelector('input'), { deltaY: -100 });
1111
expect(onChange).toHaveBeenCalledWith(1);
1212
});
1313

@@ -23,15 +23,15 @@ describe('InputNumber.Wheel', () => {
2323
keyCode: KeyCode.SHIFT,
2424
shiftKey: true,
2525
});
26-
fireEvent.wheel(container.querySelector('input'), { deltaY: -1 });
26+
fireEvent.wheel(container.querySelector('input'), { deltaY: -100 });
2727
expect(onChange).toHaveBeenCalledWith(1.3);
2828
});
2929

3030
it('wheel down', () => {
3131
const onChange = jest.fn();
3232
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
3333
fireEvent.focus(container.firstChild);
34-
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
34+
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
3535
expect(onChange).toHaveBeenCalledWith(-1);
3636
});
3737

@@ -47,7 +47,7 @@ describe('InputNumber.Wheel', () => {
4747
keyCode: KeyCode.SHIFT,
4848
shiftKey: true,
4949
});
50-
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
50+
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
5151
expect(onChange).toHaveBeenCalledWith(1.1);
5252
});
5353

@@ -56,16 +56,16 @@ describe('InputNumber.Wheel', () => {
5656
const { container, rerender } = render(<InputNumber onChange={onChange} />);
5757
fireEvent.focus(container.firstChild);
5858

59-
fireEvent.wheel(container.querySelector('input'), { deltaY: -1 });
59+
fireEvent.wheel(container.querySelector('input'), { deltaY: -100 });
6060
expect(onChange).not.toHaveBeenCalled();
6161

62-
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
62+
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
6363
expect(onChange).not.toHaveBeenCalled();
6464

6565
rerender(<InputNumber onChange={onChange} changeOnWheel />);
6666
fireEvent.focus(container.firstChild);
6767

68-
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
68+
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
6969
expect(onChange).toHaveBeenCalledWith(-1);
7070
});
7171

@@ -81,9 +81,88 @@ describe('InputNumber.Wheel', () => {
8181
keyCode: KeyCode.SHIFT,
8282
shiftKey: true,
8383
});
84-
fireEvent.wheel(container.querySelector('input'), { deltaY: -1 });
84+
fireEvent.wheel(container.querySelector('input'), { deltaY: -100 });
8585
expect(onChange).toHaveBeenCalledWith(3);
86-
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
86+
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
8787
expect(onChange).toHaveBeenCalledWith(-3);
8888
});
89+
90+
it('accumulates high precision wheel delta', () => {
91+
const onChange = jest.fn();
92+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
93+
const input = container.querySelector('input');
94+
95+
fireEvent.focus(container.firstChild);
96+
97+
for (let i = 0; i < 19; i += 1) {
98+
fireEvent.wheel(input, { deltaY: -5 });
99+
}
100+
expect(onChange).not.toHaveBeenCalled();
101+
102+
fireEvent.wheel(input, { deltaY: -5 });
103+
expect(onChange).toHaveBeenCalledTimes(1);
104+
expect(onChange).toHaveBeenCalledWith(1);
105+
});
106+
107+
it('preserves remaining wheel delta after stepping', () => {
108+
const onChange = jest.fn();
109+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
110+
const input = container.querySelector('input');
111+
112+
fireEvent.focus(container.firstChild);
113+
fireEvent.wheel(input, { deltaY: -130 });
114+
expect(onChange).toHaveBeenCalledTimes(1);
115+
expect(onChange).toHaveBeenLastCalledWith(1);
116+
117+
fireEvent.wheel(input, { deltaY: -69 });
118+
expect(onChange).toHaveBeenCalledTimes(1);
119+
120+
fireEvent.wheel(input, { deltaY: -1 });
121+
expect(onChange).toHaveBeenCalledTimes(2);
122+
expect(onChange).toHaveBeenLastCalledWith(2);
123+
});
124+
125+
it('supports line mode wheel delta', () => {
126+
const onChange = jest.fn();
127+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
128+
129+
fireEvent.focus(container.firstChild);
130+
fireEvent.wheel(container.querySelector('input'), { deltaMode: 1, deltaY: -3 });
131+
132+
expect(onChange).toHaveBeenCalledWith(1);
133+
});
134+
135+
it('supports page mode wheel delta', () => {
136+
const onChange = jest.fn();
137+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
138+
139+
fireEvent.focus(container.firstChild);
140+
fireEvent.wheel(container.querySelector('input'), { deltaMode: 2, deltaY: -1 });
141+
142+
expect(onChange).toHaveBeenCalledWith(1);
143+
});
144+
145+
it('ignores empty wheel delta', () => {
146+
const onChange = jest.fn();
147+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
148+
149+
fireEvent.focus(container.firstChild);
150+
fireEvent.wheel(container.querySelector('input'), { deltaY: 0 });
151+
152+
expect(onChange).not.toHaveBeenCalled();
153+
});
154+
155+
it('resets accumulated wheel delta when direction changes', () => {
156+
const onChange = jest.fn();
157+
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
158+
const input = container.querySelector('input');
159+
160+
fireEvent.focus(container.firstChild);
161+
fireEvent.wheel(input, { deltaY: -50 });
162+
fireEvent.wheel(input, { deltaY: 50 });
163+
expect(onChange).not.toHaveBeenCalled();
164+
165+
fireEvent.wheel(input, { deltaY: 50 });
166+
expect(onChange).toHaveBeenCalledWith(-1);
167+
});
89168
});

0 commit comments

Comments
 (0)