diff --git a/src/form/Input/NumberInputButtons.tsx b/src/form/Input/NumberInputButtons.tsx index d09a0cd..626bb6e 100644 --- a/src/form/Input/NumberInputButtons.tsx +++ b/src/form/Input/NumberInputButtons.tsx @@ -9,6 +9,12 @@ type Props = Pick, "min" | "max" | "step" | "decimal"> & { setValue: React.Dispatch>; buttonDownRef: React.MutableRefObject; inputRef: React.RefObject; + /** + * Applies the stepped value through the native input setter so that the + * `input`/`change` events (React's `onChange`) actually fire. Falls back to + * `setValue` when omitted. + */ + applyValue?: (nextValue: string) => void; }; /** @@ -23,6 +29,7 @@ export default function NumberInputButtons({ buttonDownRef, inputRef, decimal, + applyValue, }: Props) { const PRESS_TRIGGER_TIMEOUT = 200; const PRESS_REPEAT_INTERVAL = 50; @@ -65,19 +72,30 @@ export default function NumberInputButtons({ const endsWithDecimalPoint = String(value).match(/\.$/); + /** The DOM value is the freshest source while a press repeats: the native + * setter writes it before React re-renders this component. */ + function currentValue() { + const elementValue = inputRef.current?.value; + return twuiNumberfy(elementValue ?? value, decimal); + } + function applyDelta(delta: number) { - setValue((previousValue) => { - const currentValue = twuiNumberfy(previousValue, decimal); - const nextValue = clamp(currentValue + delta); + 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)); + // 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); - }); + const finalValue = String(roundedValue); + + if (applyValue) { + applyValue(finalValue); + return; + } + + setValue(finalValue); } function stepSize() { diff --git a/src/form/Input/index.tsx b/src/form/Input/index.tsx index 59939ec..3ac06a6 100644 --- a/src/form/Input/index.tsx +++ b/src/form/Input/index.tsx @@ -286,6 +286,22 @@ export default function Input( props.onChange?.(e); } + /** Stepper buttons must behave like typing: write through the native setter + * and dispatch `input` (React's `onChange`) plus `change` (native + * listeners). React's value tracker sees the value already reported by + * `input`, so the `change` dispatch cannot fire `onChange` twice. */ + function applyStepperValue(nextValue: string) { + const element = inputRef?.current; + + if (element) { + setNativeInputValue(element, nextValue); + element.dispatchEvent(new Event("change", { bubbles: true })); + return; + } + + setValue(nextValue); + } + // function updateValue( // v: string, // el?: HTMLInputElement | HTMLTextAreaElement, @@ -524,6 +540,7 @@ export default function Input( step={props.step} buttonDownRef={buttonDownRef} decimal={decimal} + applyValue={applyStepperValue} /> ) : null}