Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions src/InputNumber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends ValueType = ValueType>
extends Omit<
Expand Down Expand Up @@ -189,6 +205,8 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((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<HTMLDivElement>(null);
Expand Down Expand Up @@ -567,12 +585,36 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((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;
}
Comment on lines +606 to +611

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of resetting wheelDeltaRef.current to 0 when a step is triggered, consider subtracting WHEEL_STEP_DISTANCE (preserving the sign). This ensures that any fractional delta beyond the threshold (e.g., from high-precision wheels or fast scrolls) is preserved for subsequent events rather than being discarded, making the scrolling experience much smoother and more precise.

Suggested change
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;
}
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 -= Math.sign(wheelDeltaRef.current) * WHEEL_STEP_DISTANCE;
}

});

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;
Expand All @@ -581,10 +623,14 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((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 = () => {
Expand All @@ -595,6 +641,8 @@ const InputNumber = React.forwardRef<InputNumberRef, InputNumberProps>((props, r
setFocus(false);

userTypingRef.current = false;
wheelDeltaRef.current = 0;
wheelTimestampRef.current = 0;
};

// >>> Mouse events
Expand Down
79 changes: 70 additions & 9 deletions tests/wheel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ describe('InputNumber.Wheel', () => {
const onChange = jest.fn();
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
fireEvent.focus(container.firstChild);
fireEvent.wheel(container.querySelector('input'), { deltaY: -1 });
fireEvent.wheel(container.querySelector('input'), { deltaY: -100 });
expect(onChange).toHaveBeenCalledWith(1);
});

Expand All @@ -23,15 +23,15 @@ 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);
});

it('wheel down', () => {
const onChange = jest.fn();
const { container } = render(<InputNumber onChange={onChange} changeOnWheel />);
fireEvent.focus(container.firstChild);
fireEvent.wheel(container.querySelector('input'), { deltaY: 1 });
fireEvent.wheel(container.querySelector('input'), { deltaY: 100 });
expect(onChange).toHaveBeenCalledWith(-1);
});

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

Expand All @@ -56,16 +56,16 @@ describe('InputNumber.Wheel', () => {
const { container, rerender } = render(<InputNumber onChange={onChange} />);
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(<InputNumber onChange={onChange} changeOnWheel />);
fireEvent.focus(container.firstChild);

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

Expand All @@ -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(<InputNumber onChange={onChange} changeOnWheel />);
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(<InputNumber onChange={onChange} changeOnWheel />);

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(<InputNumber onChange={onChange} changeOnWheel />);

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(<InputNumber onChange={onChange} changeOnWheel />);

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(<InputNumber onChange={onChange} changeOnWheel />);
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);
});
});
Loading