NumberInput.tsx 7.5 Ko
Télécharger le fichier
import { NumberField } from '@base-ui/react/number-field';
import { InputHTMLAttributes, MouseEvent as ReactMouseEvent, Ref, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
export interface NumberChangeEvent {
target: { value: string; valueAsNumber: number; name?: string };
currentTarget: { value: string; valueAsNumber: number; name?: string };
preventDefault: () => void;
stopPropagation: () => void;
}
export interface NumberInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'type' | 'min' | 'max' | 'step'> {
value?: number | string | null;
/** Same fields a native number input's change event exposes: `target.value` (string) and `target.valueAsNumber`. */
onChange?: (event: NumberChangeEvent) => void;
min?: number | string;
max?: number | string;
step?: number | string;
/**
* Like a native field with min / max: typed text may leave the range (the caller validates it),
* while arrows, buttons and the wheel stay inside it. Without it Base UI clamps the typed text too.
*/
allowOutOfRange?: boolean;
ref?: Ref<HTMLInputElement>;
}
// No digit grouping and no 3-digit rounding: the field has to round-trip the raw number, like a native one.
const FORMAT: Intl.NumberFormatOptions = { useGrouping: false, maximumFractionDigits: 20 };
const toNumber = (value: number | string | null | undefined): number | null => {
if (value === null || value === undefined || value === '') return null;
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toOptionalNumber = (value: number | string | undefined): number | undefined => toNumber(value) ?? undefined;
const makeEvent = (value: number | null, name?: string): NumberChangeEvent => {
const text = value === null ? '' : String(value);
const valueAsNumber = value === null ? Number.NaN : value;
return {
target: { value: text, valueAsNumber, name },
currentTarget: { value: text, valueAsNumber, name },
preventDefault: () => undefined,
stopPropagation: () => undefined
};
};
const STEPPER_WIDTH = 14;
const HIDE_DELAY_MS = 90;
interface StepperRect {
top: number;
left: number;
height: number;
}
/**
* The up / down buttons of a native number field, drawn as an overlay over the right edge of
* the input (it is a portal, so the field's own layout never changes).
*/
const NumberSteppers = ({ input, onEnter, onLeave }: { input: HTMLInputElement; onEnter: () => void; onLeave: () => void }) => {
const [rect, setRect] = useState<StepperRect | null>(null);
useLayoutEffect(() => {
const measure = () => {
const box = input.getBoundingClientRect();
setRect({ top: box.top + 1, left: box.right - STEPPER_WIDTH - 1, height: Math.max(box.height - 2, 2) });
};
measure();
window.addEventListener('scroll', measure, true);
window.addEventListener('resize', measure);
return () => {
window.removeEventListener('scroll', measure, true);
window.removeEventListener('resize', measure);
};
}, [input]);
if (!rect) return null;
return createPortal(
<span
className="octane-number-steppers"
style={{ top: rect.top, left: rect.left, width: STEPPER_WIDTH, height: rect.height }}
// Keeps the focus (and the caret) in the input while a stepper is pressed.
onMouseDown={(event: ReactMouseEvent) => event.preventDefault()}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
<NumberField.Increment className="octane-number-step octane-number-step--up" />
<NumberField.Decrement className="octane-number-step octane-number-step--down" />
</span>,
document.body
);
};
const assignRef = <T,>(ref: Ref<T> | undefined, node: T | null) => {
if (typeof ref === 'function') ref(node);
else if (ref) (ref as { current: T | null }).current = node;
};
/**
* Base UI number field that is a drop-in for `<input type="number" value onChange>`.
* The box carries `octane-number-input` so the css written for `input[type='number']`
* also applies. Like a native field, up / down buttons appear while the pointer is over it
* or it has focus (an overlay, so the layout is untouched); arrow keys and press-and-hold
* step too. The wrapper span is `display: contents`.
*/
export const NumberInput = (props: NumberInputProps) => {
const {
value,
onChange,
min,
max,
step,
allowOutOfRange,
className,
disabled,
readOnly,
name,
id,
required,
ref,
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
...rest
} = props;
const [input, setInput] = useState<HTMLInputElement | null>(null);
const [pointerOver, setPointerOver] = useState(false);
const [focused, setFocused] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHide = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = null;
}, []);
const show = useCallback(() => {
cancelHide();
setPointerOver(true);
}, [cancelHide]);
const hideSoon = useCallback(() => {
cancelHide();
hideTimer.current = setTimeout(() => setPointerOver(false), HIDE_DELAY_MS);
}, [cancelHide]);
useEffect(() => cancelHide, [cancelHide]);
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
setInput(node);
assignRef(ref, node);
},
[ref]
);
const steppersVisible = !!input && !disabled && !readOnly && (pointerOver || focused);
return (
<NumberField.Root
allowOutOfRange={allowOutOfRange}
className="octane-number-field"
disabled={disabled}
format={FORMAT}
id={id}
max={toOptionalNumber(max)}
min={toOptionalNumber(min)}
name={name}
readOnly={readOnly}
required={required}
step={toOptionalNumber(step)}
value={toNumber(value)}
onValueChange={(next) => onChange?.(makeEvent(next, name))}
>
<NumberField.Input
ref={setInputRef}
aria-valuemax={toOptionalNumber(max)}
aria-valuemin={toOptionalNumber(min)}
aria-valuenow={toNumber(value) ?? undefined}
className={className ? `octane-number-input ${className}` : 'octane-number-input'}
// Keeps the role a native number input has; Base UI renders a text input.
role="spinbutton"
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onMouseEnter={(event) => {
show();
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
hideSoon();
onMouseLeave?.(event);
}}
{...rest}
/>
{steppersVisible && <NumberSteppers input={input} onEnter={show} onLeave={hideSoon} />}
</NumberField.Root>
);
};
