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:
@@ -76,6 +76,10 @@ export default function FormsPage() {
|
||||
prefix="$"
|
||||
suffix="USD"
|
||||
inputMode="decimal"
|
||||
numberText
|
||||
min={50}
|
||||
defaultValue={50}
|
||||
step={5}
|
||||
/>
|
||||
<Input
|
||||
label="Error state"
|
||||
|
||||
@@ -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;
|
||||
|
||||
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();
|
||||
if (endsWithDecimalPoint) return;
|
||||
applyDelta(-stepSize());
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
|
||||
+40
-20
@@ -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,11 +148,18 @@ export default function Input<KeyType extends string>(
|
||||
return v;
|
||||
}
|
||||
|
||||
const defaultInitialValue =
|
||||
props.defaultValue || props.value
|
||||
? getFinalValue(props.defaultValue || props.value)
|
||||
/** `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 || {
|
||||
isValid: true,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user