UNPKG

@base-ui-components/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.

605 lines (588 loc) 24.8 kB
"use strict"; 'use client'; Object.defineProperty(exports, "__esModule", { value: true }); exports.UseNumberFieldRoot = void 0; exports.useNumberFieldRoot = useNumberFieldRoot; var React = _interopRequireWildcard(require("react")); var _useScrub = require("./useScrub"); var _formatNumber = require("../../utils/formatNumber"); var _validate = require("../utils/validate"); var _parse = require("../utils/parse"); var _constants = require("../utils/constants"); var _detectBrowser = require("../../utils/detectBrowser"); var _mergeReactProps = require("../../utils/mergeReactProps"); var _owner = require("../../utils/owner"); var _useControlled = require("../../utils/useControlled"); var _useEnhancedEffect = require("../../utils/useEnhancedEffect"); var _useEventCallback = require("../../utils/useEventCallback"); var _useForcedRerendering = require("../../utils/useForcedRerendering"); var _useBaseUiId = require("../../utils/useBaseUiId"); var _useLatestRef = require("../../utils/useLatestRef"); var _FieldRootContext = require("../../field/root/FieldRootContext"); var _useFieldControlValidation = require("../../field/control/useFieldControlValidation"); var _useForkRef = require("../../utils/useForkRef"); var _useField = require("../../field/useField"); function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } function useNumberFieldRoot(params) { const { id: idProp, name, min, max, smallStep = 0.1, step, largeStep = 10, required = false, disabled: disabledProp = false, invalid = false, readOnly = false, autoFocus = false, allowWheelScrub = false, format, value: externalValue, onValueChange: onValueChangeProp = () => {}, defaultValue } = params; const { labelId, setControlId, validationMode, setTouched, setDirty, validityData, setValidityData, disabled: fieldDisabled } = (0, _FieldRootContext.useFieldRootContext)(); const { getInputValidationProps, getValidationProps, inputRef: inputValidationRef, commitValidation } = (0, _useFieldControlValidation.useFieldControlValidation)(); const disabled = fieldDisabled || disabledProp; const minWithDefault = min ?? Number.MIN_SAFE_INTEGER; const maxWithDefault = max ?? Number.MAX_SAFE_INTEGER; const minWithZeroDefault = min ?? 0; const formatStyle = format?.style; const inputRef = React.useRef(null); const mergedRef = (0, _useForkRef.useForkRef)(inputRef, inputValidationRef); const id = (0, _useBaseUiId.useBaseUiId)(idProp); (0, _useEnhancedEffect.useEnhancedEffect)(() => { setControlId(id); return () => { setControlId(undefined); }; }, [id, setControlId]); const [valueUnwrapped, setValueUnwrapped] = (0, _useControlled.useControlled)({ controlled: externalValue, default: defaultValue, name: 'NumberField', state: 'value' }); const value = valueUnwrapped ?? null; const valueRef = (0, _useLatestRef.useLatestRef)(value); (0, _useField.useField)({ id, commitValidation, value, controlRef: inputRef }); const forceRender = (0, _useForcedRerendering.useForcedRerendering)(); const formatOptionsRef = (0, _useLatestRef.useLatestRef)(format); const onValueChange = (0, _useEventCallback.useEventCallback)(onValueChangeProp); const startTickTimeoutRef = React.useRef(-1); const tickIntervalRef = React.useRef(-1); const intentionalTouchCheckTimeoutRef = React.useRef(-1); const isPressedRef = React.useRef(false); const isHoldingShiftRef = React.useRef(false); const isHoldingAltRef = React.useRef(false); const incrementDownCoordsRef = React.useRef({ x: 0, y: 0 }); const movesAfterTouchRef = React.useRef(0); const allowInputSyncRef = React.useRef(true); const unsubscribeFromGlobalContextMenuRef = React.useRef(() => {}); const isTouchingButtonRef = React.useRef(false); const hasTouchedInputRef = React.useRef(false); (0, _useEnhancedEffect.useEnhancedEffect)(() => { if (validityData.initialValue === null && value !== validityData.initialValue) { setValidityData(prev => ({ ...prev, initialValue: value })); } }, [setValidityData, validityData.initialValue, value]); // During SSR, the value is formatted on the server, whose locale may differ from the client's // locale. This causes a hydration mismatch, which we manually suppress. This is preferable to // rendering an empty input field and then updating it with the formatted value, as the user // can still see the value prior to hydration, even if it's not formatted correctly. const [inputValue, setInputValue] = React.useState(() => (0, _formatNumber.formatNumber)(value, [], format)); const [inputMode, setInputMode] = React.useState('numeric'); const isMin = value != null && value <= minWithDefault; const isMax = value != null && value >= maxWithDefault; const getAllowedNonNumericKeys = (0, _useEventCallback.useEventCallback)(() => { const { decimal, group, currency } = (0, _parse.getNumberLocaleDetails)([], format); const keys = Array.from(new Set(['.', ',', decimal, group])); if (formatStyle === 'percent') { keys.push(..._parse.PERCENTAGES); } if (formatStyle === 'currency' && currency) { keys.push(currency); } if (minWithDefault < 0) { keys.push('-'); } return keys; }); const getStepAmount = (0, _useEventCallback.useEventCallback)(() => { if (isHoldingAltRef.current) { return smallStep; } if (isHoldingShiftRef.current) { return largeStep; } return step; }); const setValue = (0, _useEventCallback.useEventCallback)((unvalidatedValue, event) => { const validatedValue = (0, _validate.toValidatedNumber)(unvalidatedValue, { step: getStepAmount(), format: formatOptionsRef.current, minWithDefault, maxWithDefault, minWithZeroDefault }); onValueChange?.(validatedValue, event); setValueUnwrapped(validatedValue); setDirty(validatedValue !== validityData.initialValue); if (validationMode === 'onChange') { commitValidation(validatedValue); } // We need to force a re-render, because while the value may be unchanged, the formatting may // be different. This forces the `useEnhancedEffect` to run which acts as a single source of // truth to sync the input value. forceRender(); }); const incrementValue = (0, _useEventCallback.useEventCallback)((amount, dir, currentValue, event) => { const prevValue = currentValue == null ? valueRef.current : currentValue; const nextValue = typeof prevValue === 'number' ? prevValue + amount * dir : Math.max(0, min ?? 0); setValue(nextValue, event); }); const stopAutoChange = (0, _useEventCallback.useEventCallback)(() => { window.clearTimeout(intentionalTouchCheckTimeoutRef.current); window.clearTimeout(startTickTimeoutRef.current); window.clearInterval(tickIntervalRef.current); unsubscribeFromGlobalContextMenuRef.current(); movesAfterTouchRef.current = 0; }); const startAutoChange = (0, _useEventCallback.useEventCallback)(isIncrement => { stopAutoChange(); if (!inputRef.current) { return; } const win = (0, _owner.ownerWindow)(inputRef.current); function handleContextMenu(event) { event.preventDefault(); } // A global context menu is necessary to prevent the context menu from appearing when the touch // is slightly outside of the element's hit area. win.addEventListener('contextmenu', handleContextMenu); unsubscribeFromGlobalContextMenuRef.current = () => { win.removeEventListener('contextmenu', handleContextMenu); }; win.addEventListener('pointerup', () => { isPressedRef.current = false; stopAutoChange(); }, { once: true }); function tick() { const amount = getStepAmount() ?? _constants.DEFAULT_STEP; incrementValue(amount, isIncrement ? 1 : -1); } tick(); startTickTimeoutRef.current = window.setTimeout(() => { tickIntervalRef.current = window.setInterval(tick, _constants.CHANGE_VALUE_TICK_DELAY); }, _constants.START_AUTO_CHANGE_DELAY); }); // We need to update the input value when the external `value` prop changes. This ends up acting // as a single source of truth to update the input value, bypassing the need to manually set it in // each event handler internally in this hook. // This is done inside a layout effect as an alternative to the technique to set state during // render as we're accessing a ref, which must be inside an effect. // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes // // ESLint is disabled because it needs to run even if the parsed value hasn't changed, since the // value still can be formatted differently. // eslint-disable-next-line react-hooks/exhaustive-deps (0, _useEnhancedEffect.useEnhancedEffect)(function syncFormattedInputValueOnValueChange() { // This ensures the value is only updated on blur rather than every keystroke, but still // allows the input value to be updated when the value is changed externally. if (!allowInputSyncRef.current) { return; } const nextInputValue = (0, _formatNumber.formatNumber)(value, [], formatOptionsRef.current); if (nextInputValue !== inputValue) { setInputValue(nextInputValue); } }); (0, _useEnhancedEffect.useEnhancedEffect)(function setDynamicInputModeForIOS() { if (!(0, _detectBrowser.isIOS)()) { return; } // iOS numeric software keyboard doesn't have a minus key, so we need to use the default // keyboard to let the user input a negative number. let computedInputMode = 'text'; if (minWithDefault >= 0) { // iOS numeric software keyboard doesn't have a decimal key for "numeric" input mode, but // this is better than the "text" input if possible to use. computedInputMode = 'decimal'; } setInputMode(computedInputMode); }, [minWithDefault, formatStyle]); React.useEffect(() => { return () => stopAutoChange(); }, [stopAutoChange]); React.useEffect(function registerGlobalStepModifierKeyListeners() { if (disabled || readOnly || !inputRef.current) { return undefined; } function handleWindowKeyDown(event) { if (event.shiftKey) { isHoldingShiftRef.current = true; } if (event.altKey) { isHoldingAltRef.current = true; } } function handleWindowKeyUp(event) { if (!event.shiftKey) { isHoldingShiftRef.current = false; } if (!event.altKey) { isHoldingAltRef.current = false; } } function handleWindowBlur() { // A keyup event may not be dispatched when the window loses focus. isHoldingShiftRef.current = false; isHoldingAltRef.current = false; } const win = (0, _owner.ownerWindow)(inputRef.current); win.addEventListener('keydown', handleWindowKeyDown, true); win.addEventListener('keyup', handleWindowKeyUp, true); win.addEventListener('blur', handleWindowBlur); return () => { win.removeEventListener('keydown', handleWindowKeyDown, true); win.removeEventListener('keyup', handleWindowKeyUp, true); win.removeEventListener('blur', handleWindowBlur); }; }, [disabled, readOnly]); // The `onWheel` prop can't be prevented, so we need to use a global event listener. React.useEffect(function registerElementWheelListener() { const element = inputRef.current; if (disabled || readOnly || !allowWheelScrub || !element) { return undefined; } function handleWheel(event) { if ( // Allow pinch-zooming. event.ctrlKey || (0, _owner.ownerDocument)(inputRef.current).activeElement !== inputRef.current) { return; } // Prevent the default behavior to avoid scrolling the page. event.preventDefault(); const amount = getStepAmount() ?? _constants.DEFAULT_STEP; incrementValue(amount, event.deltaY > 0 ? -1 : 1, undefined, event); } element.addEventListener('wheel', handleWheel); return () => { element.removeEventListener('wheel', handleWheel); }; }, [allowWheelScrub, incrementValue, disabled, readOnly, largeStep, step, getStepAmount]); const getGroupProps = React.useCallback((externalProps = {}) => (0, _mergeReactProps.mergeReactProps)(externalProps, { role: 'group' }), []); const getCommonButtonProps = React.useCallback((isIncrement, externalProps = {}) => { function commitValue(nativeEvent) { allowInputSyncRef.current = true; // The input may be dirty but not yet blurred, so the value won't have been committed. const parsedValue = (0, _parse.parseNumber)(inputValue, formatOptionsRef.current); if (parsedValue !== null) { // The increment value function needs to know the current input value to increment it // correctly. valueRef.current = parsedValue; setValue(parsedValue, nativeEvent); } } return (0, _mergeReactProps.mergeReactProps)(externalProps, { disabled: disabled || (isIncrement ? isMax : isMin), type: 'button', 'aria-readonly': readOnly || undefined, 'aria-label': isIncrement ? 'Increase' : 'Decrease', 'aria-controls': id, // Keyboard users shouldn't have access to the buttons, since they can use the input element // to change the value. On the other hand, `aria-hidden` is not applied because touch screen // readers should be able to use the buttons. tabIndex: -1, style: { WebkitUserSelect: 'none', userSelect: 'none' }, onTouchStart() { isTouchingButtonRef.current = true; }, onTouchEnd() { isTouchingButtonRef.current = false; }, onClick(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (event.defaultPrevented || isDisabled || // If it's not a keyboard/virtual click, ignore. event.detail !== 0) { return; } commitValue(event.nativeEvent); const amount = getStepAmount() ?? _constants.DEFAULT_STEP; incrementValue(amount, isIncrement ? 1 : -1, undefined, event.nativeEvent); }, onPointerDown(event) { const isMainButton = !event.button || event.button === 0; const isDisabled = disabled || (isIncrement ? isMax : isMin); if (event.defaultPrevented || readOnly || !isMainButton || isDisabled) { return; } isPressedRef.current = true; incrementDownCoordsRef.current = { x: event.clientX, y: event.clientY }; commitValue(event.nativeEvent); // Note: "pen" is sometimes returned for mouse usage on Linux Chrome. if (event.pointerType !== 'touch') { event.preventDefault(); inputRef.current?.focus(); startAutoChange(isIncrement); } else { // We need to check if the pointerdown was intentional, and not the result of a scroll // or pinch-zoom. In that case, we don't want to change the value. intentionalTouchCheckTimeoutRef.current = window.setTimeout(() => { const moves = movesAfterTouchRef.current; movesAfterTouchRef.current = 0; if (moves < _constants.MAX_POINTER_MOVES_AFTER_TOUCH) { startAutoChange(isIncrement); } else { stopAutoChange(); } }, _constants.TOUCH_TIMEOUT); } }, onPointerMove(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (isDisabled || event.pointerType !== 'touch' || !isPressedRef.current) { return; } movesAfterTouchRef.current += 1; const { x, y } = incrementDownCoordsRef.current; const dx = x - event.clientX; const dy = y - event.clientY; // An alternative to this technique is to detect when the NumberField's parent container // has been scrolled if (dx ** 2 + dy ** 2 > _constants.SCROLLING_POINTER_MOVE_DISTANCE ** 2) { stopAutoChange(); } }, onMouseEnter(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (event.defaultPrevented || isDisabled || !isPressedRef.current || isTouchingButtonRef.current) { return; } startAutoChange(isIncrement); }, onMouseLeave() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); }, onMouseUp() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); } }); }, [disabled, isMax, isMin, readOnly, id, getStepAmount, incrementValue, inputValue, formatOptionsRef, valueRef, setValue, startAutoChange, stopAutoChange]); const getIncrementButtonProps = React.useCallback(externalProps => getCommonButtonProps(true, externalProps), [getCommonButtonProps]); const getDecrementButtonProps = React.useCallback(externalProps => getCommonButtonProps(false, externalProps), [getCommonButtonProps]); const getInputProps = React.useCallback((externalProps = {}) => (0, _mergeReactProps.mergeReactProps)(getInputValidationProps(getValidationProps(externalProps)), { id, required, autoFocus, name, disabled, readOnly, inputMode, value: inputValue, ref: mergedRef, type: 'text', autoComplete: 'off', autoCorrect: 'off', spellCheck: 'false', 'aria-roledescription': 'Number field', 'aria-invalid': invalid || 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) { if (event.defaultPrevented || readOnly || disabled || 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 || readOnly || disabled) { return; } setTouched(true); commitValidation(valueRef.current); allowInputSyncRef.current = true; if (inputValue.trim() === '') { setValue(null); return; } const parsedValue = (0, _parse.parseNumber)(inputValue, formatOptionsRef.current); if (parsedValue !== null) { setValue(parsedValue, event.nativeEvent); } }, onChange(event) { // Workaround for https://github.com/facebook/react/issues/9023 if (event.nativeEvent.defaultPrevented) { return; } allowInputSyncRef.current = false; const targetValue = event.target.value; if (targetValue.trim() === '') { setInputValue(targetValue); setValue(null, event.nativeEvent); return; } if (event.isTrusted) { setInputValue(targetValue); return; } const parsedValue = (0, _parse.parseNumber)(targetValue, formatOptionsRef.current); if (parsedValue !== null) { setInputValue(targetValue); setValue(parsedValue, event.nativeEvent); } }, onKeyDown(event) { if (event.defaultPrevented || readOnly || disabled) { return; } const nativeEvent = event.nativeEvent; allowInputSyncRef.current = true; const allowedNonNumericKeys = getAllowedNonNumericKeys(); let isAllowedNonNumericKey = allowedNonNumericKeys.includes(event.key); const { decimal, currency, percentSign } = (0, _parse.getNumberLocaleDetails)([], formatOptionsRef.current); const selectionStart = event.currentTarget.selectionStart; const selectionEnd = event.currentTarget.selectionEnd; const isAllSelected = selectionStart === 0 && selectionEnd === inputValue.length; // Allow the minus key only if there isn't already a plus or minus sign, or if all the text // is selected, or if only the minus sign is highlighted. if (event.key === '-' && allowedNonNumericKeys.includes('-')) { const isMinusHighlighted = selectionStart === 0 && selectionEnd === 1 && inputValue[0] === '-'; isAllowedNonNumericKey = !inputValue.includes('-') || isAllSelected || isMinusHighlighted; } // Only allow one of each symbol. [decimal, currency, percentSign].forEach(symbol => { if (event.key === symbol) { const symbolIndex = inputValue.indexOf(symbol); const isSymbolHighlighted = selectionStart === symbolIndex && selectionEnd === symbolIndex + 1; isAllowedNonNumericKey = !inputValue.includes(symbol) || isAllSelected || isSymbolHighlighted; } }); const isLatinNumeral = /^[0-9]$/.test(event.key); const isArabicNumeral = _parse.ARABIC_RE.test(event.key); const isHanNumeral = _parse.HAN_RE.test(event.key); const isNavigateKey = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(event.key); 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 || event.ctrlKey || event.metaKey || isAllowedNonNumericKey || isLatinNumeral || isArabicNumeral || isHanNumeral || isNavigateKey) { return; } // We need to commit the number at this point if the input hasn't been blurred. const parsedValue = (0, _parse.parseNumber)(inputValue, formatOptionsRef.current); const amount = getStepAmount() ?? _constants.DEFAULT_STEP; // Prevent insertion of text or caret from moving. event.preventDefault(); if (event.key === 'ArrowUp') { incrementValue(amount, 1, parsedValue, nativeEvent); } else if (event.key === 'ArrowDown') { incrementValue(amount, -1, parsedValue, nativeEvent); } else if (event.key === 'Home' && min != null) { setValue(min, nativeEvent); } else if (event.key === 'End' && max != null) { setValue(max, nativeEvent); } }, onPaste(event) { if (event.defaultPrevented || readOnly || disabled) { return; } // Prevent `onChange` from being called. event.preventDefault(); const clipboardData = event.clipboardData || window.Clipboard; const pastedData = clipboardData.getData('text/plain'); const parsedValue = (0, _parse.parseNumber)(pastedData, formatOptionsRef.current); if (parsedValue !== null) { allowInputSyncRef.current = false; setValue(parsedValue, event.nativeEvent); setInputValue(pastedData); } } }), [getInputValidationProps, getValidationProps, id, required, autoFocus, name, disabled, readOnly, inputMode, inputValue, mergedRef, invalid, labelId, setTouched, formatOptionsRef, commitValidation, valueRef, setValue, getAllowedNonNumericKeys, getStepAmount, min, max, incrementValue]); const scrub = (0, _useScrub.useScrub)({ disabled, readOnly, value, inputRef, incrementValue, getStepAmount }); return React.useMemo(() => ({ getGroupProps, getInputProps, getIncrementButtonProps, getDecrementButtonProps, inputRef: mergedRef, inputValue, value, ...scrub }), [getGroupProps, getInputProps, getIncrementButtonProps, getDecrementButtonProps, mergedRef, inputValue, value, scrub]); } let UseNumberFieldRoot = exports.UseNumberFieldRoot = void 0;