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="$"
|
prefix="$"
|
||||||
suffix="USD"
|
suffix="USD"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
|
numberText
|
||||||
|
min={50}
|
||||||
|
defaultValue={50}
|
||||||
|
step={5}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Error state"
|
label="Error state"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user