@carbon/react
Version:
React components for the Carbon Design System
592 lines (590 loc) • 23.1 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { usePrefix } from "../../internal/usePrefix.js";
import { Text } from "../Text/Text.js";
import { ArrowDown, ArrowUp as ArrowUp$1 } from "../../internal/keyboard/keys.js";
import { match } from "../../internal/keyboard/match.js";
import { deprecate } from "../../prop-types/deprecate.js";
import { isComponentElement } from "../../internal/utils.js";
import { useMergedRefs } from "../../internal/useMergedRefs.js";
import { useNormalizedInputProps } from "../../internal/useNormalizedInputProps.js";
import { AILabel } from "../AILabel/index.js";
import { FormContext } from "../FluidForm/FormContext.js";
import { useControllableState } from "../../internal/useControllableState.js";
import { clamp } from "../../internal/clamp.js";
import { NumberFormatOptionsPropType } from "./NumberFormatPropTypes.js";
import classNames from "classnames";
import React, { cloneElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import PropTypes from "prop-types";
import { jsx, jsxs } from "react/jsx-runtime";
import { Add, Subtract } from "@carbon/icons-react";
import { NumberFormatter, NumberParser } from "@carbon/utilities";
//#region src/components/NumberInput/NumberInput.tsx
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const translationIds = {
"increment.number": "increment.number",
"decrement.number": "decrement.number"
};
const defaultTranslations = {
[translationIds["increment.number"]]: "Increment number",
[translationIds["decrement.number"]]: "Decrement number"
};
const defaultTranslateWithId = (messageId) => {
return defaultTranslations[messageId];
};
const getSeparators = (locale) => {
const formatted = new Intl.NumberFormat(locale).format(1234567.89);
const digitPattern = "[\\u0030-\\u0039\\u0660-\\u0669\\u0966-\\u096F\\u09E6-\\u09EF\\uFF10-\\uFF19一二三四五六七八九〇零]";
const nonDigitPattern = "[^\\u0030-\\u0039\\u0660-\\u0669\\u0966-\\u096F\\u09E6-\\u09EF\\uFF10-\\uFF19一二三四五六七八九〇零]+";
const regex = new RegExp(`(${nonDigitPattern})${digitPattern}{3}(${nonDigitPattern})${digitPattern}{2}$`);
const match = formatted.match(regex);
if (match) return {
groupSeparator: match[1],
decimalSeparator: match[2]
};
else return {
groupSeparator: null,
decimalSeparator: null
};
};
const normalizeMinus = (value) => value.replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, "-");
const normalizeNumericInput = (value) => value.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "").replace(/[\u2212\u2012\u2013\u2014\uFE63\uFF0D]/g, "-");
/**
* Converts a string with any Unicode numeral system to a JavaScript number.
* Handles all numeral systems supported by Intl.NumberFormat.
*
* @param {string} input - The input string with numerals in any Unicode system
* @param {string} locale - The locale for parsing separators
* @returns {number} The parsed number, or NaN if invalid
*/
const parseNumberWithLocale = (input, locale) => {
if (input === "" || input === void 0 || input === null) return NaN;
input = normalizeNumericInput(input);
const { groupSeparator, decimalSeparator } = getSeparators(locale);
const kanjiMap = {
零: "0",
〇: "0",
一: "1",
二: "2",
三: "3",
四: "4",
五: "5",
六: "6",
七: "7",
八: "8",
九: "9"
};
const digitRanges = [
{
start: 48,
end: 57,
base: 48
},
{
start: 1632,
end: 1641,
base: 1632
},
{
start: 2406,
end: 2415,
base: 2406
},
{
start: 2534,
end: 2543,
base: 2534
},
{
start: 65296,
end: 65305,
base: 65296
}
];
let normalized = Array.from(input).map((char) => {
if (char === "e" || char === "E" || char === "+" || char === "-") return char;
if (kanjiMap[char] !== void 0) return kanjiMap[char];
const code = char.charCodeAt(0);
for (const range of digitRanges) if (code >= range.start && code <= range.end) return String(code - range.start);
return char;
}).join("");
if (groupSeparator) if (groupSeparator?.trim() === "") normalized = normalized?.replace(/[\u00A0\u202F\s]/g, "");
else {
if (decimalSeparator !== "," && decimalSeparator !== "٬") normalized = normalized?.replace(/[,٬]/g, "");
if (groupSeparator !== "," && groupSeparator !== "٬") {
const escaped = groupSeparator?.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
normalized = normalized?.replace(new RegExp(escaped, "g"), "");
}
}
normalized = normalized.replace(/٫/g, ".");
if (decimalSeparator && decimalSeparator !== "." && decimalSeparator !== "٫") {
const escaped = decimalSeparator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
normalized = normalized.replace(new RegExp(escaped, "g"), ".");
}
normalized = normalizeMinus(normalized);
return Number(normalized);
};
const validateNumberSeparators = (input, locale) => {
if (input === "") return true;
input = normalizeNumericInput(input);
const { groupSeparator, decimalSeparator } = getSeparators(locale);
if (!decimalSeparator) return !isNaN(Number(input));
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const digit = "[\\u0030-\\u0039\\u0660-\\u0669\\u0966-\\u096F\\u09E6-\\u09EF\\uFF10-\\uFF19一二三四五六七八九〇零]";
let group = "";
if (groupSeparator) if (groupSeparator?.trim() === "") group = "[\\u00A0\\u202F\\s]";
else if (groupSeparator === "," || groupSeparator === "٬") group = "[,٬]";
else group = esc(groupSeparator);
let decimal = esc(decimalSeparator);
if (decimalSeparator === "." || decimalSeparator === "٫") decimal = "[.٫]";
const sign = "[\\-\\u2212]?";
const scientific = `([eE][+-]?${digit}+)?`;
const usesGrouping = group && (groupSeparator?.trim() === "" ? /[\u00A0\u202F\s]/.test(input) : groupSeparator === "," || groupSeparator === "٬" ? /[,٬]/.test(input) : groupSeparator ? input.includes(groupSeparator) : false);
const scientificMatch = input?.match(/^([^eE]+)([eE][+-]?.*)?$/);
const baseNumber = scientificMatch ? scientificMatch[1] : input;
let integerPart;
if (decimalSeparator === "." || decimalSeparator === "٫") integerPart = baseNumber?.split(/[.,]/)[0];
else integerPart = baseNumber?.split(decimalSeparator)[0];
if (!(usesGrouping ? new RegExp(`^${sign}(${digit}{1,3}|${digit}{1,3}(${group}${digit}{3})+)$`) : new RegExp(`^${sign}${digit}+$`)).test(integerPart)) return false;
return new RegExp(`^${sign}${digit}+` + (usesGrouping ? `(${group}${digit}{3})*` : "") + `(${decimal}${digit}+)?${scientific}$`).test(input);
};
const NumberInput = React.forwardRef((props, forwardRef) => {
const { allowEmpty = false, className: customClassName, decorator, disabled = false, disableWheel: disableWheelProp = false, formatOptions, helperText = "", hideLabel = false, hideSteppers, iconDescription, id, inputMode = "decimal", invalid = false, invalidText, label, light, locale = "en-US", max, min, onBlur, onStepperBlur, onChange, onClick, onKeyUp, pattern = "[0-9]*", readOnly, size = "md", slug, step = 1, translateWithId: t = defaultTranslateWithId, type = "number", defaultValue = type === "number" ? 0 : NaN, validate, warn = false, warnText = "", stepStartValue = 0, value: controlledValue, ...rest } = props;
const prefix = usePrefix();
const { isFluid } = useContext(FormContext);
const [isFocused, setIsFocused] = useState(false);
/**
* The input value, only used when type=number
*/
const [value, setValue] = useState(() => {
if (controlledValue !== void 0) return controlledValue;
return defaultValue;
});
const numberParser = useMemo(() => new NumberParser(locale, formatOptions), [locale, formatOptions]);
/**
* The currently parsed number value.
* Only used when type=text
* Updated based on the `value` as the user types.
*/
const [numberValue, setNumberValue, isControlled] = useControllableState({
name: "NumberInput",
defaultValue: typeof defaultValue === "string" ? numberParser.parse(defaultValue) : defaultValue,
value: typeof controlledValue === "string" ? numberParser.parse(controlledValue) : controlledValue
});
/**
* The number value that was previously "committed" to the input on blur
* Only used when type="text"
*/
const previousNumberValue = useRef(numberValue);
/**
* The current text value of the input.
* Only used when type=text
* Updated as the user types and formatted on blur.
*/
const [inputValue, setInputValue] = React.useState(() => isNaN(numberValue) ? "" : new NumberFormatter(locale, formatOptions).format(numberValue));
const numberingSystem = useMemo(() => numberParser.getNumberingSystem(inputValue), [numberParser, inputValue]);
const numberFormatter = useMemo(() => new NumberFormatter(locale, {
...formatOptions,
numberingSystem
}), [
locale,
formatOptions,
numberingSystem
]);
const format = useCallback((value) => isNaN(value) || value === null ? "" : numberFormatter.format(value), [numberFormatter]);
useEffect(() => {
if (isControlled && !(isNaN(previousNumberValue.current) && isNaN(numberValue)) && previousNumberValue.current !== numberValue) {
setInputValue(format(numberValue));
previousNumberValue.current = numberValue;
}
}, [
isControlled,
numberValue,
format
]);
const inputRef = useRef(null);
const ref = useMergedRefs([forwardRef, inputRef]);
const numberInputClasses = classNames({
[`${prefix}--number`]: true,
[`${prefix}--number--helpertext`]: true,
[`${prefix}--number--readonly`]: readOnly,
[`${prefix}--number--light`]: light,
[`${prefix}--number--nolabel`]: hideLabel,
[`${prefix}--number--nosteppers`]: hideSteppers,
[`${prefix}--number--${size}`]: size
});
const normalizedProps = useNormalizedInputProps({
id,
readOnly,
disabled,
invalid: !getInputValidity({
allowEmpty,
invalid,
value: validate ? inputValue : type === "number" ? value : numberValue,
max,
min,
validate,
locale
}),
invalidText,
warn,
warnText
});
const [incrementNumLabel, decrementNumLabel] = [t("increment.number"), t("decrement.number")];
const wrapperClasses = classNames(`${prefix}--number__input-wrapper`, {
[`${prefix}--number__input-wrapper--warning`]: normalizedProps.warn,
[`${prefix}--number__input-wrapper--slug`]: slug,
[`${prefix}--number__input-wrapper--decorator`]: decorator
});
const iconClasses = classNames({
[`${prefix}--number__invalid`]: normalizedProps.invalid || normalizedProps.warn,
[`${prefix}--number__invalid--warning`]: normalizedProps.warn
});
useEffect(() => {
if (type === "number" && controlledValue !== void 0) if (allowEmpty && controlledValue === "") setValue("");
else setValue(controlledValue);
}, [
controlledValue,
type,
allowEmpty
]);
let ariaDescribedBy = void 0;
if (normalizedProps.invalid) ariaDescribedBy = normalizedProps.invalidId;
if (normalizedProps.warn) ariaDescribedBy = normalizedProps.warnId;
if (!normalizedProps.validation) ariaDescribedBy = helperText ? normalizedProps.helperId : void 0;
function handleOnChange(event) {
if (disabled) return;
if (type === "number") {
const state = {
value: allowEmpty && event.target.value === "" ? "" : Number(event.target.value),
direction: value < event.target.value ? "up" : "down"
};
setValue(state.value);
if (onChange) onChange(event, state);
return;
}
if (type === "text") {
const _value = allowEmpty && event.target.value === "" ? "" : event.target.value;
setNumberValue(numberParser.parse(_value));
setInputValue(_value);
}
}
const handleFocus = (evt) => {
if ("type" in evt.target && evt.target.type === "button") setIsFocused(false);
else setIsFocused(evt.type === "focus");
};
const outerElementClasses = classNames(`${prefix}--form-item`, {
...customClassName ? { [customClassName]: true } : {},
[`${prefix}--number-input--fluid--invalid`]: isFluid && normalizedProps.invalid,
[`${prefix}--number-input--fluid--focus`]: isFluid && isFocused,
[`${prefix}--number-input--fluid--disabled`]: isFluid && disabled
});
const Icon = normalizedProps.icon;
const getDecimalPlaces = (num) => {
const parts = num.toString().split(".");
return parts[1] ? parts[1].length : 0;
};
const handleStep = (event, direction) => {
if (inputRef.current) {
const currentValue = type === "number" ? Number(inputRef.current.value) : numberValue;
let rawValue;
if (Number.isNaN(currentValue) || !currentValue) {
if (typeof stepStartValue === "number" && stepStartValue) rawValue = stepStartValue;
else if (min && min < 0 && max && max > 0 || !max && !min || max) {
if (direction === `up`) rawValue = 1;
if (direction === `down`) rawValue = -1;
} else if (min && min > 0 && max && max > 0 || min) rawValue = min;
} else if (direction === "up") rawValue = currentValue + step;
else rawValue = currentValue - step;
const precision = Math.max(getDecimalPlaces(currentValue), getDecimalPlaces(step));
const newValue = clamp(parseFloat(Number(rawValue).toFixed(precision)), min ?? -Infinity, max ?? Infinity);
const state = {
value: newValue,
direction
};
if (type === "number") setValue(state.value);
if (type === "text") {
const formattedNewValue = format(newValue);
const parsedFormattedNewValue = numberParser.parse(formattedNewValue);
setNumberValue(parsedFormattedNewValue);
setInputValue(formattedNewValue);
previousNumberValue.current = parsedFormattedNewValue;
}
if (onChange) onChange(event, state);
return state;
}
};
const handleStepperClick = (event, direction) => {
if (inputRef.current) {
const state = handleStep(event, direction);
if (onClick) onClick(event, state);
}
};
const candidate = slug ?? decorator;
const normalizedDecorator = isComponentElement(candidate, AILabel) ? cloneElement(candidate, { size: "mini" }) : candidate;
const isRevertActive = isComponentElement(normalizedDecorator, AILabel) ? normalizedDecorator.props.revertActive : void 0;
useEffect(() => {
if (!isRevertActive && slug && defaultValue) setValue(defaultValue);
}, [
defaultValue,
isRevertActive,
slug
]);
return /* @__PURE__ */ jsx("div", {
className: outerElementClasses,
onFocus: isFluid ? handleFocus : void 0,
onBlur: isFluid ? handleFocus : void 0,
children: /* @__PURE__ */ jsxs("div", {
className: numberInputClasses,
"data-invalid": normalizedProps.invalid ? true : void 0,
children: [
/* @__PURE__ */ jsx(Label, {
disabled: normalizedProps.disabled,
hideLabel,
id,
label
}),
/* @__PURE__ */ jsxs("div", {
className: wrapperClasses,
children: [
/* @__PURE__ */ jsx("input", {
...rest,
"data-invalid": normalizedProps.invalid ? true : void 0,
"aria-invalid": normalizedProps.invalid,
"aria-describedby": ariaDescribedBy,
"aria-readonly": readOnly,
disabled: normalizedProps.disabled,
ref,
id,
max,
min,
onClick,
onChange: handleOnChange,
onKeyUp,
onKeyDown: (e) => {
if (type === "text" && !readOnly && !disabled) {
if (match(e, ArrowUp$1)) handleStep(e, "up");
else if (match(e, ArrowDown)) handleStep(e, "down");
}
if (rest?.onKeyDown) rest?.onKeyDown(e);
},
onFocus: (e) => {
if (disableWheelProp) e.target.addEventListener("wheel", disableWheel);
if (rest.onFocus) rest.onFocus(e);
},
onBlur: (e) => {
if (disableWheelProp) e.target.removeEventListener("wheel", disableWheel);
let parsedValueForBlur;
if (type === "text") {
const _numberValue = isControlled ? numberParser.parse(inputValue) : numberValue;
const formattedValue = isNaN(_numberValue) ? "" : format(_numberValue);
const rawValue = e.target.value;
const isValid = validate ? validate(rawValue, locale) : true;
setInputValue(isValid ? formattedValue : rawValue);
const parsedFormattedNewValue = numberParser.parse(formattedValue);
parsedValueForBlur = parsedFormattedNewValue;
if (onChange && isValid) {
const state = {
value: parsedFormattedNewValue,
direction: previousNumberValue.current < parsedFormattedNewValue ? "up" : "down"
};
if (!(isNaN(previousNumberValue.current) && isNaN(parsedFormattedNewValue))) onChange(e, state);
}
if (!(isNaN(previousNumberValue.current) && isNaN(numberValue))) previousNumberValue.current = numberValue;
if (!(isNaN(numberValue) && isNaN(parsedFormattedNewValue))) setNumberValue(parsedFormattedNewValue);
}
if (onBlur) {
if (type === "number") {
onBlur(e, value);
return;
}
onBlur(e, parsedValueForBlur ?? (isControlled ? numberParser.parse(inputValue) : numberValue));
}
},
pattern,
inputMode,
readOnly,
step,
type,
value: type === "number" ? value : inputValue
}),
slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", {
className: `${prefix}--number__input-inner-wrapper--decorator`,
children: normalizedDecorator
}) : "",
Icon ? /* @__PURE__ */ jsx(Icon, { className: iconClasses }) : null,
!hideSteppers && /* @__PURE__ */ jsxs("div", {
className: `${prefix}--number__controls`,
children: [
/* @__PURE__ */ jsx("button", {
"aria-label": decrementNumLabel || iconDescription,
className: `${prefix}--number__control-btn down-icon`,
disabled: disabled || readOnly,
onClick: (event) => handleStepperClick(event, "down"),
onBlur: onStepperBlur,
tabIndex: -1,
title: decrementNumLabel || iconDescription,
type: "button",
children: /* @__PURE__ */ jsx(Subtract, { className: "down-icon" })
}),
/* @__PURE__ */ jsx("div", { className: `${prefix}--number__rule-divider` }),
/* @__PURE__ */ jsx("button", {
"aria-label": incrementNumLabel || iconDescription,
className: `${prefix}--number__control-btn up-icon`,
disabled: disabled || readOnly,
onClick: (event) => handleStepperClick(event, "up"),
onBlur: onStepperBlur,
tabIndex: -1,
title: incrementNumLabel || iconDescription,
type: "button",
children: /* @__PURE__ */ jsx(Add, { className: "up-icon" })
}),
/* @__PURE__ */ jsx("div", { className: `${prefix}--number__rule-divider` })
]
})
]
}),
isFluid && /* @__PURE__ */ jsx("hr", { className: `${prefix}--number-input__divider` }),
normalizedProps.validation ? normalizedProps.validation : /* @__PURE__ */ jsx(HelperText, {
id: normalizedProps.helperId,
disabled,
description: helperText
})
]
})
});
});
NumberInput.propTypes = {
allowEmpty: PropTypes.bool,
className: PropTypes.string,
decorator: PropTypes.node,
defaultValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
disableWheel: PropTypes.bool,
disabled: PropTypes.bool,
formatOptions: NumberFormatOptionsPropType,
helperText: PropTypes.node,
hideLabel: PropTypes.bool,
hideSteppers: PropTypes.bool,
iconDescription: PropTypes.string,
id: PropTypes.string.isRequired,
inputMode: PropTypes.oneOf([
"none",
"text",
"tel",
"url",
"email",
"numeric",
"decimal",
"search"
]),
invalid: PropTypes.bool,
invalidText: PropTypes.node,
label: PropTypes.node,
light: deprecate(PropTypes.bool, "The `light` prop for `NumberInput` is no longer needed and has been deprecated in v11 in favor of the new `Layer` component. It will be moved in the next major release."),
locale: PropTypes.string,
max: PropTypes.number,
min: PropTypes.number,
stepStartValue: PropTypes.number,
onBlur: PropTypes.func,
onStepperBlur: PropTypes.func,
onChange: PropTypes.func,
onClick: PropTypes.func,
onKeyUp: PropTypes.func,
pattern: PropTypes.string,
readOnly: PropTypes.bool,
size: PropTypes.oneOf([
"sm",
"md",
"lg"
]),
slug: deprecate(PropTypes.node, "The `slug` prop for `NumberInput` is no longer needed and has been deprecated in v11 in favor of the new `decorator` prop. It will be moved in the next major release."),
step: PropTypes.number,
translateWithId: PropTypes.func,
type: PropTypes.oneOf(["number", "text"]),
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
warn: PropTypes.bool,
warnText: PropTypes.node,
validate: PropTypes.func
};
const Label = ({ disabled, id, hideLabel, label }) => {
const prefix = usePrefix();
const className = classNames({
[`${prefix}--label`]: true,
[`${prefix}--label--disabled`]: disabled,
[`${prefix}--visually-hidden`]: hideLabel
});
if (label) return /* @__PURE__ */ jsx(Text, {
as: "label",
htmlFor: id,
className,
children: label
});
return null;
};
const HelperText = ({ disabled, description, id }) => {
const prefix = usePrefix();
const className = classNames(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: disabled });
if (description) return /* @__PURE__ */ jsx(Text, {
as: "div",
id,
className,
children: description
});
return null;
};
/**
* Determine if the given value is invalid based on the given max, min and
* conditions like `allowEmpty`. If `invalid` is passed through, it will default
* to false.
*
* @param {object} config
* @param {boolean} config.allowEmpty
* @param {boolean} config.invalid
* @param {number} config.value
* @param {number} config.max
* @param {number} config.min
* @param {Function} config.validate
* @param {string} config.locale
* @returns {boolean}
*/
function getInputValidity({ allowEmpty, invalid, value, max, min, validate, locale }) {
if (invalid) return false;
if (value === "") return allowEmpty;
let numericValue;
if (typeof value === "string") numericValue = parseNumberWithLocale(value, locale);
else numericValue = value;
if (validate && typeof value === "string") {
if (validate(value, locale) === false) return false;
}
if (max !== void 0 && numericValue > max) return false;
if (min !== void 0 && numericValue < min) return false;
return true;
}
/**
* It prevents any wheel event from emitting.
*
* We want to prevent this input field from changing by the user accidentally
* when the user scrolling up or down in a long form. So we prevent the default
* behavior of wheel events when it is focused.
*
* Because React uses passive event handler by default, we can't just call
* `preventDefault` in the `onWheel` event handler. So we have to get the input
* ref and add our event handler manually.
*
* @see https://github.com/facebook/react/pull/19654
* @param {WheelEvent} e A wheel event.
*/
function disableWheel(e) {
e.preventDefault();
}
//#endregion
export { NumberInput, validateNumberSeparators };