Skip to content

Instantly share code, notes, and snippets.

@sgb-io
Created March 27, 2024 12:59
Show Gist options
  • Save sgb-io/800d8f99d434b7ca5a89bf8b6fe3ffcf to your computer and use it in GitHub Desktop.
Save sgb-io/800d8f99d434b7ca5a89bf8b6fe3ffcf to your computer and use it in GitHub Desktop.
import React, { useState, useEffect } from "react";
type NativeEventInputType =
| "insertText"
| "deleteContentBackward"
| "insertFromPaste"
| "formatBold"
| "deleteByCut";
function isNativeEventTyping(inputType: NativeEventInputType) {
switch (inputType) {
case "deleteContentBackward":
case "insertFromPaste":
case "insertText":
case "formatBold":
case "deleteByCut":
return true;
default:
return false;
}
}
const countDecimalPlaces = (value: number) => {
if (!isNaN(value)) {
const decimalPlaces = String(value)
.match(/\d*\.\d*/)?.[0]
?.split(".")[1];
if (decimalPlaces) {
return decimalPlaces.length;
}
}
return 0;
};
function sanitizeIncrementedValue(value: number, numDecimals: number) {
return parseFloat(value.toFixed(numDecimals));
}
export const SteppedNumberInput: React.FC<{
step: string;
initialValue?: number;
onChange?: (value: number) => void;
}> = ({ step, initialValue, onChange }) => {
const [arrowChange, setArrowChange] = useState<number | undefined>();
const [value, setValue] = useState<number | string>(initialValue ?? "");
const stepAmt = parseFloat(step);
const minDecimals = countDecimalPlaces(parseFloat(step));
// Manually apply a stepper change
const applyStepChange = () => {
if (typeof arrowChange !== "undefined") {
// If there is non-empty content, parse to a number
// If there is empty content, the input is blank, treat as 0
const valueNum =
typeof value === "string"
? value === ""
? 0
: parseFloat(value)
: value;
const wasUpwards = arrowChange > valueNum;
const wasDownwards = arrowChange < valueNum;
if (wasUpwards || wasDownwards) {
const change = wasUpwards ? stepAmt : -stepAmt;
const valueDecimals = countDecimalPlaces(valueNum);
const numDecimals =
valueDecimals > minDecimals ? valueDecimals : minDecimals;
const newValue = sanitizeIncrementedValue(
valueNum + change,
numDecimals
);
if (!isNaN(newValue)) {
setValue(newValue);
}
}
setArrowChange(undefined);
}
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Do not change the input value if we detect a stepper was clicked
// Instead, track what the value would have changed to
const isTypingChange = isNativeEventTyping(
(event.nativeEvent as any).inputType
);
if (!isTypingChange) {
setArrowChange(parseFloat(event.currentTarget.value));
event.preventDefault();
return;
}
// Update the input value as normal
const newValue = parseFloat(event.target.value);
// Allow for blank value where the user types a dash (when entering a negative value)
if (event.target.value === "") {
setValue("");
return;
}
if (!isNaN(newValue)) {
setValue(newValue);
}
};
// Trigger step changes when arrow buttons are clicked
const onMouseDown = () => {
applyStepChange();
};
// Trigger stepper changes when stepper click values change
useEffect(() => {
applyStepChange();
}, [arrowChange, applyStepChange]);
// Notify parent when value changes to a valid number
useEffect(() => {
if (!onChange) {
return;
}
if (typeof value === "string") {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
onChange(parsed);
}
}
if (typeof value === "number") {
onChange(value);
}
}, [value, onChange]);
const onBlur = () => {
if (value === "" && typeof initialValue === "number") {
setValue(initialValue);
}
};
return (
<input
type="number"
step="any"
value={value}
onChange={handleInputChange}
onMouseDown={onMouseDown}
onBlur={onBlur}
/>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment