Fire native change events from Input number steppers

Route stepper value changes through the native input setter and dispatch
input/change events so React onChange and DOM listeners see the update.
Read the live DOM value while holding a stepper so repeats don't lag.
This commit is contained in:
2026-10-22 11:18:25 +01:00
parent 57e57863a9
commit b516084b69
2 changed files with 45 additions and 10 deletions
+23 -5
View File
@@ -9,6 +9,12 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
setValue: React.Dispatch<React.SetStateAction<string>>; setValue: React.Dispatch<React.SetStateAction<string>>;
buttonDownRef: React.MutableRefObject<boolean>; buttonDownRef: React.MutableRefObject<boolean>;
inputRef: React.RefObject<HTMLInputElement | null>; inputRef: React.RefObject<HTMLInputElement | null>;
/**
* 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, buttonDownRef,
inputRef, inputRef,
decimal, decimal,
applyValue,
}: Props) { }: Props) {
const PRESS_TRIGGER_TIMEOUT = 200; const PRESS_TRIGGER_TIMEOUT = 200;
const PRESS_REPEAT_INTERVAL = 50; const PRESS_REPEAT_INTERVAL = 50;
@@ -65,10 +72,15 @@ export default function NumberInputButtons({
const endsWithDecimalPoint = String(value).match(/\.$/); 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) { function applyDelta(delta: number) {
setValue((previousValue) => { const nextValue = clamp(currentValue() + delta);
const currentValue = twuiNumberfy(previousValue, decimal);
const nextValue = clamp(currentValue + delta);
// Round off float drift (e.g. 0.1 + 0.2) before stringifying. // Round off float drift (e.g. 0.1 + 0.2) before stringifying.
const roundedValue = const roundedValue =
@@ -76,8 +88,14 @@ export default function NumberInputButtons({
? Number(nextValue.toFixed(10)) ? Number(nextValue.toFixed(10))
: Number(nextValue.toFixed(decimal)); : Number(nextValue.toFixed(decimal));
return String(roundedValue); const finalValue = String(roundedValue);
});
if (applyValue) {
applyValue(finalValue);
return;
}
setValue(finalValue);
} }
function stepSize() { function stepSize() {
+17
View File
@@ -286,6 +286,22 @@ export default function Input<KeyType extends string>(
props.onChange?.(e); 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( // function updateValue(
// v: string, // v: string,
// el?: HTMLInputElement | HTMLTextAreaElement, // el?: HTMLInputElement | HTMLTextAreaElement,
@@ -524,6 +540,7 @@ export default function Input<KeyType extends string>(
step={props.step} step={props.step}
buttonDownRef={buttonDownRef} buttonDownRef={buttonDownRef}
decimal={decimal} decimal={decimal}
applyValue={applyStepperValue}
/> />
) : null} ) : null}
</div> </div>