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.

390 lines (373 loc) 17.3 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.NumberFieldInput = void 0; var React = _interopRequireWildcard(require("react")); var _safeReact = require("@base-ui/utils/safeReact"); var _warn = require("@base-ui/utils/warn"); var _useIsoLayoutEffect = require("@base-ui/utils/useIsoLayoutEffect"); var _utils = require("../../floating-ui-react/utils"); var _NumberFieldRootContext = require("../root/NumberFieldRootContext"); var _FieldRootContext = require("../../internals/field-root-context/FieldRootContext"); var _useRegisterFieldControl = require("../../internals/field-register-control/useRegisterFieldControl"); var _FormContext = require("../../internals/form-context/FormContext"); var _LabelableContext = require("../../internals/labelable-provider/LabelableContext"); var _parse = require("../utils/parse"); var _stateAttributesMapping = require("../utils/stateAttributesMapping"); var _useRenderElement = require("../../internals/useRenderElement"); var _createBaseUIEventDetails = require("../../internals/createBaseUIEventDetails"); var _formatNumber = require("../../utils/formatNumber"); var _useValueChanged = require("../../internals/useValueChanged"); var _reasons = require("../../internals/reasons"); var _validate = require("../utils/validate"); 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) */ const NumberFieldInput = exports.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 } = (0, _NumberFieldRootContext.useNumberFieldRootContext)(); const { clearErrors } = (0, _FormContext.useFormContext)(); const { validationMode, setTouched, setFocused, invalid, shouldValidateOnChange, validation } = (0, _FieldRootContext.useFieldRootContext)(); const { labelId } = (0, _LabelableContext.useLabelableContext)(); const hasTouchedInputRef = React.useRef(false); const blockRevalidationRef = React.useRef(false); const pendingCaretRef = React.useRef(null); (0, _useRegisterFieldControl.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. (0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => { if (pendingCaretRef.current != null) { const caret = pendingCaretRef.current; pendingCaretRef.current = null; inputRef.current?.setSelectionRange(caret, caret); } }); (0, _useValueChanged.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 = (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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, (0, _createBaseUIEventDetails.createGenericEventDetails)(_reasons.REASONS.inputClear, event.nativeEvent)); } return; } const formatOptions = formatOptionsRef.current; const parsedValue = (0, _parse.parseNumber)(inputValue, locale, formatOptions); if (parsedValue === null) { return; } // Avoid applying Intl's default precision unless the format opts into rounding. const hasRoundingOptions = (0, _validate.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 = (0, _validate.removeFloatingPointErrors)(parsedValue, formatOptions); } else { committed = parsedValue; } const nextEventDetails = (0, _createBaseUIEventDetails.createGenericEventDetails)(_reasons.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 = (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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 = (0, _formatNumber.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, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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 => (0, _parse.isNumeralChar)(ch) || _parse.ANY_MINUS_DETECT_RE.test(ch) || allowedNonNumericKeys.has(ch)); if (!isValidCharacterString) { return; } const parsedValue = (0, _parse.parseNumber)(targetValue, locale, formatOptionsRef.current); setInputValue(targetValue); if (parsedValue !== null) { setValue(parsedValue, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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 } = (0, _parse.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 = [[_parse.ANY_MINUS_DETECT_RE, _parse.ANY_MINUS_RE], [_parse.ANY_PLUS_DETECT_RE, _parse.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 = !(_parse.ANY_MINUS_DETECT_RE.test(inputValue) || _parse.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 || (0, _parse.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 ? (0, _parse.parseNumber)(inputValue, locale, formatOptionsRef.current) : null; const amount = getStepAmount(event); // Prevent insertion of text or caret from moving. (0, _utils.stopEvent)(event); const commitDetails = (0, _createBaseUIEventDetails.createGenericEventDetails)(_reasons.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.REASONS.keyboard }); } else if (willSetHome) { allowInputSyncRef.current = true; changed = setValue(min, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.keyboard, nativeEvent)); } else if (willSetEnd) { allowInputSyncRef.current = true; changed = setValue(max, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.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.SafeReact.captureOwnerStack?.() || ''; (0, _warn.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 = (0, _parse.parseNumber)(nextText, locale, formatOptionsRef.current); if (parsedValue !== null) { allowInputSyncRef.current = false; pendingCaretRef.current = selectionStart + pastedData.length; setValue(parsedValue, (0, _createBaseUIEventDetails.createChangeEventDetails)(_reasons.REASONS.inputPaste, event.nativeEvent)); setInputValue(nextText); } } }; const element = (0, _useRenderElement.useRenderElement)('input', componentProps, { ref: [forwardedRef, inputRef], state, props: [inputProps, elementProps, props => validation.getValidationProps(disabled, props)], stateAttributesMapping: _stateAttributesMapping.stateAttributesMapping }); return element; }); if (process.env.NODE_ENV !== "production") NumberFieldInput.displayName = "NumberFieldInput";