@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
384 lines (367 loc) • 16.4 kB
JavaScript
'use client';
import * as React from 'react';
import { SafeReact } from '@base-ui/utils/safeReact';
import { warn } from '@base-ui/utils/warn';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { stopEvent } from "../../floating-ui-react/utils.mjs";
import { useNumberFieldRootContext } from "../root/NumberFieldRootContext.mjs";
import { useFieldRootContext } from "../../internals/field-root-context/FieldRootContext.mjs";
import { useRegisterFieldControl } from "../../internals/field-register-control/useRegisterFieldControl.mjs";
import { useFormContext } from "../../internals/form-context/FormContext.mjs";
import { useLabelableContext } from "../../internals/labelable-provider/LabelableContext.mjs";
import { getNumberLocaleDetails, isNumeralChar, parseNumber, ANY_MINUS_RE, ANY_PLUS_RE, ANY_MINUS_DETECT_RE, ANY_PLUS_DETECT_RE } from "../utils/parse.mjs";
import { stateAttributesMapping } from "../utils/stateAttributesMapping.mjs";
import { useRenderElement } from "../../internals/useRenderElement.mjs";
import { createChangeEventDetails, createGenericEventDetails } from "../../internals/createBaseUIEventDetails.mjs";
import { formatNumber } from "../../utils/formatNumber.mjs";
import { useValueChanged } from "../../internals/useValueChanged.mjs";
import { REASONS } from "../../internals/reasons.mjs";
import { hasNumberFormatRoundingOptions, removeFloatingPointErrors } from "../utils/validate.mjs";
const NAVIGATE_KEYS = new Set(['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter', 'Escape']);
/**
* The native input control in the number field.
* Renders an `<input>` element.
*
* Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
*/
export const NumberFieldInput = /*#__PURE__*/React.forwardRef(function NumberFieldInput(componentProps, forwardedRef) {
const {
render,
className,
style,
...elementProps
} = componentProps;
const {
allowInputSyncRef,
disabled,
formatOptionsRef,
getAllowedNonNumericKeys,
getStepAmount,
id,
incrementValue,
inputMode,
inputValue,
max,
min,
name,
nameProp,
readOnly,
required,
setValue,
state,
setInputValue,
locale,
inputRef,
value,
onValueCommitted,
lastChangedValueRef,
hasPendingCommitRef,
valueRef
} = useNumberFieldRootContext();
const {
clearErrors
} = useFormContext();
const {
validationMode,
setTouched,
setFocused,
invalid,
shouldValidateOnChange,
validation
} = useFieldRootContext();
const {
labelId
} = useLabelableContext();
const hasTouchedInputRef = React.useRef(false);
const blockRevalidationRef = React.useRef(false);
const pendingCaretRef = React.useRef(null);
useRegisterFieldControl(inputRef, id, value, undefined, !disabled, nameProp);
// After a paste splices text into the controlled value, the browser would otherwise drop the
// caret at the end of the new value. Restore it just after the inserted text.
useIsoLayoutEffect(() => {
if (pendingCaretRef.current != null) {
const caret = pendingCaretRef.current;
pendingCaretRef.current = null;
inputRef.current?.setSelectionRange(caret, caret);
}
});
useValueChanged(value, () => {
clearErrors(name);
if (blockRevalidationRef.current && !shouldValidateOnChange()) {
blockRevalidationRef.current = false;
return;
}
validation.change(value);
});
const inputProps = {
id,
required,
disabled,
readOnly,
inputMode,
value: inputValue,
type: 'text',
autoComplete: 'off',
autoCorrect: 'off',
spellCheck: 'false',
'aria-roledescription': 'Number field',
'aria-invalid': !disabled && invalid ? true : undefined,
'aria-labelledby': labelId,
// If the server's locale does not match the client's locale, the formatting may not match,
// causing a hydration mismatch.
suppressHydrationWarning: true,
onFocus(event) {
// Read-only inputs are still focusable; only the value-changing handlers stay gated on it.
if (event.defaultPrevented || disabled) {
return;
}
setFocused(true);
if (hasTouchedInputRef.current) {
return;
}
hasTouchedInputRef.current = true;
// Browsers set selection at the start of the input field by default. We want to set it at
// the end for the first focus.
const target = event.currentTarget;
const length = target.value.length;
target.setSelectionRange(length, length);
},
onBlur(event) {
if (event.defaultPrevented || disabled) {
return;
}
setTouched(true);
setFocused(false);
if (readOnly) {
return;
}
const hadManualInput = !allowInputSyncRef.current;
const hadPendingProgrammaticChange = hasPendingCommitRef.current;
allowInputSyncRef.current = true;
if (inputValue.trim() === '') {
const clearDetails = createChangeEventDetails(REASONS.inputClear, event.nativeEvent);
setValue(null, clearDetails);
// Respect a canceled clear, mirroring the non-empty blur path below.
if (clearDetails.isCanceled) {
return;
}
if (validationMode === 'onBlur') {
validation.commit(null);
}
// Don't report a commit when blurring an already-empty field that the user never
// interacted with: nothing was cleared and no programmatic change is pending.
if (hadManualInput || hadPendingProgrammaticChange || value !== null) {
onValueCommitted(null, createGenericEventDetails(REASONS.inputClear, event.nativeEvent));
}
return;
}
const formatOptions = formatOptionsRef.current;
const parsedValue = parseNumber(inputValue, locale, formatOptions);
if (parsedValue === null) {
return;
}
// Avoid applying Intl's default precision unless the format opts into rounding.
const hasRoundingOptions = hasNumberFormatRoundingOptions(formatOptions);
let committed;
if (!hadManualInput && !hasRoundingOptions) {
// No rounding options and no manual edit: the visible text is purely formatted
// display, so keep the authoritative numeric value as-is rather than re-parsing the
// rounded text and discarding precision (e.g. focus/blur with no edits, or blur after
// a programmatic change).
committed = value;
} else if (hasRoundingOptions) {
// Explicit rounding options apply to the committed value, whether typed or external.
committed = removeFloatingPointErrors(parsedValue, formatOptions);
} else {
committed = parsedValue;
}
const nextEventDetails = createGenericEventDetails(REASONS.inputBlur, event.nativeEvent);
const shouldUpdateValue = value !== committed;
const shouldCommit = hadManualInput || shouldUpdateValue || hadPendingProgrammaticChange;
// Use the stored value after `setValue` clamps it.
let committedValue = committed;
if (shouldUpdateValue) {
const changeDetails = createChangeEventDetails(REASONS.inputBlur, event.nativeEvent);
blockRevalidationRef.current = true;
setValue(committed, changeDetails);
if (changeDetails.isCanceled) {
blockRevalidationRef.current = false;
return;
}
committedValue = lastChangedValueRef.current ?? committed;
// If validation normalized back to the current value, `useValueChanged` won't fire to
// reset the flag, so reset it here or the next external change won't revalidate.
if (committedValue === value) {
blockRevalidationRef.current = false;
}
}
if (validationMode === 'onBlur') {
validation.commit(committedValue);
}
if (shouldCommit) {
onValueCommitted(committedValue, nextEventDetails);
}
// Normalize only the displayed text
const canonicalText = formatNumber(committedValue, locale, formatOptions);
if (inputValue !== canonicalText) {
setInputValue(canonicalText);
}
},
onChange(event) {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
return;
}
allowInputSyncRef.current = false;
const targetValue = event.currentTarget.value;
if (targetValue.trim() === '') {
setInputValue(targetValue);
setValue(null, createChangeEventDetails(REASONS.inputClear, event.nativeEvent));
return;
}
// Update the input text immediately and only fire onValueChange if the typed value is
// currently parseable into a number. This preserves good UX for IME
// composition/partial input while still providing live numeric updates when possible.
const allowedNonNumericKeys = getAllowedNonNumericKeys();
const isValidCharacterString = Array.from(targetValue).every(ch => isNumeralChar(ch) || ANY_MINUS_DETECT_RE.test(ch) || allowedNonNumericKeys.has(ch));
if (!isValidCharacterString) {
return;
}
const parsedValue = parseNumber(targetValue, locale, formatOptionsRef.current);
setInputValue(targetValue);
if (parsedValue !== null) {
setValue(parsedValue, createChangeEventDetails(REASONS.inputChange, event.nativeEvent));
}
},
onKeyDown(event) {
if (event.defaultPrevented || readOnly || disabled) {
return;
}
const nativeEvent = event.nativeEvent;
// Snapshot the dirty state without clearing it: navigation/allowed keys (ArrowLeft, Tab,
// Enter, Escape, …) return early without changing the value, so marking the input synced
// here would wrongly discard dirty-input authority. Only the value-changing branches below
// mark it synced.
const hadManualInput = !allowInputSyncRef.current;
const allowedNonNumericKeys = getAllowedNonNumericKeys();
let isAllowedNonNumericKey = allowedNonNumericKeys.has(event.key);
const {
decimal,
currency,
percentSign
} = getNumberLocaleDetails(locale, formatOptionsRef.current);
const selectionStart = event.currentTarget.selectionStart;
const selectionEnd = event.currentTarget.selectionEnd;
const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length;
const selectionContainsIndex = index => selectionStart != null && selectionEnd != null && index >= selectionStart && index < selectionEnd;
// Only allow a single sign character: permit it when there is no existing sign of either
// kind, when all text is selected, or when the selection covers the existing sign so it's
// being replaced.
const signGroups = [[ANY_MINUS_DETECT_RE, ANY_MINUS_RE], [ANY_PLUS_DETECT_RE, ANY_PLUS_RE]];
signGroups.forEach(([detectRe, globalRe]) => {
if (detectRe.test(event.key) && Array.from(allowedNonNumericKeys).some(k => detectRe.test(k))) {
const existingIndex = inputValue.search(globalRe);
const isReplacingExisting = existingIndex !== -1 && selectionContainsIndex(existingIndex);
isAllowedNonNumericKey = !(ANY_MINUS_DETECT_RE.test(inputValue) || ANY_PLUS_DETECT_RE.test(inputValue)) || isAllSelected || isReplacingExisting;
}
});
// Only allow one of each symbol.
[decimal, currency, percentSign].forEach(symbol => {
if (event.key === symbol) {
const symbolIndex = inputValue.indexOf(symbol);
const isSymbolHighlighted = selectionContainsIndex(symbolIndex);
isAllowedNonNumericKey = !inputValue.includes(symbol) || isAllSelected || isSymbolHighlighted;
}
});
const isNavigateKey = NAVIGATE_KEYS.has(event.key);
// Alt+ArrowUp/ArrowDown selects smallStep, so don't treat it as a bypass modifier.
const isStepKey = event.key === 'ArrowUp' || event.key === 'ArrowDown';
if (
// Allow composition events (e.g., pinyin)
// event.nativeEvent.isComposing does not work in Safari:
// https://bugs.webkit.org/show_bug.cgi?id=165004
event.which === 229 || event.altKey && !isStepKey || event.ctrlKey || event.metaKey || isAllowedNonNumericKey || isNumeralChar(event.key) || isNavigateKey) {
return;
}
const willSetHome = event.key === 'Home' && min != null;
const willSetEnd = event.key === 'End' && max != null;
// Let the browser handle multi-character keys we don't act on (PageUp, Insert, F-keys,
// Home/End without min/max); invalid single characters are still blocked below.
if (event.key.length > 1 && !isStepKey && !willSetHome && !willSetEnd) {
return;
}
// Step from the authoritative numeric value unless the input has unsaved manual edits.
// When the text is already synced, parsing the rounded display would collapse precision,
// so pass no `currentValue` and let `incrementValue` fall back to the numeric state
// (mirrors the button path).
const currentValue = hadManualInput ? parseNumber(inputValue, locale, formatOptionsRef.current) : null;
const amount = getStepAmount(event);
// Prevent insertion of text or caret from moving.
stopEvent(event);
const commitDetails = createGenericEventDetails(REASONS.keyboard, nativeEvent);
let changed = false;
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
allowInputSyncRef.current = true;
// When stepping from the synced numeric state, refresh the commit ref to the current
// value so a canceled step can't commit a stale `lastChangedValueRef` left over from an
// earlier change (mirrors the button path).
if (!hadManualInput) {
lastChangedValueRef.current = valueRef.current;
}
changed = incrementValue(amount, {
direction: event.key === 'ArrowUp' ? 1 : -1,
currentValue,
event: nativeEvent,
reason: REASONS.keyboard
});
} else if (willSetHome) {
allowInputSyncRef.current = true;
changed = setValue(min, createChangeEventDetails(REASONS.keyboard, nativeEvent));
} else if (willSetEnd) {
allowInputSyncRef.current = true;
changed = setValue(max, createChangeEventDetails(REASONS.keyboard, nativeEvent));
}
if (changed) {
onValueCommitted(lastChangedValueRef.current ?? valueRef.current, commitDetails);
}
},
onPaste(event) {
if (event.defaultPrevented || readOnly || disabled) {
return;
}
let pastedData = '';
try {
pastedData = event.clipboardData?.getData('text/plain') ?? '';
} catch {
if (process.env.NODE_ENV !== 'production') {
const ownerStackMessage = SafeReact.captureOwnerStack?.() || '';
warn('<NumberField.Input> could not read clipboard text during paste handling.', ownerStackMessage);
}
return;
}
// Prevent `onChange` from being called.
event.preventDefault();
// Insert the pasted text at the caret/selection instead of replacing the entire value,
// matching native input behavior (e.g. pasting "5" into "123|" yields "1235").
const input = event.currentTarget;
const selectionStart = input.selectionStart ?? inputValue.length;
const selectionEnd = input.selectionEnd ?? inputValue.length;
const nextText = inputValue.slice(0, selectionStart) + pastedData + inputValue.slice(selectionEnd);
const parsedValue = parseNumber(nextText, locale, formatOptionsRef.current);
if (parsedValue !== null) {
allowInputSyncRef.current = false;
pendingCaretRef.current = selectionStart + pastedData.length;
setValue(parsedValue, createChangeEventDetails(REASONS.inputPaste, event.nativeEvent));
setInputValue(nextText);
}
}
};
const element = useRenderElement('input', componentProps, {
ref: [forwardedRef, inputRef],
state,
props: [inputProps, elementProps, props => validation.getValidationProps(disabled, props)],
stateAttributesMapping
});
return element;
});
if (process.env.NODE_ENV !== "production") NumberFieldInput.displayName = "NumberFieldInput";