Skip to content

Instantly share code, notes, and snippets.

@kripod
Last active January 4, 2020 16:07
Show Gist options
  • Save kripod/a0fde82708df64c35fbf1521f951374f to your computer and use it in GitHub Desktop.
Save kripod/a0fde82708df64c35fbf1521f951374f to your computer and use it in GitHub Desktop.
React NumberInput with a value of type Number
// Demo: https://codesandbox.io/s/react-numberinput-v5-7lqtb
import React, { useEffect, useState } from 'react';
function clamp(
x: number,
lower: number = Number.NEGATIVE_INFINITY,
upper: number = Number.POSITIVE_INFINITY,
): number {
return Math.min(Math.max(x, lower), upper);
}
function roundTo(x: number, precision: number): number {
// `/ (1 / precision)` is preferred over `* precision` to avoid rounding bugs
return Math.round(x / precision) / (1 / precision);
}
function toInner(value: number | null | undefined): string {
return value != null ? String(value) : '';
}
function toOuter(value: string): number | null {
return value ? Number(value) : null;
}
export interface NumberInputProps
extends Omit<React.PropsWithoutRef<JSX.IntrinsicElements['input']>, 'value'> {
value?: number | null;
min?: number;
max?: number;
step?: number;
clampOnBlur?: boolean;
roundOnBlur?: boolean;
onValueChange?: (value: number | null) => void;
}
// TODO: Use `React.RefForwardingComponent`
// See: https://github.com/strothj/react-docgen-typescript-loader/issues/76
const NumberInput: React.FunctionComponent<NumberInputProps> = React.forwardRef(
(
{
value,
min,
max,
step = 1,
clampOnBlur = true,
roundOnBlur = true,
onValueChange,
onChange,
onBlur,
...props
}: NumberInputProps,
ref: React.Ref<HTMLInputElement>,
) => {
const [innerValue, setInnerValue] = useState(toInner(value));
// Propagate changes of `value` to `innerValue`
useEffect(() => {
setInnerValue(toInner(value));
// Ignore `innerValue` changes to prevent locking into a state forever
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
function handleEvent<T extends React.SyntheticEvent>(
baseCallack: ((event: T) => void) | undefined,
event: T,
nextInnerValue: string,
transformValue?: (value: number) => number,
): void {
// Allow base callback to cancel the handler below
baseCallack?.(event);
if (event.defaultPrevented) return;
let nextValue = toOuter(nextInnerValue);
if (transformValue && nextValue != null) {
nextValue = transformValue(nextValue);
setInnerValue(toInner(nextValue));
} else {
// Keep non-digits in place, e.g. while typing a decimal separator
setInnerValue(nextInnerValue);
}
// Prevent false positive event triggering
if (nextValue !== toOuter(innerValue)) {
onValueChange?.(nextValue);
}
}
let inputMode: React.HTMLAttributes<HTMLInputElement>['inputMode'];
if (Number(min) >= 0)
inputMode = Number.isInteger(step) ? 'numeric' : 'decimal';
return (
<input
ref={ref}
type="number"
pattern={inputMode === 'numeric' ? '\\d*' : undefined} // iOS fallback
inputMode={inputMode}
value={innerValue}
min={min}
max={max}
step={step}
onChange={(event): void => {
handleEvent(onChange, event, event.currentTarget.value);
}}
onBlur={(event): void => {
handleEvent(onBlur, event, innerValue, nextValue => {
/* eslint-disable no-param-reassign */
if (clampOnBlur) nextValue = clamp(nextValue, min, max);
if (roundOnBlur) nextValue = roundTo(nextValue, step);
/* eslint-enable no-param-reassign */
return nextValue;
});
}}
{...props}
/>
);
},
);
export default NumberInput;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment