Fix Input validation, value handling and number steppers

Input:
- honour `invalidMessage` instead of always reporting "Value mismatch"
- don't let the mount value wipe an external `validity` prop, and don't
  force `isValid: true` on clear when `validity` is supplied
- sync controlled and programmatic values into `textarea` (the value
  effect had an empty branch that skipped textareas entirely)
- accept `defaultValue`/`value` of `0` and `""` (nil check, not truthiness)
- clear debounce/validation/change-handler timers on unmount
- type `wrapperProps` as `ComponentProps<"div">`
- let `labelProps.htmlFor` override the name link deterministically
- drop duplicated focus outline classes and the stale buttonDown guard
  that discarded edits made right after holding a stepper

NumberInputButtons:
- write through `setValue` so steppers work on controlled inputs and
  fire `onChange`
- clamp to both `min` and `max`, including `0` values, and respect `step`
- round float drift, ignore steps while a trailing decimal point is typed
- instance-scoped press timers with unmount cleanup; a held press no
  longer double-steps on the trailing click

Also adds a numberText stepper example to the dev playground.
This commit is contained in:
2026-10-22 05:02:16 +01:00
parent d07cbb7c84
commit 844c6bad11
3 changed files with 127 additions and 123 deletions
+82 -102
View File
@@ -1,12 +1,9 @@
import React, { useEffect, useRef, useState } from "react";
import Row from "../../layout/Row";
import { Info, Minus, Plus } from "lucide-react";
import { Minus, Plus } from "lucide-react";
import twuiNumberfy from "../../utils/numberfy";
import type { InputProps } from ".";
let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
@@ -28,142 +25,125 @@ export default function NumberInputButtons({
decimal,
}: Props) {
const PRESS_TRIGGER_TIMEOUT = 200;
const PRESS_REPEAT_INTERVAL = 50;
const DEFAULT_STEP = 1;
const [buttonDown, setButtonDown] = useState(false);
// function getNormalizedValue(value: string) {
// if (numberText) {
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max))
// return getFinalValue(props.max);
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
// return getFinalValue(props.min);
// return getFinalValue(value);
// } else {
// return value;
// }
// }
// Instance scoped so holding one stepper cannot cancel another's repeat.
const pressTimeoutRef = useRef<any>(null);
const pressIntervalRef = useRef<any>(null);
const heldRef = useRef(false);
useEffect(() => {
buttonDownRef.current = buttonDown;
if (buttonDown) {
setValue(inputRef.current?.value || "");
} else {
setTimeout(() => {
setValue(inputRef.current?.value || "");
}, 50);
}
}, [buttonDown]);
function incrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
useEffect(() => {
return () => {
clearPressTimers();
};
}, []);
pressTimeout = setTimeout(() => {
pressInterval = setInterval(() => {
increment();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
function isNil(v: any) {
return v === undefined || v === null;
}
function incrementDownCancel() {
setButtonDown(false);
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
function clearPressTimers() {
window.clearTimeout(pressTimeoutRef.current);
window.clearInterval(pressIntervalRef.current);
}
function decrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
function clamp(numberValue: number) {
const minValue = isNil(min) ? undefined : twuiNumberfy(min, decimal);
const maxValue = isNil(max) ? undefined : twuiNumberfy(max, decimal);
pressTimeout = setTimeout(() => {
pressInterval = setInterval(() => {
decrement();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
if (minValue !== undefined && numberValue < minValue) return minValue;
if (maxValue !== undefined && numberValue > maxValue) return maxValue;
return numberValue;
}
function decrementDownCancel() {
setButtonDown(false);
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
const endsWithDecimalPoint = String(value).match(/\.$/);
function applyDelta(delta: number) {
setValue((previousValue) => {
const currentValue = twuiNumberfy(previousValue, decimal);
const nextValue = clamp(currentValue + delta);
// Round off float drift (e.g. 0.1 + 0.2) before stringifying.
const roundedValue =
decimal === undefined
? Number(nextValue.toFixed(10))
: Number(nextValue.toFixed(decimal));
return String(roundedValue);
});
}
function stepSize() {
const stepValue = twuiNumberfy(step || DEFAULT_STEP, decimal);
return stepValue || DEFAULT_STEP;
}
function increment() {
if (!inputRef.current) return;
const existingValue = inputRef.current.value;
const existingNumberValue = twuiNumberfy(existingValue, decimal);
let new_value = "";
if (max && existingNumberValue >= twuiNumberfy(max, decimal)) {
new_value = twuiNumberfy(max, decimal).toLocaleString();
} else if (min && existingNumberValue < twuiNumberfy(min, decimal)) {
new_value = twuiNumberfy(min, decimal).toLocaleString();
} else {
new_value = (
existingNumberValue +
twuiNumberfy(step || DEFAULT_STEP, decimal)
).toLocaleString();
}
inputRef.current.value = new_value;
if (endsWithDecimalPoint) return;
applyDelta(stepSize());
}
function decrement() {
if (!inputRef.current) return;
if (endsWithDecimalPoint) return;
applyDelta(-stepSize());
}
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue, decimal);
function startPress(action: () => void) {
clearPressTimers();
heldRef.current = false;
setButtonDown(true);
let new_value = "";
pressTimeoutRef.current = setTimeout(() => {
heldRef.current = true;
pressIntervalRef.current = setInterval(action, PRESS_REPEAT_INTERVAL);
}, PRESS_TRIGGER_TIMEOUT);
}
if (min && existingNumberValue <= twuiNumberfy(min, decimal)) {
new_value = twuiNumberfy(min, decimal).toLocaleString();
} else {
new_value = (
existingNumberValue -
twuiNumberfy(step || DEFAULT_STEP, decimal)
).toLocaleString();
function cancelPress() {
clearPressTimers();
setButtonDown(false);
}
/** A held press already stepped the value; ignore the trailing click. */
function handleClick(e: React.MouseEvent, action: () => void) {
e.preventDefault();
if (heldRef.current) {
heldRef.current = false;
return;
}
inputRef.current.value = new_value;
action();
}
return (
<Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2">
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
decrement();
}}
onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel}
onMouseLeave={decrementDownCancel}
onTouchEnd={decrementDownCancel}
onTouchCancel={decrementDownCancel}
onClick={(e) => handleClick(e, decrement)}
onMouseDown={() => startPress(decrement)}
onTouchStart={() => startPress(decrement)}
onMouseUp={cancelPress}
onMouseLeave={cancelPress}
onTouchEnd={cancelPress}
onTouchCancel={cancelPress}
>
<Minus size={20} />
</Row>
<Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => {
e.preventDefault();
increment();
}}
onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel}
onMouseLeave={incrementDownCancel}
onTouchEnd={incrementDownCancel}
onTouchCancel={incrementDownCancel}
onClick={(e) => handleClick(e, increment)}
onMouseDown={() => startPress(increment)}
onTouchStart={() => startPress(increment)}
onMouseUp={cancelPress}
onMouseLeave={cancelPress}
onTouchEnd={cancelPress}
onTouchCancel={cancelPress}
>
<Plus size={20} />
</Row>
+41 -21
View File
@@ -42,10 +42,7 @@ export type InputProps<KeyType extends string> = Omit<
>;
showLabel?: boolean;
istextarea?: boolean;
wrapperProps?: DetailedHTMLProps<
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperProps?: ComponentProps<"div">;
wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>,
@@ -126,10 +123,19 @@ export default function Input<KeyType extends string>(
let timeoutRef = useRef<any>(null);
let validationFnTimeoutRef = useRef<any>(null);
let externalValueChangeTimeoutRef = useRef<any>(null);
const didMountRef = useRef(false);
refreshes++;
componentRefreshesRef.current++;
React.useEffect(() => {
return () => {
window.clearTimeout(timeoutRef.current);
window.clearTimeout(validationFnTimeoutRef.current);
window.clearTimeout(externalValueChangeTimeoutRef.current);
};
}, []);
function getFinalValue(v: any) {
if (rawNumber) return twuiNumberfy(v);
if (numberText) {
@@ -142,10 +148,17 @@ export default function Input<KeyType extends string>(
return v;
}
const defaultInitialValue =
props.defaultValue || props.value
? getFinalValue(props.defaultValue || props.value)
: undefined;
/** `defaultValue`/`value` of `0` or `""` is valid input, so compare against
* `undefined`/`null` instead of relying on truthiness. */
const initialValue = !isNil(props.defaultValue)
? props.defaultValue
: !isNil(props.value)
? props.value
: undefined;
const defaultInitialValue = isNil(initialValue)
? undefined
: getFinalValue(initialValue);
const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || {
@@ -160,7 +173,7 @@ export default function Input<KeyType extends string>(
const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
props.defaultValue ? String(props.defaultValue) : "",
isNil(initialValue) ? "" : String(initialValue),
);
const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState(
@@ -188,8 +201,6 @@ export default function Input<KeyType extends string>(
}, [existingValidity]);
const updateValueFn = (val: string) => {
if (buttonDownRef.current) return;
if (changeHandler) {
window.clearTimeout(externalValueChangeTimeoutRef.current);
externalValueChangeTimeoutRef.current = setTimeout(() => {
@@ -199,7 +210,9 @@ export default function Input<KeyType extends string>(
if (typeof val == "string") {
if (!val.match(/./)) {
setValidity({ isValid: true });
// An externally supplied `validity` stays authoritative, so a
// cleared `required` field is not reported as valid.
if (!existingValidity) setValidity({ isValid: true });
setValue("");
if (istextarea && textAreaRef.current) {
textAreaRef.current.value = "";
@@ -215,7 +228,7 @@ export default function Input<KeyType extends string>(
timeoutRef.current = setTimeout(() => {
setValidity({
isValid: validationRegex.test(val),
msg: "Value mismatch",
msg: initialInvalidMessage ?? "Value mismatch",
});
}, finalDebounce);
}
@@ -250,11 +263,17 @@ export default function Input<KeyType extends string>(
// }, [refreshDefaultValue]);
React.useEffect(() => {
// if (!existingReady) return;
if (istextarea && textAreaRef.current) {
} else if (inputRef?.current) {
inputRef.current.value = getFinalValue(value);
const element = istextarea ? textAreaRef.current : inputRef?.current;
if (element) element.value = getFinalValue(value);
if (!didMountRef.current) {
didMountRef.current = true;
// Do not validate the mount value: an external `validity` prop owns
// the initial state until the user actually edits the field.
if (!existingValidity) updateValueFn(value);
return;
}
updateValueFn(value);
}, [value]);
@@ -363,9 +382,6 @@ export default function Input<KeyType extends string>(
"relative flex items-center gap-2 rounded-default px-3 py-2 outline-1",
"hover:[&_.twui-clear-input-field-button]:opacity-100",
"w-full border-none",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
@@ -393,8 +409,8 @@ export default function Input<KeyType extends string>(
>
{showLabel && (
<label
htmlFor={props.name}
{...labelProps}
htmlFor={labelProps?.htmlFor || props.name}
className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/80 dark:bg-background-dark whitespace-nowrap",
@@ -555,6 +571,10 @@ export default function Input<KeyType extends string>(
const PICKER_INPUT_TYPES = ["date", "datetime-local", "time", "month", "week"];
function isNil(value: unknown): value is undefined | null {
return value === undefined || value === null;
}
function setNativeInputValue(
element: HTMLInputElement | HTMLTextAreaElement,
newValue: string,