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:
@@ -9,6 +9,12 @@ type Props = Pick<InputProps<any>, "min" | "max" | "step" | "decimal"> & {
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
buttonDownRef: React.MutableRefObject<boolean>;
|
||||
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,
|
||||
inputRef,
|
||||
decimal,
|
||||
applyValue,
|
||||
}: Props) {
|
||||
const PRESS_TRIGGER_TIMEOUT = 200;
|
||||
const PRESS_REPEAT_INTERVAL = 50;
|
||||
@@ -65,10 +72,15 @@ 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 =
|
||||
@@ -76,8 +88,14 @@ export default function NumberInputButtons({
|
||||
? Number(nextValue.toFixed(10))
|
||||
: Number(nextValue.toFixed(decimal));
|
||||
|
||||
return String(roundedValue);
|
||||
});
|
||||
const finalValue = String(roundedValue);
|
||||
|
||||
if (applyValue) {
|
||||
applyValue(finalValue);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(finalValue);
|
||||
}
|
||||
|
||||
function stepSize() {
|
||||
|
||||
@@ -286,6 +286,22 @@ export default function Input<KeyType extends string>(
|
||||
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<KeyType extends string>(
|
||||
step={props.step}
|
||||
buttonDownRef={buttonDownRef}
|
||||
decimal={decimal}
|
||||
applyValue={applyStepperValue}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user