@carbon/react
Version:
React components for the Carbon Design System
660 lines (643 loc) • 23.4 kB
JavaScript
/**
* 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.
*/
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var iconsReact = require('@carbon/icons-react');
var cx = require('classnames');
var PropTypes = require('prop-types');
var React = require('react');
var useMergedRefs = require('../../internal/useMergedRefs.js');
var useNormalizedInputProps = require('../../internal/useNormalizedInputProps.js');
var usePrefix = require('../../internal/usePrefix.js');
var deprecate = require('../../prop-types/deprecate.js');
require('../FluidForm/FluidForm.js');
var FormContext = require('../FluidForm/FormContext.js');
require('../Text/index.js');
var clamp = require('../../internal/clamp.js');
var useControllableState = require('../../internal/useControllableState.js');
var utilities = require('@carbon/utilities');
var keys = require('../../internal/keyboard/keys.js');
var match = require('../../internal/keyboard/match.js');
var NumberFormatPropTypes = require('./NumberFormatPropTypes.js');
var index = require('../AILabel/index.js');
var utils = require('../../internal/utils.js');
var Text = require('../Text/Text.js');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var cx__default = /*#__PURE__*/_interopDefaultLegacy(cx);
var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes);
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
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__default["default"].forwardRef(function NumberInput(props, forwardRef) {
const {
allowEmpty = false,
className: customClassName,
decorator,
disabled = false,
disableWheel: disableWheelProp = false,
defaultValue = 0,
formatOptions,
helperText = '',
hideLabel = false,
hideSteppers,
iconDescription,
id,
inputMode,
invalid = false,
invalidText,
label,
light,
locale = 'en-US',
max,
min,
onChange,
onClick,
onKeyUp,
pattern = '[0-9]*',
readOnly,
size = 'md',
slug,
step = 1,
translateWithId: t = id => defaultTranslations[id],
type = 'number',
warn = false,
warnText = '',
value: controlledValue,
...rest
} = props;
const prefix = usePrefix.usePrefix();
const {
isFluid
} = React.useContext(FormContext.FormContext);
const [isFocused, setIsFocused] = React.useState(false);
/**
* The input value, only used when type=number
*/
const [value, setValue] = React.useState(() => {
if (controlledValue !== undefined) {
return controlledValue;
}
if (defaultValue !== undefined) {
return defaultValue;
}
if (allowEmpty) {
return '';
}
return 0;
});
const [prevControlledValue, setPrevControlledValue] = React.useState(controlledValue);
const numberParser = React.useMemo(() => new utilities.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.useControllableState({
name: 'NumberInput',
defaultValue: typeof defaultValue === 'string' ? numberParser.parse(defaultValue) : defaultValue,
value: typeof controlledValue === 'string' ? numberParser.parse(controlledValue) : controlledValue
});
/**
* 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__default["default"].useState(() => isNaN(numberValue) ? '' : new utilities.NumberFormatter(locale, formatOptions).format(numberValue));
const numberingSystem = React.useMemo(() => numberParser.getNumberingSystem(inputValue), [numberParser, inputValue]);
const numberFormatter = React.useMemo(() => new utilities.NumberFormatter(locale, {
...formatOptions,
numberingSystem
}), [locale, formatOptions, numberingSystem]);
const format = React.useCallback(value => isNaN(value) || value === null ? '' : numberFormatter.format(value), [numberFormatter]);
const inputRef = React.useRef(null);
const ref = useMergedRefs.useMergedRefs([forwardRef, inputRef]);
const numberInputClasses = cx__default["default"]({
[`${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.useNormalizedInputProps({
id,
readOnly,
disabled,
invalid: !isInputValid,
invalidText,
warn,
warnText
});
const [incrementNumLabel, decrementNumLabel] = [t('increment.number'), t('decrement.number')];
const wrapperClasses = cx__default["default"](`${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__default["default"]({
[`${prefix}--number__invalid`]: normalizedProps.invalid || normalizedProps.warn,
[`${prefix}--number__invalid--warning`]: normalizedProps.warn
});
if (controlledValue !== 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;
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__default["default"](`${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) : numberParser.parse(inputRef.current.value);
const rawValue = direction === 'up' ? currentValue + step : currentValue - step;
const precision = Math.max(getDecimalPlaces(currentValue), getDecimalPlaces(step));
const floatValue = parseFloat(rawValue.toFixed(precision));
const newValue = clamp.clamp(floatValue, min ?? -Infinity, max ?? Infinity);
let state;
if (type === 'number') {
state = {
value: allowEmpty && inputRef.current.value === '' && step === 0 ? '' : newValue,
direction
};
setValue(state.value);
}
if (type === 'text') {
const formattedNewValue = format(newValue);
state = {
value: allowEmpty && inputRef.current.value === '' && step === 0 ? '' : formattedNewValue,
direction
};
// newValue does not need to be parsed because it is derived from
// currentValue, which is parsed at the beginning of this function
setNumberValue(newValue);
setInputValue(formattedNewValue);
}
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 = utils.isComponentElement(candidate, index.AILabel);
const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/React.cloneElement(candidate, {
size: 'mini'
}) : null;
// Need to update the internal value when the revert button is clicked
let isRevertActive;
if (normalizedDecorator?.type === index.AILabel) {
isRevertActive = normalizedDecorator.props.revertActive;
}
React.useEffect(() => {
if (!isRevertActive && slug && defaultValue) {
setValue(defaultValue);
}
}, [defaultValue, isRevertActive, slug]);
return /*#__PURE__*/React__default["default"].createElement("div", {
className: outerElementClasses,
onFocus: isFluid ? handleFocus : undefined,
onBlur: isFluid ? handleFocus : undefined
}, /*#__PURE__*/React__default["default"].createElement("div", {
className: numberInputClasses,
"data-invalid": normalizedProps.invalid ? true : undefined
}, /*#__PURE__*/React__default["default"].createElement(Label, {
disabled: normalizedProps.disabled,
hideLabel: hideLabel,
id: id,
label: label
}), /*#__PURE__*/React__default["default"].createElement("div", {
className: wrapperClasses
}, /*#__PURE__*/React__default["default"].createElement("input", _rollupPluginBabelHelpers["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.match(e, keys.ArrowUp) && handleStep(e, 'up');
match.match(e, keys.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);
if (onChange) {
const state = {
value: formattedValue,
direction: value < e.target.value ? 'up' : 'down'
};
onChange(e, state);
}
}
if (rest.onBlur) {
rest.onBlur(e);
}
},
pattern: pattern,
inputMode: inputMode,
readOnly: readOnly,
step: step,
type: type,
value: type === 'number' ? value : inputValue
})), slug ? normalizedDecorator : decorator ? /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--number__input-inner-wrapper--decorator`
}, normalizedDecorator) : '', Icon ? /*#__PURE__*/React__default["default"].createElement(Icon, {
className: iconClasses
}) : null, !hideSteppers && /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--number__controls`
}, /*#__PURE__*/React__default["default"].createElement("button", {
"aria-label": decrementNumLabel || iconDescription,
className: `${prefix}--number__control-btn down-icon`,
disabled: disabled || readOnly,
onClick: event => handleStepperClick(event, 'down'),
tabIndex: -1,
title: decrementNumLabel || iconDescription,
type: "button"
}, _Subtract || (_Subtract = /*#__PURE__*/React__default["default"].createElement(iconsReact.Subtract, {
className: "down-icon"
}))), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--number__rule-divider`
}), /*#__PURE__*/React__default["default"].createElement("button", {
"aria-label": incrementNumLabel || iconDescription,
className: `${prefix}--number__control-btn up-icon`,
disabled: disabled || readOnly,
onClick: event => handleStepperClick(event, 'up'),
tabIndex: -1,
title: incrementNumLabel || iconDescription,
type: "button"
}, _Add || (_Add = /*#__PURE__*/React__default["default"].createElement(iconsReact.Add, {
className: "up-icon"
}))), /*#__PURE__*/React__default["default"].createElement("div", {
className: `${prefix}--number__rule-divider`
}))), isFluid && /*#__PURE__*/React__default["default"].createElement("hr", {
className: `${prefix}--number-input__divider`
}), normalizedProps.validation ? normalizedProps.validation : /*#__PURE__*/React__default["default"].createElement(HelperText, {
id: normalizedProps.helperId,
disabled: disabled,
description: helperText
})));
});
NumberInput.propTypes = {
/**
* `true` to allow empty string.
*/
allowEmpty: PropTypes__default["default"].bool,
/**
* Specify an optional className to be applied to the wrapper node
*/
className: PropTypes__default["default"].string,
/**
* **Experimental**: Provide a `decorator` component to be rendered inside the `NumberInput` component
*/
decorator: PropTypes__default["default"].node,
/**
* Optional starting value for uncontrolled state
*/
defaultValue: PropTypes__default["default"].oneOfType([PropTypes__default["default"].number, PropTypes__default["default"].string]),
/**
* Specify if the wheel functionality for the input should be disabled, or no t
*/
disableWheel: PropTypes__default["default"].bool,
/**
* Specify if the control should be disabled, or not
*/
disabled: PropTypes__default["default"].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: NumberFormatPropTypes.NumberFormatOptionsPropType,
/**
* Provide text that is used alongside the control label for additional help
*/
helperText: PropTypes__default["default"].node,
/**
* Specify whether you want the underlying label to be visually hidden
*/
hideLabel: PropTypes__default["default"].bool,
/**
* Specify whether you want the steppers to be hidden
*/
hideSteppers: PropTypes__default["default"].bool,
/**
* Provide a description for up/down icons that can be read by screen readers
*/
iconDescription: PropTypes__default["default"].string,
/**
* Specify a custom `id` for the input
*/
id: PropTypes__default["default"].string.isRequired,
/**
* Instruct the browser which keyboard to display on mobile devices. 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__default["default"].oneOf(['none', 'text', 'tel', 'url', 'email', 'numeric', 'decimal', 'search']),
/**
* Specify if the currently value is invalid.
*/
invalid: PropTypes__default["default"].bool,
/**
* Message which is displayed if the value is invalid.
*/
invalidText: PropTypes__default["default"].node,
/**
* Generic `label` that will be used as the textual representation of what
* this field is for
*/
label: PropTypes__default["default"].node,
/**
* `true` to use the light version.
*/
light: deprecate["default"](PropTypes__default["default"].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__default["default"].string,
/**
* The maximum value.
*/
max: PropTypes__default["default"].number,
/**
* The minimum value.
*/
min: PropTypes__default["default"].number,
/**
* 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__default["default"].func,
/**
* Provide an optional function to be called when the up/down button is clicked
*/
onClick: PropTypes__default["default"].func,
/**
* Provide an optional function to be called when a key is pressed in the number input
*/
onKeyUp: PropTypes__default["default"].func,
/**
* When type="text", provide an optional pattern to restrict user input. Has
* no effect when type="number".
*/
pattern: PropTypes__default["default"].string,
/**
* Specify if the component should be read-only
*/
readOnly: PropTypes__default["default"].bool,
/**
* Specify the size of the Number Input.
*/
size: PropTypes__default["default"].oneOf(['sm', 'md', 'lg']),
/**
* **Experimental**: Provide a `Slug` component to be rendered inside the
* `NumberInput` component
*/
slug: deprecate["default"](PropTypes__default["default"].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__default["default"].number,
/**
* Provide custom text for the component for each translation id
*/
translateWithId: PropTypes__default["default"].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__default["default"].oneOf(['number', 'text']),
/**
* Specify the value of the input
*/
value: PropTypes__default["default"].oneOfType([PropTypes__default["default"].number, PropTypes__default["default"].string]),
/**
* Specify whether the control is currently in warning state
*/
warn: PropTypes__default["default"].bool,
/**
* Provide the text that is displayed when the control is in warning state
*/
warnText: PropTypes__default["default"].node
};
const Label = ({
disabled,
id,
hideLabel,
label
}) => {
const prefix = usePrefix.usePrefix();
const className = cx__default["default"]({
[`${prefix}--label`]: true,
[`${prefix}--label--disabled`]: disabled,
[`${prefix}--visually-hidden`]: hideLabel
});
if (label) {
return /*#__PURE__*/React__default["default"].createElement(Text.Text, {
as: "label",
htmlFor: id,
className: className
}, label);
}
return null;
};
Label.propTypes = {
disabled: PropTypes__default["default"].bool,
hideLabel: PropTypes__default["default"].bool,
id: PropTypes__default["default"].string,
label: PropTypes__default["default"].node
};
function HelperText({
disabled,
description,
id
}) {
const prefix = usePrefix.usePrefix();
const className = cx__default["default"](`${prefix}--form__helper-text`, {
[`${prefix}--form__helper-text--disabled`]: disabled
});
if (description) {
return /*#__PURE__*/React__default["default"].createElement(Text.Text, {
as: "div",
id: id,
className: className
}, description);
}
return null;
}
HelperText.propTypes = {
description: PropTypes__default["default"].node,
disabled: PropTypes__default["default"].bool,
id: PropTypes__default["default"].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();
}
exports.NumberInput = NumberInput;
exports.translationIds = translationIds;