UNPKG

@carbon/react

Version:

React components for the Carbon Design System

710 lines (692 loc) 24 kB
/** * Copyright IBM Corp. 2016, 2023 * * 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 { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import { Subtract, Add } from '@carbon/icons-react'; import cx from 'classnames'; import PropTypes from 'prop-types'; import React, { useContext, useState, useMemo, useCallback, useRef, cloneElement, useEffect } from 'react'; import { useMergedRefs } from '../../internal/useMergedRefs.js'; import { useNormalizedInputProps } from '../../internal/useNormalizedInputProps.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { deprecate } from '../../prop-types/deprecate.js'; import '../FluidForm/FluidForm.js'; import { FormContext } from '../FluidForm/FormContext.js'; import '../Text/index.js'; import { clamp } from '../../internal/clamp.js'; import { useControllableState } from '../../internal/useControllableState.js'; import { NumberParser, NumberFormatter } from '@carbon/utilities'; import { ArrowUp, ArrowDown } from '../../internal/keyboard/keys.js'; import { match } from '../../internal/keyboard/match.js'; import { NumberFormatOptionsPropType } from './NumberFormatPropTypes.js'; import { AILabel } from '../AILabel/index.js'; import { isComponentElement } from '../../internal/utils.js'; import { Text } from '../Text/Text.js'; var _Subtract, _Add; const translationIds = { 'increment.number': 'increment.number', 'decrement.number': 'decrement.number' }; /** * Message ids that will be passed to translateWithId(). */ const defaultTranslations = { [translationIds['increment.number']]: 'Increment number', [translationIds['decrement.number']]: 'Decrement number' }; const NumberInput = /*#__PURE__*/React.forwardRef(function NumberInput(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, onChange, onClick, onKeyUp, pattern = '[0-9]*', readOnly, size = 'md', slug, step = 1, translateWithId: t = id => defaultTranslations[id], type = 'number', defaultValue = type === 'number' ? 0 : NaN, 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 !== undefined) { return controlledValue; } if (defaultValue !== undefined) { return defaultValue; } if (allowEmpty) { return ''; } return 0; }); const [prevControlledValue, setPrevControlledValue] = useState(controlledValue); 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, setPreviousNumberValue] = useState(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]); if (isControlled && !(isNaN(previousNumberValue) && isNaN(numberValue)) && previousNumberValue !== numberValue) { setInputValue(format(numberValue)); setPreviousNumberValue(numberValue); } const inputRef = useRef(null); const ref = useMergedRefs([forwardRef, inputRef]); const numberInputClasses = cx({ [`${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 isInputValid = getInputValidity({ allowEmpty, invalid, value: type === 'number' ? value : numberValue, max, min }); const normalizedProps = useNormalizedInputProps({ id, readOnly, disabled, invalid: !isInputValid, invalidText, warn, warnText }); const [incrementNumLabel, decrementNumLabel] = [t('increment.number'), t('decrement.number')]; const wrapperClasses = cx(`${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 = cx({ [`${prefix}--number__invalid`]: normalizedProps.invalid || normalizedProps.warn, [`${prefix}--number__invalid--warning`]: normalizedProps.warn }); if (controlledValue !== prevControlledValue && !(isNaN(Number(controlledValue)) === isNaN(Number(prevControlledValue)))) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion setValue(controlledValue); setPrevControlledValue(controlledValue); } let ariaDescribedBy = undefined; if (normalizedProps.invalid) { ariaDescribedBy = normalizedProps.invalidId; } if (normalizedProps.warn) { ariaDescribedBy = normalizedProps.warnId; } if (!normalizedProps.validation) { ariaDescribedBy = helperText ? normalizedProps.helperId : undefined; } 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; // When isControlled, setNumberValue will not update numberValue in useControllableState. setNumberValue(numberParser.parse(_value)); setInputValue(_value); // The onChange prop isn't called here because it will be called on blur // or on click of a stepper, after the number is parsed and formatted // according to the locale. } } const handleFocus = evt => { if ('type' in evt.target && evt.target.type === 'button') { setIsFocused(false); } else { setIsFocused(evt.type === 'focus' ? true : false); } }; const outerElementClasses = cx(`${prefix}--form-item`, { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion [customClassName]: !!customClassName, [`${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 { rawValue = 0; } } else if (direction === 'up') { rawValue = currentValue + step; } else { rawValue = currentValue - step; } const precision = Math.max(getDecimalPlaces(currentValue), getDecimalPlaces(step)); const floatValue = parseFloat(rawValue.toFixed(precision)); const newValue = clamp(floatValue, min ?? -Infinity, max ?? Infinity); const state = { value: newValue, direction }; if (type === 'number') { setValue(state.value); } if (type === 'text') { // Calling format() can alter the number (such as rounding it) causing // the numberValue to mismatch the formatted value in the input. // To avoid this, the newValue is re-parsed after formatting. const formattedNewValue = format(newValue); const parsedFormattedNewValue = numberParser.parse(formattedNewValue); // When isControlled, setNumberValue will not actually update // numberValue in useControllableState. setNumberValue(parsedFormattedNewValue); setInputValue(formattedNewValue); setPreviousNumberValue(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); } } }; // AILabel always size `mini` const candidate = slug ?? decorator; const candidateIsAILabel = isComponentElement(candidate, AILabel); const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/cloneElement(candidate, { size: 'mini' }) : null; // Need to update the internal value when the revert button is clicked let isRevertActive; if (normalizedDecorator?.type === AILabel) { isRevertActive = normalizedDecorator.props.revertActive; } useEffect(() => { if (!isRevertActive && slug && defaultValue) { setValue(defaultValue); } }, [defaultValue, isRevertActive, slug]); return /*#__PURE__*/React.createElement("div", { className: outerElementClasses, onFocus: isFluid ? handleFocus : undefined, onBlur: isFluid ? handleFocus : undefined }, /*#__PURE__*/React.createElement("div", { className: numberInputClasses, "data-invalid": normalizedProps.invalid ? true : undefined }, /*#__PURE__*/React.createElement(Label, { disabled: normalizedProps.disabled, hideLabel: hideLabel, id: id, label: label }), /*#__PURE__*/React.createElement("div", { className: wrapperClasses }, /*#__PURE__*/React.createElement("input", _extends({}, rest, { "data-invalid": normalizedProps.invalid ? true : undefined, "aria-invalid": normalizedProps.invalid, "aria-describedby": ariaDescribedBy, "aria-readonly": readOnly, disabled: normalizedProps.disabled, ref: ref, id: id, max: max, min: min, onClick: onClick, onChange: handleOnChange, onKeyUp: onKeyUp, onKeyDown: e => { if (type === 'text') { match(e, ArrowUp) && handleStep(e, 'up'); 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); } if (type === 'text') { // When isControlled, the current inputValue needs re-parsed // because the consumer's onChange hasn't been called yet and // the `numberValue` we have in state is the (stale) value // they've passed in. const _numberValue = isControlled ? numberParser.parse(inputValue) : numberValue; const formattedValue = isNaN(_numberValue) ? '' : format(_numberValue); setInputValue(formattedValue); // Calling format() can alter the number (such as rounding it) // causing the _numberValue to mismatch the formatted value in // the input. To avoid this, formattedValue is re-parsed. const parsedFormattedNewValue = numberParser.parse(formattedValue); if (onChange) { const state = { value: parsedFormattedNewValue, direction: previousNumberValue < parsedFormattedNewValue ? 'up' : 'down' }; // If the old and new values are NaN, don't call onChange // to avoid an unecessary re-render and potential infinite // loop when isControlled. if (!(isNaN(previousNumberValue) && isNaN(parsedFormattedNewValue))) { onChange(e, state); } } // If the old and new values are NaN, don't set state to avoid // an unecessary re-render and potential infinite loop when // isControlled. if (!(isNaN(previousNumberValue) && isNaN(numberValue))) { setPreviousNumberValue(numberValue); } if (!(isNaN(numberValue) && isNaN(parsedFormattedNewValue))) { setNumberValue(parsedFormattedNewValue); } } if (onBlur) { onBlur(e); } }, pattern: pattern, inputMode: inputMode, readOnly: readOnly, step: step, type: type, value: type === 'number' ? value : inputValue })), slug ? normalizedDecorator : decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--number__input-inner-wrapper--decorator` }, normalizedDecorator) : '', Icon ? /*#__PURE__*/React.createElement(Icon, { className: iconClasses }) : null, !hideSteppers && /*#__PURE__*/React.createElement("div", { className: `${prefix}--number__controls` }, /*#__PURE__*/React.createElement("button", { "aria-label": decrementNumLabel || iconDescription, className: `${prefix}--number__control-btn down-icon`, disabled: disabled || readOnly, onClick: event => handleStepperClick(event, 'down'), onBlur: onBlur, tabIndex: -1, title: decrementNumLabel || iconDescription, type: "button" }, _Subtract || (_Subtract = /*#__PURE__*/React.createElement(Subtract, { className: "down-icon" }))), /*#__PURE__*/React.createElement("div", { className: `${prefix}--number__rule-divider` }), /*#__PURE__*/React.createElement("button", { "aria-label": incrementNumLabel || iconDescription, className: `${prefix}--number__control-btn up-icon`, disabled: disabled || readOnly, onClick: event => handleStepperClick(event, 'up'), onBlur: onBlur, tabIndex: -1, title: incrementNumLabel || iconDescription, type: "button" }, _Add || (_Add = /*#__PURE__*/React.createElement(Add, { className: "up-icon" }))), /*#__PURE__*/React.createElement("div", { className: `${prefix}--number__rule-divider` }))), isFluid && /*#__PURE__*/React.createElement("hr", { className: `${prefix}--number-input__divider` }), normalizedProps.validation ? normalizedProps.validation : /*#__PURE__*/React.createElement(HelperText, { id: normalizedProps.helperId, disabled: disabled, description: helperText }))); }); NumberInput.propTypes = { /** * `true` to allow empty string. */ allowEmpty: PropTypes.bool, /** * Specify an optional className to be applied to the wrapper node */ className: PropTypes.string, /** * **Experimental**: Provide a `decorator` component to be rendered inside the `NumberInput` component */ decorator: PropTypes.node, /** * Optional starting value for uncontrolled state */ defaultValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** * Specify if the wheel functionality for the input should be disabled, or no t */ disableWheel: PropTypes.bool, /** * Specify if the control should be disabled, or not */ disabled: PropTypes.bool, /** * **Experimental:** Specify Intl.NumberFormat options applied to internal * number parsing and formatting. Use with `type="text"`, has no effect when * `type="number"`. * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options */ formatOptions: NumberFormatOptionsPropType, /** * Provide text that is used alongside the control label for additional help */ helperText: PropTypes.node, /** * Specify whether you want the underlying label to be visually hidden */ hideLabel: PropTypes.bool, /** * Specify whether you want the steppers to be hidden */ hideSteppers: PropTypes.bool, /** * Provide a description for up/down icons that can be read by screen readers */ iconDescription: PropTypes.string, /** * Specify a custom `id` for the input */ id: PropTypes.string.isRequired, /** * Instruct the browser which keyboard to display on mobile devices. Defaults * to `decimal`, but note that standard numeric keyboards vary across devices * and operating systems. * @see https://css-tricks.com/everything-you-ever-wanted-to-know-about-inputmode/ */ inputMode: PropTypes.oneOf(['none', 'text', 'tel', 'url', 'email', 'numeric', 'decimal', 'search']), /** * Specify if the currently value is invalid. */ invalid: PropTypes.bool, /** * Message which is displayed if the value is invalid. */ invalidText: PropTypes.node, /** * Generic `label` that will be used as the textual representation of what * this field is for */ label: PropTypes.node, /** * `true` to use the light version. */ 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.'), /** * **Experimental:** Specify a [BCP47](https://www.ietf.org/rfc/bcp/bcp47.txt) * language code for parsing and formatting. Use with `type="text"`, has no * effect when `type="number"`. */ locale: PropTypes.string, /** * The maximum value. */ max: PropTypes.number, /** * The minimum value. */ min: PropTypes.number, /** * Provide the value stepping should begin at when the input is empty */ stepStartValue: PropTypes.number, /** * Provide an optional handler that is called when the input or stepper * buttons are blurred. */ onBlur: PropTypes.func, /** * Provide an optional handler that is called when the internal state of * NumberInput changes. This handler is called with event and state info. * When type="number", this is called on every change of the input. * When type="text", this is only called on blur after the number has been * parsed and formatted. * `(event, { value, direction }) => void` */ onChange: PropTypes.func, /** * Provide an optional function to be called when the up/down button is clicked */ onClick: PropTypes.func, /** * Provide an optional function to be called when a key is pressed in the number input */ onKeyUp: PropTypes.func, /** * When type="text", provide an optional pattern to restrict user input. Has * no effect when type="number". */ pattern: PropTypes.string, /** * Specify if the component should be read-only */ readOnly: PropTypes.bool, /** * Specify the size of the Number Input. */ size: PropTypes.oneOf(['sm', 'md', 'lg']), /** * **Experimental**: Provide a `Slug` component to be rendered inside the * `NumberInput` component */ 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.'), /** * Specify how much the values should increase/decrease upon clicking on * up/down button */ step: PropTypes.number, /** * Provide custom text for the component for each translation id */ translateWithId: PropTypes.func, /** * **Experimental**: Specify if the input should be of type text or number. * Use type="text" with `locale`, `formatOptions`, and guide user input with * `pattern` and `inputMode`. */ type: PropTypes.oneOf(['number', 'text']), /** * Specify the value of the input */ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** * Specify whether the control is currently in warning state */ warn: PropTypes.bool, /** * Provide the text that is displayed when the control is in warning state */ warnText: PropTypes.node }; const Label = ({ disabled, id, hideLabel, label }) => { const prefix = usePrefix(); const className = cx({ [`${prefix}--label`]: true, [`${prefix}--label--disabled`]: disabled, [`${prefix}--visually-hidden`]: hideLabel }); if (label) { return /*#__PURE__*/React.createElement(Text, { as: "label", htmlFor: id, className: className }, label); } return null; }; Label.propTypes = { disabled: PropTypes.bool, hideLabel: PropTypes.bool, id: PropTypes.string, label: PropTypes.node }; function HelperText({ disabled, description, id }) { const prefix = usePrefix(); const className = cx(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: disabled }); if (description) { return /*#__PURE__*/React.createElement(Text, { as: "div", id: id, className: className }, description); } return null; } HelperText.propTypes = { description: PropTypes.node, disabled: PropTypes.bool, id: PropTypes.string }; /** * 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 * @returns {boolean} */ function getInputValidity({ allowEmpty, invalid, value, max, min }) { if (invalid) { return false; } if (value === '') { return allowEmpty; } if (value > max || value < 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(); } export { NumberInput, translationIds };