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
+4
View File
@@ -76,6 +76,10 @@ export default function FormsPage() {
prefix="$" prefix="$"
suffix="USD" suffix="USD"
inputMode="decimal" inputMode="decimal"
numberText
min={50}
defaultValue={50}
step={5}
/> />
<Input <Input
label="Error state" label="Error state"
+84 -104
View File
@@ -1,12 +1,9 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import Row from "../../layout/Row"; import Row from "../../layout/Row";
import { Info, Minus, Plus } from "lucide-react"; import { Minus, Plus } from "lucide-react";
import twuiNumberfy from "../../utils/numberfy"; import twuiNumberfy from "../../utils/numberfy";
import type { InputProps } from "."; import type { InputProps } from ".";
let pressInterval: any;
let pressTimeout: any;
type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & { type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
value: string; value: string;
setValue: React.Dispatch<React.SetStateAction<string>>; setValue: React.Dispatch<React.SetStateAction<string>>;
@@ -28,142 +25,125 @@ export default function NumberInputButtons({
decimal, decimal,
}: Props) { }: Props) {
const PRESS_TRIGGER_TIMEOUT = 200; const PRESS_TRIGGER_TIMEOUT = 200;
const PRESS_REPEAT_INTERVAL = 50;
const DEFAULT_STEP = 1; const DEFAULT_STEP = 1;
const [buttonDown, setButtonDown] = useState(false); const [buttonDown, setButtonDown] = useState(false);
// function getNormalizedValue(value: string) { // Instance scoped so holding one stepper cannot cancel another's repeat.
// if (numberText) { const pressTimeoutRef = useRef<any>(null);
// if (props.max && twuiNumberfy(value) > twuiNumberfy(props.max)) const pressIntervalRef = useRef<any>(null);
// return getFinalValue(props.max); const heldRef = useRef(false);
// if (props.min && twuiNumberfy(value) < twuiNumberfy(props.min))
// return getFinalValue(props.min);
// return getFinalValue(value);
// } else {
// return value;
// }
// }
useEffect(() => { useEffect(() => {
buttonDownRef.current = buttonDown; buttonDownRef.current = buttonDown;
if (buttonDown) {
setValue(inputRef.current?.value || "");
} else {
setTimeout(() => {
setValue(inputRef.current?.value || "");
}, 50);
}
}, [buttonDown]); }, [buttonDown]);
function incrementDownPress() { useEffect(() => {
window.clearTimeout(pressTimeout); return () => {
window.clearInterval(pressInterval); clearPressTimers();
setButtonDown(true); };
}, []);
pressTimeout = setTimeout(() => { function isNil(v: any) {
pressInterval = setInterval(() => { return v === undefined || v === null;
increment();
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
} }
function incrementDownCancel() { function clearPressTimers() {
setButtonDown(false); window.clearTimeout(pressTimeoutRef.current);
window.clearTimeout(pressTimeout); window.clearInterval(pressIntervalRef.current);
window.clearInterval(pressInterval);
} }
function decrementDownPress() { function clamp(numberValue: number) {
window.clearTimeout(pressTimeout); const minValue = isNil(min) ? undefined : twuiNumberfy(min, decimal);
window.clearInterval(pressInterval); const maxValue = isNil(max) ? undefined : twuiNumberfy(max, decimal);
setButtonDown(true);
pressTimeout = setTimeout(() => { if (minValue !== undefined && numberValue < minValue) return minValue;
pressInterval = setInterval(() => { if (maxValue !== undefined && numberValue > maxValue) return maxValue;
decrement(); return numberValue;
}, 50);
}, PRESS_TRIGGER_TIMEOUT);
} }
function decrementDownCancel() { const endsWithDecimalPoint = String(value).match(/\.$/);
setButtonDown(false);
window.clearTimeout(pressTimeout); function applyDelta(delta: number) {
window.clearInterval(pressInterval); 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() { function increment() {
if (!inputRef.current) return; if (endsWithDecimalPoint) return;
applyDelta(stepSize());
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;
} }
function decrement() { function decrement() {
if (!inputRef.current) return; if (endsWithDecimalPoint) return;
applyDelta(-stepSize());
const existingValue = inputRef.current?.value;
const existingNumberValue = twuiNumberfy(existingValue, decimal);
let new_value = "";
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; function startPress(action: () => void) {
clearPressTimers();
heldRef.current = false;
setButtonDown(true);
pressTimeoutRef.current = setTimeout(() => {
heldRef.current = true;
pressIntervalRef.current = setInterval(action, PRESS_REPEAT_INTERVAL);
}, PRESS_TRIGGER_TIMEOUT);
}
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;
}
action();
} }
return ( return (
<Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2"> <Row className="flex-nowrap gap-1 -my-2 ml-auto -mr-2">
<Row <Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center" className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => { onClick={(e) => handleClick(e, decrement)}
e.preventDefault(); onMouseDown={() => startPress(decrement)}
decrement(); onTouchStart={() => startPress(decrement)}
}} onMouseUp={cancelPress}
onMouseDown={decrementDownPress} onMouseLeave={cancelPress}
onTouchStart={decrementDownPress} onTouchEnd={cancelPress}
onMouseUp={decrementDownCancel} onTouchCancel={cancelPress}
onMouseLeave={decrementDownCancel}
onTouchEnd={decrementDownCancel}
onTouchCancel={decrementDownCancel}
> >
<Minus size={20} /> <Minus size={20} />
</Row> </Row>
<Row <Row
className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center" className="rounded-full w-8 h-8 cursor-pointer touch-none select-none justify-center"
onClick={(e) => { onClick={(e) => handleClick(e, increment)}
e.preventDefault(); onMouseDown={() => startPress(increment)}
increment(); onTouchStart={() => startPress(increment)}
}} onMouseUp={cancelPress}
onMouseDown={incrementDownPress} onMouseLeave={cancelPress}
onTouchStart={incrementDownPress} onTouchEnd={cancelPress}
onMouseUp={incrementDownCancel} onTouchCancel={cancelPress}
onMouseLeave={incrementDownCancel}
onTouchEnd={incrementDownCancel}
onTouchCancel={incrementDownCancel}
> >
<Plus size={20} /> <Plus size={20} />
</Row> </Row>
+40 -20
View File
@@ -42,10 +42,7 @@ export type InputProps<KeyType extends string> = Omit<
>; >;
showLabel?: boolean; showLabel?: boolean;
istextarea?: boolean; istextarea?: boolean;
wrapperProps?: DetailedHTMLProps< wrapperProps?: ComponentProps<"div">;
InputHTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: ComponentProps<typeof Stack>; wrapperWrapperProps?: ComponentProps<typeof Stack>;
labelProps?: DetailedHTMLProps< labelProps?: DetailedHTMLProps<
LabelHTMLAttributes<HTMLLabelElement>, LabelHTMLAttributes<HTMLLabelElement>,
@@ -126,10 +123,19 @@ export default function Input<KeyType extends string>(
let timeoutRef = useRef<any>(null); let timeoutRef = useRef<any>(null);
let validationFnTimeoutRef = useRef<any>(null); let validationFnTimeoutRef = useRef<any>(null);
let externalValueChangeTimeoutRef = useRef<any>(null); let externalValueChangeTimeoutRef = useRef<any>(null);
const didMountRef = useRef(false);
refreshes++; refreshes++;
componentRefreshesRef.current++; componentRefreshesRef.current++;
React.useEffect(() => {
return () => {
window.clearTimeout(timeoutRef.current);
window.clearTimeout(validationFnTimeoutRef.current);
window.clearTimeout(externalValueChangeTimeoutRef.current);
};
}, []);
function getFinalValue(v: any) { function getFinalValue(v: any) {
if (rawNumber) return twuiNumberfy(v); if (rawNumber) return twuiNumberfy(v);
if (numberText) { if (numberText) {
@@ -142,11 +148,18 @@ export default function Input<KeyType extends string>(
return v; return v;
} }
const defaultInitialValue = /** `defaultValue`/`value` of `0` or `""` is valid input, so compare against
props.defaultValue || props.value * `undefined`/`null` instead of relying on truthiness. */
? getFinalValue(props.defaultValue || props.value) const initialValue = !isNil(props.defaultValue)
? props.defaultValue
: !isNil(props.value)
? props.value
: undefined; : undefined;
const defaultInitialValue = isNil(initialValue)
? undefined
: getFinalValue(initialValue);
const [validity, setValidity] = React.useState<TWUISelectValidityObject>( const [validity, setValidity] = React.useState<TWUISelectValidityObject>(
existingValidity || { existingValidity || {
isValid: true, isValid: true,
@@ -160,7 +173,7 @@ export default function Input<KeyType extends string>(
const buttonDownRef = React.useRef(false); const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState( const [value, setValue] = React.useState(
props.defaultValue ? String(props.defaultValue) : "", isNil(initialValue) ? "" : String(initialValue),
); );
const [focus, setFocus] = React.useState(false); const [focus, setFocus] = React.useState(false);
const [inputType, setInputType] = React.useState( const [inputType, setInputType] = React.useState(
@@ -188,8 +201,6 @@ export default function Input<KeyType extends string>(
}, [existingValidity]); }, [existingValidity]);
const updateValueFn = (val: string) => { const updateValueFn = (val: string) => {
if (buttonDownRef.current) return;
if (changeHandler) { if (changeHandler) {
window.clearTimeout(externalValueChangeTimeoutRef.current); window.clearTimeout(externalValueChangeTimeoutRef.current);
externalValueChangeTimeoutRef.current = setTimeout(() => { externalValueChangeTimeoutRef.current = setTimeout(() => {
@@ -199,7 +210,9 @@ export default function Input<KeyType extends string>(
if (typeof val == "string") { if (typeof val == "string") {
if (!val.match(/./)) { 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(""); setValue("");
if (istextarea && textAreaRef.current) { if (istextarea && textAreaRef.current) {
textAreaRef.current.value = ""; textAreaRef.current.value = "";
@@ -215,7 +228,7 @@ export default function Input<KeyType extends string>(
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setValidity({ setValidity({
isValid: validationRegex.test(val), isValid: validationRegex.test(val),
msg: "Value mismatch", msg: initialInvalidMessage ?? "Value mismatch",
}); });
}, finalDebounce); }, finalDebounce);
} }
@@ -250,11 +263,17 @@ export default function Input<KeyType extends string>(
// }, [refreshDefaultValue]); // }, [refreshDefaultValue]);
React.useEffect(() => { React.useEffect(() => {
// if (!existingReady) return; const element = istextarea ? textAreaRef.current : inputRef?.current;
if (istextarea && textAreaRef.current) { if (element) element.value = getFinalValue(value);
} else if (inputRef?.current) {
inputRef.current.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); updateValueFn(value);
}, [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", "relative flex items-center gap-2 rounded-default px-3 py-2 outline-1",
"hover:[&_.twui-clear-input-field-button]:opacity-100", "hover:[&_.twui-clear-input-field-button]:opacity-100",
"w-full border-none", "w-full border-none",
focus && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
focus && validity.isValid focus && validity.isValid
? "outline-slate-700 dark:outline-white/50" ? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20", : "outline-slate-300 dark:outline-white/20",
@@ -393,8 +409,8 @@ export default function Input<KeyType extends string>(
> >
{showLabel && ( {showLabel && (
<label <label
htmlFor={props.name}
{...labelProps} {...labelProps}
htmlFor={labelProps?.htmlFor || props.name}
className={twMerge( className={twMerge(
"text-xs absolute -top-2.5 left-2 text-foreground-light/80 bg-background-light", "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", "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"]; 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( function setNativeInputValue(
element: HTMLInputElement | HTMLTextAreaElement, element: HTMLInputElement | HTMLTextAreaElement,
newValue: string, newValue: string,