diff --git a/dev/src/pages/forms.tsx b/dev/src/pages/forms.tsx
index aa47854..1af0137 100644
--- a/dev/src/pages/forms.tsx
+++ b/dev/src/pages/forms.tsx
@@ -76,6 +76,10 @@ export default function FormsPage() {
prefix="$"
suffix="USD"
inputMode="decimal"
+ numberText
+ min={50}
+ defaultValue={50}
+ step={5}
/>
, "min" | "max" | "step" | "decimal"> & {
value: string;
setValue: React.Dispatch>;
@@ -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(null);
+ const pressIntervalRef = useRef(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 (
{
- 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}
>
{
- 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}
>
diff --git a/src/form/Input/index.tsx b/src/form/Input/index.tsx
index aee00cc..59939ec 100644
--- a/src/form/Input/index.tsx
+++ b/src/form/Input/index.tsx
@@ -42,10 +42,7 @@ export type InputProps = Omit<
>;
showLabel?: boolean;
istextarea?: boolean;
- wrapperProps?: DetailedHTMLProps<
- InputHTMLAttributes,
- HTMLDivElement
- >;
+ wrapperProps?: ComponentProps<"div">;
wrapperWrapperProps?: ComponentProps;
labelProps?: DetailedHTMLProps<
LabelHTMLAttributes,
@@ -126,10 +123,19 @@ export default function Input(
let timeoutRef = useRef(null);
let validationFnTimeoutRef = useRef(null);
let externalValueChangeTimeoutRef = useRef(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(
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(
existingValidity || {
@@ -160,7 +173,7 @@ export default function Input(
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(
}, [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(
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(
timeoutRef.current = setTimeout(() => {
setValidity({
isValid: validationRegex.test(val),
- msg: "Value mismatch",
+ msg: initialInvalidMessage ?? "Value mismatch",
});
}, finalDebounce);
}
@@ -250,11 +263,17 @@ export default function Input(
// }, [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(
"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(
>
{showLabel && (