UNPKG

@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
'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";