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