@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.
399 lines (388 loc) • 15.4 kB
JavaScript
'use client';
import * as React from 'react';
import { useControlled } from '@base-ui-components/utils/useControlled';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { useInterval } from '@base-ui-components/utils/useInterval';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef';
import { useForcedRerendering } from '@base-ui-components/utils/useForcedRerendering';
import { ownerDocument, ownerWindow } from '@base-ui-components/utils/owner';
import { isIOS } from '@base-ui-components/utils/detectBrowser';
import { NumberFieldRootContext } from "./NumberFieldRootContext.js";
import { useFieldRootContext } from "../../field/root/FieldRootContext.js";
import { useLabelableId } from "../../labelable-provider/useLabelableId.js";
import { stateAttributesMapping } from "../utils/stateAttributesMapping.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { getNumberLocaleDetails, PERMILLE, PERCENTAGES, SPACE_SEPARATOR_RE, BASE_NON_NUMERIC_SYMBOLS, MINUS_SIGNS_WITH_ASCII, PLUS_SIGNS_WITH_ASCII } from "../utils/parse.js";
import { formatNumber, formatNumberMaxPrecision } from "../../utils/formatNumber.js";
import { CHANGE_VALUE_TICK_DELAY, DEFAULT_STEP, START_AUTO_CHANGE_DELAY } from "../utils/constants.js";
import { toValidatedNumber } from "../utils/validate.js";
import { createChangeEventDetails, createGenericEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/**
* Groups all parts of the number field and manages its state.
* Renders a `<div>` element.
*
* Documentation: [Base UI Number Field](https://base-ui.com/react/components/number-field)
*/
export const NumberFieldRoot = /*#__PURE__*/React.forwardRef(function NumberFieldRoot(componentProps, forwardedRef) {
const {
id: idProp,
min,
max,
smallStep = 0.1,
step = 1,
largeStep = 10,
required = false,
disabled: disabledProp = false,
readOnly = false,
name: nameProp,
defaultValue,
value: valueProp,
onValueChange: onValueChangeProp,
onValueCommitted: onValueCommittedProp,
allowWheelScrub = false,
snapOnStep = false,
format,
locale,
render,
className,
inputRef: inputRefProp,
...elementProps
} = componentProps;
const {
setDirty,
validityData,
disabled: fieldDisabled,
setFilled,
invalid,
name: fieldName,
state: fieldState
} = useFieldRootContext();
const disabled = fieldDisabled || disabledProp;
const name = fieldName ?? nameProp;
const [isScrubbing, setIsScrubbing] = React.useState(false);
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 id = useLabelableId({
id: idProp
});
const [valueUnwrapped, setValueUnwrapped] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'NumberField',
state: 'value'
});
const value = valueUnwrapped ?? null;
const valueRef = useValueAsRef(value);
useIsoLayoutEffect(() => {
setFilled(value !== null);
}, [setFilled, value]);
const forceRender = useForcedRerendering();
const formatOptionsRef = useValueAsRef(format);
const hasPendingCommitRef = React.useRef(false);
const onValueCommitted = useStableCallback((nextValue, eventDetails) => {
hasPendingCommitRef.current = false;
onValueCommittedProp?.(nextValue, eventDetails);
});
const startTickTimeout = useTimeout();
const tickInterval = useInterval();
const intentionalTouchCheckTimeout = useTimeout();
const isPressedRef = React.useRef(false);
const movesAfterTouchRef = React.useRef(0);
const allowInputSyncRef = React.useRef(true);
const lastChangedValueRef = React.useRef(null);
const unsubscribeFromGlobalContextMenuRef = React.useRef(() => {});
// 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(() => {
if (valueProp !== undefined) {
return getControlledInputValue(value, locale, format);
}
return formatNumber(value, locale, format);
});
const [inputMode, setInputMode] = React.useState('numeric');
const getAllowedNonNumericKeys = useStableCallback(() => {
const {
decimal,
group,
currency,
literal
} = getNumberLocaleDetails(locale, format);
const keys = new Set();
BASE_NON_NUMERIC_SYMBOLS.forEach(symbol => keys.add(symbol));
if (decimal) {
keys.add(decimal);
}
if (group) {
keys.add(group);
if (SPACE_SEPARATOR_RE.test(group)) {
keys.add(' ');
}
}
const allowPercentSymbols = formatStyle === 'percent' || formatStyle === 'unit' && format?.unit === 'percent';
const allowPermilleSymbols = formatStyle === 'percent' || formatStyle === 'unit' && format?.unit === 'permille';
if (allowPercentSymbols) {
PERCENTAGES.forEach(key => keys.add(key));
}
if (allowPermilleSymbols) {
PERMILLE.forEach(key => keys.add(key));
}
if (formatStyle === 'currency' && currency) {
keys.add(currency);
}
if (literal) {
// Some locales (e.g. de-DE) insert a literal space character between the number
// and the symbol, so allow those characters to be typed/removed.
Array.from(literal).forEach(char => keys.add(char));
if (SPACE_SEPARATOR_RE.test(literal)) {
keys.add(' ');
}
}
// Allow plus sign in all cases; minus sign only when negatives are valid
PLUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key));
if (minWithDefault < 0) {
MINUS_SIGNS_WITH_ASCII.forEach(key => keys.add(key));
}
return keys;
});
const getStepAmount = useStableCallback(event => {
if (event?.altKey) {
return smallStep;
}
if (event?.shiftKey) {
return largeStep;
}
return step;
});
const setValue = useStableCallback((unvalidatedValue, details) => {
const eventWithOptionalKeyState = details.event;
const dir = details.direction;
const validatedValue = toValidatedNumber(unvalidatedValue, {
step: dir ? getStepAmount(eventWithOptionalKeyState) * dir : undefined,
format: formatOptionsRef.current,
minWithDefault,
maxWithDefault,
minWithZeroDefault,
snapOnStep,
small: eventWithOptionalKeyState?.altKey ?? false
});
// Determine whether we should notify about a change even if the numeric value is unchanged.
// This is needed when the user input is clamped/snapped to the same current value, or when
// the source value differs but validation normalizes to the existing value.
const shouldFireChange = validatedValue !== value || unvalidatedValue !== value || allowInputSyncRef.current === false;
if (shouldFireChange) {
lastChangedValueRef.current = validatedValue;
onValueChangeProp?.(validatedValue, details);
if (details.isCanceled) {
return;
}
setValueUnwrapped(validatedValue);
setDirty(validatedValue !== validityData.initialValue);
hasPendingCommitRef.current = true;
}
// Keep the visible input in sync immediately when programmatic changes occur
// (increment/decrement, wheel, etc). During direct typing we don't want
// to overwrite the user-provided text until blur, so we gate on
// `allowInputSyncRef`.
if (allowInputSyncRef.current) {
setInputValue(formatNumber(validatedValue, locale, format));
}
// Formatting can change even if the numeric value hasn't, so ensure a re-render when needed.
forceRender();
});
const incrementValue = useStableCallback((amount, {
direction,
currentValue,
event,
reason
}) => {
const prevValue = currentValue == null ? valueRef.current : currentValue;
const nextValue = typeof prevValue === 'number' ? prevValue + amount * direction : Math.max(0, min ?? 0);
const nativeEvent = event;
setValue(nextValue, createChangeEventDetails(reason, nativeEvent, undefined, {
direction
}));
});
const stopAutoChange = useStableCallback(() => {
intentionalTouchCheckTimeout.clear();
startTickTimeout.clear();
tickInterval.clear();
unsubscribeFromGlobalContextMenuRef.current();
movesAfterTouchRef.current = 0;
});
const startAutoChange = useStableCallback((isIncrement, triggerEvent) => {
stopAutoChange();
if (!inputRef.current) {
return;
}
const win = 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', event => {
isPressedRef.current = false;
stopAutoChange();
const committed = lastChangedValueRef.current ?? valueRef.current;
const commitReason = isIncrement ? 'increment' : 'decrement';
onValueCommitted(committed, createGenericEventDetails(commitReason, event));
}, {
once: true
});
function tick() {
const amount = getStepAmount(triggerEvent) ?? DEFAULT_STEP;
incrementValue(amount, {
direction: isIncrement ? 1 : -1,
event: triggerEvent,
reason: isIncrement ? 'increment-press' : 'decrement-press'
});
}
tick();
startTickTimeout.start(START_AUTO_CHANGE_DELAY, () => {
tickInterval.start(CHANGE_VALUE_TICK_DELAY, tick);
});
});
// 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
useIsoLayoutEffect(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 = valueProp !== undefined ? getControlledInputValue(value, locale, format) : formatNumber(value, locale, format);
if (nextInputValue !== inputValue) {
setInputValue(nextInputValue);
}
});
useIsoLayoutEffect(function setDynamicInputModeForIOS() {
if (!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]);
// 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 || ownerDocument(inputRef.current).activeElement !== inputRef.current) {
return;
}
// Prevent the default behavior to avoid scrolling the page.
event.preventDefault();
const amount = getStepAmount(event) ?? DEFAULT_STEP;
incrementValue(amount, {
direction: event.deltaY > 0 ? -1 : 1,
event,
reason: 'wheel'
});
}
element.addEventListener('wheel', handleWheel);
return () => {
element.removeEventListener('wheel', handleWheel);
};
}, [allowWheelScrub, incrementValue, disabled, readOnly, largeStep, step, getStepAmount]);
const state = React.useMemo(() => ({
...fieldState,
disabled,
readOnly,
required,
value,
inputValue,
scrubbing: isScrubbing
}), [fieldState, disabled, readOnly, required, value, inputValue, isScrubbing]);
const contextValue = React.useMemo(() => ({
inputRef,
inputValue,
value,
startAutoChange,
stopAutoChange,
minWithDefault,
maxWithDefault,
disabled,
readOnly,
id,
setValue,
incrementValue,
getStepAmount,
allowInputSyncRef,
formatOptionsRef,
valueRef,
lastChangedValueRef,
hasPendingCommitRef,
isPressedRef,
intentionalTouchCheckTimeout,
movesAfterTouchRef,
name,
required,
invalid,
inputMode,
getAllowedNonNumericKeys,
min,
max,
setInputValue,
locale,
isScrubbing,
setIsScrubbing,
state,
onValueCommitted
}), [inputRef, inputValue, value, startAutoChange, stopAutoChange, minWithDefault, maxWithDefault, disabled, readOnly, id, setValue, incrementValue, getStepAmount, allowInputSyncRef, formatOptionsRef, valueRef, lastChangedValueRef, hasPendingCommitRef, isPressedRef, intentionalTouchCheckTimeout, movesAfterTouchRef, name, required, invalid, inputMode, getAllowedNonNumericKeys, min, max, setInputValue, locale, isScrubbing, state, onValueCommitted]);
const element = useRenderElement('div', componentProps, {
ref: forwardedRef,
state,
props: elementProps,
stateAttributesMapping
});
return /*#__PURE__*/_jsxs(NumberFieldRootContext.Provider, {
value: contextValue,
children: [element, name && /*#__PURE__*/_jsx("input", {
type: "hidden",
name: name,
ref: inputRefProp,
value: value ?? '',
disabled: disabled,
required: required
})]
});
});
if (process.env.NODE_ENV !== "production") NumberFieldRoot.displayName = "NumberFieldRoot";
function getControlledInputValue(value, locale, format) {
const explicitPrecision = format?.maximumFractionDigits != null || format?.minimumFractionDigits != null;
return explicitPrecision ? formatNumber(value, locale, format) : formatNumberMaxPrecision(value, locale, format);
}