UNPKG

@carbon/react

Version:

React components for the Carbon Design System

592 lines (590 loc) 23.1 kB
/** * 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 };