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 && (