diff --git a/src/InputNumber.tsx b/src/InputNumber.tsx index f3b4a2a7..f193c6a0 100644 --- a/src/InputNumber.tsx +++ b/src/InputNumber.tsx @@ -53,6 +53,22 @@ const getDecimalIfValidate = (value: ValueType) => { return decimal.isInvalidate() ? null : decimal; }; +const WHEEL_STEP_DISTANCE = 100; +const WHEEL_LINE_HEIGHT = 40; +const WHEEL_PAGE_HEIGHT = 800; +const WHEEL_DELTA_RESET_INTERVAL = 200; + +const getWheelDeltaY = (event: WheelEvent) => { + switch (event.deltaMode) { + case 1: + return event.deltaY * WHEEL_LINE_HEIGHT; + case 2: + return event.deltaY * WHEEL_PAGE_HEIGHT; + default: + return event.deltaY; + } +}; + type SemanticName = 'root' | 'actions' | 'input' | 'action' | 'prefix' | 'suffix'; export interface InputNumberProps extends Omit< @@ -189,6 +205,8 @@ const InputNumber = React.forwardRef((props, r const userTypingRef = React.useRef(false); const compositionRef = React.useRef(false); const shiftKeyRef = React.useRef(false); + const wheelDeltaRef = React.useRef(0); + const wheelTimestampRef = React.useRef(0); // ============================= Refs ============================= const rootRef = React.useRef(null); @@ -567,12 +585,36 @@ const InputNumber = React.forwardRef((props, r shiftKeyRef.current = false; }; + const onInternalWheel = useEvent((event: WheelEvent) => { + const wheelDelta = getWheelDeltaY(event); + if (!wheelDelta) { + return; + } + + const eventTimestamp = event.timeStamp || Date.now(); + if (eventTimestamp - wheelTimestampRef.current > WHEEL_DELTA_RESET_INTERVAL) { + wheelDeltaRef.current = 0; + } + wheelTimestampRef.current = eventTimestamp; + + if (wheelDeltaRef.current && Math.sign(wheelDeltaRef.current) !== Math.sign(wheelDelta)) { + wheelDeltaRef.current = 0; + } + + wheelDeltaRef.current += wheelDelta; + + if (Math.abs(wheelDeltaRef.current) >= WHEEL_STEP_DISTANCE) { + // moving mouse wheel rises wheel event with deltaY < 0 + // scroll value grows from top to bottom, as screen Y coordinate + onInternalStep(wheelDeltaRef.current < 0, 'wheel'); + wheelDeltaRef.current = 0; + } + }); + React.useEffect(() => { if (changeOnWheel && focus) { - const onWheel = (event) => { - // moving mouse wheel rises wheel event with deltaY < 0 - // scroll value grows from top to bottom, as screen Y coordinate - onInternalStep(event.deltaY < 0, 'wheel'); + const onWheel = (event: WheelEvent) => { + onInternalWheel(event); event.preventDefault(); }; const input = inputRef.current; @@ -581,10 +623,14 @@ const InputNumber = React.forwardRef((props, r // That's why we should subscribe with DOM listener // https://stackoverflow.com/questions/63663025/react-onwheel-handler-cant-preventdefault-because-its-a-passive-event-listenev input.addEventListener('wheel', onWheel, { passive: false }); - return () => input.removeEventListener('wheel', onWheel); + return () => { + input.removeEventListener('wheel', onWheel); + wheelDeltaRef.current = 0; + wheelTimestampRef.current = 0; + }; } } - }); + }, [changeOnWheel, focus, onInternalWheel]); // >>> Focus & Blur const onBlur = () => { @@ -595,6 +641,8 @@ const InputNumber = React.forwardRef((props, r setFocus(false); userTypingRef.current = false; + wheelDeltaRef.current = 0; + wheelTimestampRef.current = 0; }; // >>> Mouse events diff --git a/tests/wheel.test.tsx b/tests/wheel.test.tsx index 533e60ac..6aa3b08f 100644 --- a/tests/wheel.test.tsx +++ b/tests/wheel.test.tsx @@ -7,7 +7,7 @@ describe('InputNumber.Wheel', () => { const onChange = jest.fn(); const { container } = render(); fireEvent.focus(container.firstChild); - fireEvent.wheel(container.querySelector('input'), { deltaY: -1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: -100 }); expect(onChange).toHaveBeenCalledWith(1); }); @@ -23,7 +23,7 @@ describe('InputNumber.Wheel', () => { keyCode: KeyCode.SHIFT, shiftKey: true, }); - fireEvent.wheel(container.querySelector('input'), { deltaY: -1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: -100 }); expect(onChange).toHaveBeenCalledWith(1.3); }); @@ -31,7 +31,7 @@ describe('InputNumber.Wheel', () => { const onChange = jest.fn(); const { container } = render(); fireEvent.focus(container.firstChild); - fireEvent.wheel(container.querySelector('input'), { deltaY: 1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: 100 }); expect(onChange).toHaveBeenCalledWith(-1); }); @@ -47,7 +47,7 @@ describe('InputNumber.Wheel', () => { keyCode: KeyCode.SHIFT, shiftKey: true, }); - fireEvent.wheel(container.querySelector('input'), { deltaY: 1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: 100 }); expect(onChange).toHaveBeenCalledWith(1.1); }); @@ -56,16 +56,16 @@ describe('InputNumber.Wheel', () => { const { container, rerender } = render(); fireEvent.focus(container.firstChild); - fireEvent.wheel(container.querySelector('input'), { deltaY: -1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: -100 }); expect(onChange).not.toHaveBeenCalled(); - fireEvent.wheel(container.querySelector('input'), { deltaY: 1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: 100 }); expect(onChange).not.toHaveBeenCalled(); rerender(); fireEvent.focus(container.firstChild); - fireEvent.wheel(container.querySelector('input'), { deltaY: 1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: 100 }); expect(onChange).toHaveBeenCalledWith(-1); }); @@ -81,9 +81,70 @@ describe('InputNumber.Wheel', () => { keyCode: KeyCode.SHIFT, shiftKey: true, }); - fireEvent.wheel(container.querySelector('input'), { deltaY: -1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: -100 }); expect(onChange).toHaveBeenCalledWith(3); - fireEvent.wheel(container.querySelector('input'), { deltaY: 1 }); + fireEvent.wheel(container.querySelector('input'), { deltaY: 100 }); expect(onChange).toHaveBeenCalledWith(-3); }); + + it('accumulates high precision wheel delta', () => { + const onChange = jest.fn(); + const { container } = render(); + const input = container.querySelector('input'); + + fireEvent.focus(container.firstChild); + + for (let i = 0; i < 19; i += 1) { + fireEvent.wheel(input, { deltaY: -5 }); + } + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.wheel(input, { deltaY: -5 }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(1); + }); + + it('supports line mode wheel delta', () => { + const onChange = jest.fn(); + const { container } = render(); + + fireEvent.focus(container.firstChild); + fireEvent.wheel(container.querySelector('input'), { deltaMode: 1, deltaY: -3 }); + + expect(onChange).toHaveBeenCalledWith(1); + }); + + it('supports page mode wheel delta', () => { + const onChange = jest.fn(); + const { container } = render(); + + fireEvent.focus(container.firstChild); + fireEvent.wheel(container.querySelector('input'), { deltaMode: 2, deltaY: -1 }); + + expect(onChange).toHaveBeenCalledWith(1); + }); + + it('ignores empty wheel delta', () => { + const onChange = jest.fn(); + const { container } = render(); + + fireEvent.focus(container.firstChild); + fireEvent.wheel(container.querySelector('input'), { deltaY: 0 }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('resets accumulated wheel delta when direction changes', () => { + const onChange = jest.fn(); + const { container } = render(); + const input = container.querySelector('input'); + + fireEvent.focus(container.firstChild); + fireEvent.wheel(input, { deltaY: -50 }); + fireEvent.wheel(input, { deltaY: 50 }); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.wheel(input, { deltaY: 50 }); + expect(onChange).toHaveBeenCalledWith(-1); + }); });