Last active
January 4, 2020 16:07
-
-
Save kripod/a0fde82708df64c35fbf1521f951374f to your computer and use it in GitHub Desktop.
React NumberInput with a value of type Number
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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