diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index b92246026eff..5c465f68084e 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -4874,6 +4874,109 @@ Map { }, "NumberInput" => Object { "$$typeof": Symbol(react.forward_ref), + "propTypes": Object { + "allowEmpty": Object { + "type": "bool", + }, + "className": Object { + "type": "string", + }, + "defaultValue": Object { + "args": Array [ + Array [ + Object { + "type": "number", + }, + Object { + "type": "string", + }, + ], + ], + "type": "oneOfType", + }, + "disabled": Object { + "type": "bool", + }, + "helperText": Object { + "type": "node", + }, + "hideLabel": Object { + "type": "bool", + }, + "hideSteppers": Object { + "type": "bool", + }, + "iconDescription": Object { + "type": "string", + }, + "id": Object { + "isRequired": true, + "type": "string", + }, + "invalid": Object { + "type": "bool", + }, + "invalidText": Object { + "type": "node", + }, + "label": Object { + "type": "node", + }, + "light": [Function], + "max": Object { + "type": "number", + }, + "min": Object { + "type": "number", + }, + "onChange": Object { + "type": "func", + }, + "onClick": Object { + "type": "func", + }, + "onKeyUp": Object { + "type": "func", + }, + "readOnly": Object { + "type": "bool", + }, + "size": Object { + "args": Array [ + Array [ + "sm", + "md", + "lg", + ], + ], + "type": "oneOf", + }, + "step": Object { + "type": "number", + }, + "translateWithId": Object { + "type": "func", + }, + "value": Object { + "args": Array [ + Array [ + Object { + "type": "number", + }, + Object { + "type": "string", + }, + ], + ], + "type": "oneOfType", + }, + "warn": Object { + "type": "bool", + }, + "warnText": Object { + "type": "node", + }, + }, "render": [Function], }, "NumberInputSkeleton" => Object { diff --git a/packages/react/src/components/Form/Form-story.js b/packages/react/src/components/Form/Form-story.js index 99b1044b4c3f..6e2410b7b1a5 100644 --- a/packages/react/src/components/Form/Form-story.js +++ b/packages/react/src/components/Form/Form-story.js @@ -13,7 +13,7 @@ import Checkbox from '../Checkbox'; import Form from '../Form'; import FormGroup from '../FormGroup'; import FileUploader from '../FileUploader'; -import NumberInput from '../NumberInput'; +import { NumberInput } from '../NumberInput'; import RadioButton from '../RadioButton'; import RadioButtonGroup from '../RadioButtonGroup'; import Button from '../Button'; diff --git a/packages/react/src/components/Form/next/Form.stories.js b/packages/react/src/components/Form/next/Form.stories.js index d225ded0c7f2..18d890f1721c 100644 --- a/packages/react/src/components/Form/next/Form.stories.js +++ b/packages/react/src/components/Form/next/Form.stories.js @@ -10,7 +10,7 @@ import Checkbox from '../../Checkbox'; import Form from '../'; import FormGroup from '../../FormGroup'; import FileUploader from '../../FileUploader'; -import NumberInput from '../../NumberInput'; +import { NumberInput } from '../../NumberInput'; import RadioButton from '../../RadioButton'; import RadioButtonGroup from '../../RadioButtonGroup'; import Button from '../../Button'; diff --git a/packages/react/src/components/NumberInput/NumberInput-story.js b/packages/react/src/components/NumberInput/NumberInput-story.js deleted file mode 100644 index aeabffa1b9bd..000000000000 --- a/packages/react/src/components/NumberInput/NumberInput-story.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2018 - * - * 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 React from 'react'; -import { action } from '@storybook/addon-actions'; - -import { - withKnobs, - boolean, - number, - text, - object, - select, -} from '@storybook/addon-knobs'; -import NumberInput from '../NumberInput'; -import { NumberInput as OGNumberInput } from './NumberInput'; -import NumberInputSkeleton from '../NumberInput/NumberInput.Skeleton'; -import mdx from './NumberInput.mdx'; - -const sizes = { - 'Small (sm)': 'sm', - 'Medium (md) - default': undefined, - 'Large (lg)': 'lg', -}; - -const props = () => ({ - className: 'some-class', - id: 'tj-input', - label: text('Label (label)', 'NumberInput label'), - hideLabel: boolean('No label (hideLabel)', false), - hideSteppers: boolean('No steppers (hideSteppers)', false), - min: number('Minimum value (min)', 0), - max: number('Maximum value (max)', 100), - value: number('Value (value)', 50), - step: number('Step of up/down arrow (step)', 10), - size: select('Field size (size)', sizes, undefined) || undefined, - disabled: boolean('Disabled (disabled)', false), - readOnly: boolean('Read only (readOnly)', false), - invalid: boolean('Show form validation UI (invalid)', false), - invalidText: text( - 'Form validation UI content (invalidText)', - 'Number is not valid' - ), - warn: boolean('Show warning state (warn)', false), - warnText: text( - 'Warning state text (warnText)', - 'A high threshold may impact performance' - ), - helperText: text('Helper text (helperText)', 'Optional helper text.'), - light: boolean('Light variant (light)', false), - onChange: action('onChange'), - onClick: action('onClick'), - allowEmpty: boolean('Allow empty value (allowEmpty)', false), - numberInputArrowTranslationIds: object( - 'Number input arrow icon translation IDs (for translateWithId callback)', - { - 'increment.number': 'Increment number', - 'decrement.number': 'Decrement number', - } - ), -}); - -export default { - title: 'Components/NumberInput', - component: OGNumberInput, - decorators: [withKnobs], - - parameters: { - docs: { - page: mdx, - }, - - subcomponents: { - NumberInputSkeleton, - }, - }, -}; - -export const Default = () => { - return ( - - ); -}; - -Default.story = { - name: 'Number Input', -}; - -export const Playground = () => { - const { numberInputArrowTranslationIds, ...rest } = props(); - return ( - numberInputArrowTranslationIds[id]} - {...rest} - /> - ); -}; - -export const Skeleton = () => ; diff --git a/packages/react/src/components/NumberInput/NumberInput-test.js b/packages/react/src/components/NumberInput/NumberInput-test.js deleted file mode 100644 index 897626357a1e..000000000000 --- a/packages/react/src/components/NumberInput/NumberInput-test.js +++ /dev/null @@ -1,499 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2018 - * - * 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 React from 'react'; -import { mount, shallow } from 'enzyme'; -import { Subtract, Add } from '@carbon/icons-react'; -import NumberInput from '../NumberInput'; -import NumberInputSkeleton from '../NumberInput/NumberInput.Skeleton'; - -const prefix = 'cds'; - -describe('NumberInput', () => { - describe('should render as expected', () => { - let wrapper; - let label; - let numberInput; - let container; - let formItem; - let icons; - let helper; - let mockProps; - - beforeEach(() => { - mockProps = { - min: 0, - max: 100, - id: 'test', - label: 'Number Input', - ariaLabel: 'Number Input', - className: 'extra-class', - invalidText: 'invalid text', - helperText: 'testHelper', - translateWithId: - /* - Simulates a condition where up/down button's hover over text matches `iconDescription` in `v10`, - which is, when the translation for up/down button are not there - */ - () => undefined, - }; - - wrapper = mount(); - - const iconTypes = [Subtract, Add]; - label = wrapper.find('label'); - numberInput = wrapper.find('input'); - container = wrapper.find(`.${prefix}--number`); - formItem = wrapper.find(`.${prefix}--form-item`); - icons = wrapper.findWhere((n) => iconTypes.includes(n.type())); - helper = wrapper.find(`.${prefix}--form__helper-text`); - }); - - describe('input', () => { - it('renders a numberInput', () => { - expect(numberInput.length).toEqual(1); - }); - - it('has the expected classes', () => { - expect( - container.hasClass(`${prefix}--number ${prefix}--number--helpertext`) - ).toEqual(true); - }); - - it('has renders with form-item wrapper', () => { - expect(formItem.hasClass(`${prefix}--form-item`)).toEqual(true); - }); - - it('applies extra classes via className', () => { - expect(container.hasClass('extra-class')).toEqual(true); - }); - - it('should set a min as expected', () => { - expect(numberInput.prop('min')).toEqual(0); - wrapper.setProps({ min: 10 }); - expect(wrapper.find('input').prop('min')).toEqual(10); - }); - - it('should set a max as expected', () => { - expect(numberInput.prop('max')).toEqual(100); - wrapper.setProps({ max: 10 }); - expect(wrapper.find('input').prop('max')).toEqual(10); - }); - - it('should set step as expected', () => { - expect(numberInput.prop('step')).toEqual(1); - wrapper.setProps({ step: 10 }); - expect(wrapper.find('input').prop('step')).toEqual(10); - }); - - it('should set disabled as expected', () => { - expect(numberInput.prop('disabled')).toEqual(false); - wrapper.setProps({ disabled: true }); - expect(wrapper.find('input').prop('disabled')).toEqual(true); - }); - - it('should set invalid as expected', () => { - expect(container.prop('data-invalid')).toEqual(undefined); - wrapper.setProps({ invalid: true }); - expect(wrapper.find(`.${prefix}--number`).prop('data-invalid')).toEqual( - true - ); - }); - - it('should apply aria-label based on the label', () => { - const getInputRegion = () => wrapper.find('input'); - expect(getInputRegion().prop('aria-label')).toEqual(null); - - wrapper.setProps({ label: '' }); - expect(getInputRegion().prop('aria-label')).toEqual( - mockProps.ariaLabel - ); - }); - - it('should set invalidText as expected', () => { - expect(wrapper.find(`.${prefix}--form-requirement`).length).toEqual(0); - wrapper.setProps({ invalid: true }); - const invalidText = wrapper.find(`.${prefix}--form-requirement`); - expect(invalidText.length).toEqual(1); - expect(invalidText.text()).toEqual('invalid text'); - }); - - it('should specify light number input as expected', () => { - expect(wrapper.find('NumberInput').props().light).toEqual(false); - wrapper.setProps({ light: true }); - expect(wrapper.find('NumberInput').props().light).toEqual(true); - }); - - it('should hide label as expected', () => { - expect(numberInput.prop('min')).toEqual(0); - wrapper.setProps({ hideLabel: true }); - expect( - wrapper.find('label').hasClass(`${prefix}--visually-hidden`) - ).toEqual(true); - expect( - wrapper - .find(`.${prefix}--number`) - .hasClass(`${prefix}--number--nolabel`) - ).toEqual(true); - }); - - describe('initial rendering', () => { - const getWrapper = (min, max, value) => - mount( - - ); - const getNumberInput = (wrapper) => wrapper.find('input'); - - it('should correctly set defaultValue on uncontrolled input', () => { - const wrapper = mount( - - ); - const numberInput = getNumberInput(wrapper); - expect(wrapper.find('NumberInput').instance().state.value).toEqual( - 10 - ); - expect(numberInput.prop('value')).toEqual(10); - }); - - it('should set value as expected when value > min', () => { - const wrapper = getWrapper(-1, 100, 0); - const numberInput = getNumberInput(wrapper); - expect(numberInput.prop('value')).toEqual(0); - }); - - it('should set value as expected when min === 0 and value > min', () => { - const wrapper = getWrapper(0, 100, 1); - const numberInput = getNumberInput(wrapper); - expect(numberInput.prop('value')).toEqual(1); - }); - - it('should set value when value < min and set invalid state', () => { - let wrapper = getWrapper(5, 100, 0); - let numberInput = wrapper.find('input'); - let invalidText = wrapper.find(`.${prefix}--form-requirement`); - expect(numberInput.prop('value')).toEqual(0); - expect(invalidText.length).toEqual(1); - expect(invalidText.text()).toEqual('Number is not valid'); - }); - - it('should set value when min is undefined', () => { - let wrapper = getWrapper(undefined, 100, 5); - let numberInput = wrapper.find('input'); - expect(numberInput.prop('value')).toEqual(5); - }); - - it('should set invalidText when value is empty string', () => { - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: '' }); - wrapper.update(); - const invalidText = wrapper.find(`.${prefix}--form-requirement`); - expect(invalidText.length).toEqual(1); - - expect(invalidText.text()).toEqual('invalid text'); - }); - - it('allow empty string value', () => { - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: '' }); - - wrapper.update(); - wrapper.setProps({ allowEmpty: true }); - const invalidText = wrapper.find(`.${prefix}--form-requirement`); - expect(invalidText.length).toEqual(0); - }); - - it('should allow updating the value with empty string and not be invalid', () => { - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: 50 }); - - wrapper.update(); - wrapper.setProps({ value: '', allowEmpty: true }); - wrapper.update(); - const invalidText = wrapper.find(`.${prefix}--form-requirement`); - expect(invalidText.length).toEqual(0); - }); - - it('should change the value upon change in props', () => { - wrapper.setProps({ value: 1 }); - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: 1 }); - wrapper.update(); - wrapper.setProps({ value: 2 }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(2); - }); - - it('should not cap the number given to value prop', () => { - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: 0 }); - wrapper.update(); - wrapper.setProps({ value: 5, min: 10, max: 20 }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(5); - wrapper.setProps({ value: 25, min: 10, max: 20 }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual( - 25 - ); - }); - - // NumberInput propTypes do not allow a string to be passed - it.skip('should avoid capping when non-number prop is given to value prop', () => { - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: 2 }); - wrapper.update(); - wrapper.setProps({ value: '', min: 1, max: 3 }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual( - '' - ); - }); - - it('should avoid change the value upon setting props, unless there the value actually changes', () => { - wrapper.setProps({ value: 1 }); - // Enzyme doesn't seem to allow setState() in a forwardRef-wrapped class component - wrapper.find('NumberInput').instance().setState({ value: 2 }); - wrapper.update(); - wrapper.setProps({ value: 1 }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(2); - }); - }); - }); - - describe('Icon', () => { - it('renders two Icon components', () => { - expect(icons.length).toEqual(2); - }); - - it('has the expected default iconDescription', () => { - expect(wrapper.find('NumberInput').prop('iconDescription')).toEqual( - 'choose a number' - ); - }); - - it('should use correct icons', () => { - expect(icons.at(0).type()).toBe(Subtract); - expect(icons.at(1).type()).toBe(Add); - }); - - it('adds new iconDescription when passed via props', () => { - wrapper.setProps({ iconDescription: 'new description' }); - expect(wrapper.prop('iconDescription')).toEqual('new description'); - }); - - it('should have iconDescription match Icon component description prop', () => { - const iconUpText = wrapper.find('button.up-icon').prop('title'); - const iconDownText = wrapper.find('button.down-icon').prop('title'); - const iconDescription = wrapper - .find('NumberInput') - .prop('iconDescription'); - - const matches = - iconDescription === iconUpText && iconDescription === iconDownText; - expect(matches).toEqual(true); - }); - }); - - describe('label', () => { - it('renders a label', () => { - expect(label.length).toEqual(1); - }); - - it('has the expected classes', () => { - expect(label.hasClass(`${prefix}--label`)).toEqual(true); - }); - }); - - describe('helper', () => { - it('renders a helper', () => { - expect(helper.length).toEqual(1); - }); - - it('renders children as expected', () => { - wrapper.setProps({ - helperText: This is helper text., - }); - const renderedHelper = wrapper.find(`.${prefix}--form__helper-text`); - expect(renderedHelper.props().children).toEqual( - This is helper text. - ); - }); - - it('should set helper text as expected', () => { - wrapper.setProps({ helperText: 'Helper text' }); - expect(helper.text()).toEqual('Helper text'); - }); - }); - }); - - describe('events', () => { - describe('disabled numberInput', () => { - const onClick = jest.fn(); - const onChange = jest.fn(); - - const wrapper = mount( - - ); - - const input = wrapper.find('input'); - const upArrow = wrapper.find('button.up-icon'); - const downArrow = wrapper.find('button.down-icon'); - - it('should be disabled when numberInput is disabled', () => { - expect(upArrow.prop('disabled')).toEqual(true); - expect(downArrow.prop('disabled')).toEqual(true); - }); - - it('should not invoke onClick when up arrow is clicked', () => { - upArrow.simulate('click'); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('should not invoke onClick when down arrow is clicked', () => { - downArrow.simulate('click'); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('should not invoke onChange when numberInput is changed', () => { - input.simulate('change'); - expect(onChange).not.toHaveBeenCalled(); - }); - }); - - describe('enabled numberInput', () => { - let onClick; - let onChange; - let input; - let upArrow; - let downArrow; - let wrapper; - - beforeEach(() => { - onClick = jest.fn(); - onChange = jest.fn(); - - wrapper = mount( - - ); - - input = wrapper.find('input'); - upArrow = wrapper.find(Add).closest('button'); - downArrow = wrapper.find(Subtract).closest('button'); - }); - - it('should invoke onClick when numberInput is clicked', () => { - input.simulate('click'); - expect(onClick).toHaveBeenCalled(); - }); - - it('should invoke onClick when up arrow is clicked', () => { - wrapper.setProps({ value: 1 }); - upArrow.simulate('click'); - expect(onClick).toHaveBeenCalled(); - expect(onClick).toHaveBeenCalledWith(expect.anything(), 'up', 2); - }); - - it('should only increase the value on up arrow click if value is less than max', () => { - wrapper.setProps({ value: 100 }); - upArrow.simulate('click'); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(100); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('should only decrease the value on down arrow click if value is greater than min', () => { - wrapper.setProps({ value: 0 }); - downArrow.simulate('click'); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(0); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('should increase by the value of step', () => { - wrapper.setProps({ - step: 10, - value: 0, - }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(0); - upArrow.simulate('click'); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(10); - }); - - it('should decrease by the value of step', () => { - wrapper.setProps({ - step: 10, - value: 100, - }); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(100); - downArrow.simulate('click'); - // Enzyme doesn't seem to allow state() in a forwardRef-wrapped class component - expect(wrapper.find('NumberInput').instance().state.value).toEqual(90); - }); - - it('should not invoke onClick when down arrow is clicked and value is 0', () => { - downArrow.simulate('click'); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('should invoke onClick when down arrow is clicked and value is above min', () => { - wrapper.setProps({ value: 1 }); - downArrow.simulate('click'); - expect(onClick).toHaveBeenCalled(); - expect(onChange).toHaveBeenCalled(); - expect(onClick).toHaveBeenCalledWith(expect.anything(), 'down', 0); - }); - - it('should invoke onChange when numberInput is changed', () => { - input.simulate('change'); - expect(onChange).toHaveBeenCalled(); - expect(onChange).toHaveBeenCalledWith( - expect.anything(), - expect.anything() - ); - }); - }); - }); -}); - -describe('NumberInputSkeleton', () => { - describe('Renders as expected', () => { - const wrapper = shallow(); - - const container = wrapper.find(`.${prefix}--number`); - const label = wrapper.find(`.${prefix}--label`); - - it('has the expected classes', () => { - expect(container.hasClass(`${prefix}--skeleton`)).toEqual(true); - expect(label.hasClass(`${prefix}--skeleton`)).toEqual(true); - }); - }); -}); diff --git a/packages/react/src/components/NumberInput/NumberInput.Skeleton.js b/packages/react/src/components/NumberInput/NumberInput.Skeleton.js index 71a6e80ccb51..0ed6fc9d2f99 100644 --- a/packages/react/src/components/NumberInput/NumberInput.Skeleton.js +++ b/packages/react/src/components/NumberInput/NumberInput.Skeleton.js @@ -10,7 +10,7 @@ import React from 'react'; import cx from 'classnames'; import { usePrefix } from '../../internal/usePrefix'; -const NumberInputSkeleton = ({ hideLabel, className, ...rest }) => { +function NumberInputSkeleton({ hideLabel, className, ...rest }) { const prefix = usePrefix(); return (
@@ -20,7 +20,7 @@ const NumberInputSkeleton = ({ hideLabel, className, ...rest }) => {
); -}; +} NumberInputSkeleton.propTypes = { /** diff --git a/packages/react/src/components/NumberInput/NumberInput.js b/packages/react/src/components/NumberInput/NumberInput.js index e16efcf90f04..e54ae5fd1ef3 100644 --- a/packages/react/src/components/NumberInput/NumberInput.js +++ b/packages/react/src/components/NumberInput/NumberInput.js @@ -5,20 +5,15 @@ * LICENSE file in the root directory of this source tree. */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import classNames from 'classnames'; import { Add, Subtract } from '@carbon/icons-react'; -import * as FeatureFlags from '@carbon/feature-flags'; -import mergeRefs from '../../tools/mergeRefs'; -import requiredIfValueExists from '../../prop-types/requiredIfValueExists'; -// replace "use" prefix to avoid react thinking this is a hook that -// can only be placed in a function component -import { useNormalizedInputProps as getNormalizedInputProps } from '../../internal/useNormalizedInputProps'; -import { useControlledStateWithValue } from '../../internal/FeatureFlags'; -import { PrefixContext } from '../../internal/usePrefix'; +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useRef, useState } from 'react'; +import { useFeatureFlag } from '../FeatureFlags'; +import { useMergedRefs } from '../../internal/useMergedRefs'; +import { useNormalizedInputProps as normalize } from '../../internal/useNormalizedInputProps'; +import { usePrefix } from '../../internal/usePrefix'; import deprecate from '../../prop-types/deprecate'; -import { FeatureFlagContext } from '../FeatureFlags'; export const translationIds = { 'increment.number': 'increment.number', @@ -30,530 +25,431 @@ const defaultTranslations = { [translationIds['decrement.number']]: 'Decrement number', }; -const capMin = (min, value) => - isNaN(min) || (!min && min !== 0) || isNaN(value) || (!value && value !== 0) - ? value - : Math.max(min, value); -const capMax = (max, value) => - isNaN(max) || (!max && max !== 0) || isNaN(value) || (!value && value !== 0) - ? value - : Math.min(max, value); - -class NumberInput extends Component { - static propTypes = { - /** - * `true` to allow empty string. - */ - allowEmpty: PropTypes.bool, - - /** - * Provide a description that would be used to best describe the use case of the NumberInput component - */ - ariaLabel: PropTypes.string, - - /** - * Specify an optional className to be applied to the wrapper node - */ - className: PropTypes.string, - - /** - * Optional starting value for uncontrolled state - */ - defaultValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - /** - * Specify if the control should be disabled, or not - */ - disabled: PropTypes.bool, - - /** - * 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.isRequired, - - /** - * Specify a custom `id` for the input - */ - id: PropTypes.string.isRequired, - - /** - * Specify if the currently value is invalid. - */ - invalid: PropTypes.bool, - - /** - * Message which is displayed if the value is invalid. - */ - invalidText: PropTypes.node, - - /** - * `true` to use the mobile variant. - */ - isMobile: deprecate( - PropTypes.bool, - `The \`isMobile\` prop no longer needed as the default NumberInput styles are now identical to the mobile variant styles. This prop will be removed in the next major version of \`carbon-components-react\`` - ), - - /** - * 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: PropTypes.bool, - - /** - * The maximum value. - */ - max: PropTypes.number, - - /** - * The minimum value. - */ - min: PropTypes.number, - - /** - * The new value is available in 'imaginaryTarget.value' - * i.e. to get the value: evt.imaginaryTarget.value - * - * * _With_ `useControlledStateWithValue` feature flag, the signature of the event handler will be altered to provide additional context in the second parameter: `onChange(event, { value, direction })` where: - * * `event` is the (React) raw event - * * `value` is the new value - * * `direction` tells you the button you hit is up button or down button - * * _Without_ this feature flag the event handler has `onChange(event, direction)` signature. - */ - onChange: !useControlledStateWithValue - ? PropTypes.func - : requiredIfValueExists(PropTypes.func), - - /** - * Provide an optional function to be called when the up/down button is clicked - */ - onClick: PropTypes.func, - - /** - * Specify if the component should be read-only - */ - readOnly: PropTypes.bool, - - /** - * Specify the size of the Number Input. Currently supports either `sm`, 'md' (default) or 'lg` as an option. - * TODO V11: remove `xl` (replaced with lg) - */ - size: PropTypes.oneOf(['sm', 'md', 'lg', 'xl']), - - /** - * 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.isRequired, - - /** - * 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, - }; - - static defaultProps = { - disabled: false, - hideLabel: false, - iconDescription: FeatureFlags.enabled('enable-v11-release') - ? undefined - : 'choose a number', - step: 1, - invalid: false, - invalidText: FeatureFlags.enabled('enable-v11-release') - ? undefined - : 'Provide invalidText', - warn: false, - warnText: '', - ariaLabel: 'Numeric input field with increment and decrement buttons', - helperText: '', - light: false, - allowEmpty: false, - translateWithId: (id) => defaultTranslations[id], - }; - - static contextType = FeatureFlagContext; - - static getDerivedStateFromProps({ value }, state) { - const { prevValue } = state; - - if (useControlledStateWithValue && value === '' && prevValue !== '') { - return { - value: '', - prevValue: '', - }; +const NumberInput = React.forwardRef(function NumberInput(props, forwardRef) { + const enabled = useFeatureFlag('enable-v11-release'); + const { + allowEmpty = false, + className: customClassName, + disabled = false, + defaultValue, + helperText = '', + hideLabel = false, + hideSteppers, + iconDescription = enabled ? undefined : 'choose a number', + id, + label, + invalid = false, + invalidText = enabled ? undefined : 'Provide invalidText', + light, + max, + min, + onChange, + onClick, + onKeyUp, + readOnly, + size = 'md', + step = 1, + translateWithId: t = (id) => defaultTranslations[id], + warn = false, + warnText = '', + value: controlledValue, + ...rest + } = props; + const prefix = usePrefix(); + const [value, setValue] = useState(() => { + if (controlledValue !== undefined) { + return controlledValue; } + if (defaultValue !== undefined) { + return defaultValue; + } + return 0; + }); + const [prevControlledValue, setPrevControlledValue] = + useState(controlledValue); + 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, + [customClassName]: !enabled, + }); + const isInputValid = getInputValidity({ + allowEmpty, + invalid, + value, + max, + min, + }); + const normalizedProps = normalize({ + 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, + }); + const iconClasses = cx({ + [`${prefix}--number__invalid`]: + normalizedProps.invalid || normalizedProps.warn, + [`${prefix}--number__invalid--warning`]: normalizedProps.warn, + [`${prefix}--number__readonly-icon`]: readOnly, + }); + + if (controlledValue !== prevControlledValue) { + setValue(controlledValue); + setPrevControlledValue(controlledValue); + } - // If `useControlledStateWithValue` feature flag is on, do nothing here. - // Otherwise, do prop -> state sync with "value capping". - //// Value capping removed in #8965 - //// value: capMax(max, capMin(min, value)), (L223) - return useControlledStateWithValue || prevValue === value - ? null - : { - value, - prevValue: value, - }; + let ariaDescribedBy = null; + if (normalizedProps.invalid) { + ariaDescribedBy = normalizedProps.invalidId; + } + if (normalizedProps.warn) { + ariaDescribedBy = normalizedProps.warnId; } - /** - * The DOM node reference to the ``. - * @type {HTMLInputElement} - */ - _inputRef = null; - - constructor(props) { - super(props); - this.isControlled = props.value !== undefined; - if (useControlledStateWithValue && this.isControlled) { - // Skips the logic of setting initial state if this component is controlled - this.state = {}; + function handleOnChange(event) { + if (disabled) { return; } - let value = - useControlledStateWithValue || typeof props.defaultValue !== 'undefined' - ? props.defaultValue - : props.value; - value = value === undefined ? 0 : value; - if (props.min || props.min === 0) { - value = Math.max(props.min, value); + + const state = { + value: event.target.value, + direction: value < event.target.value ? 'up' : 'down', + }; + setValue(state.value); + + if (onChange) { + onChange(event, state); } - this.state = { value }; } - handleChange = (evt) => { - const { disabled, onChange } = this.props; - if (!disabled) { - evt.persist(); - evt.imaginaryTarget = this._inputRef; - const prevValue = this.state.value; - const value = evt.target.value; - const direction = prevValue < value ? 'up' : 'down'; - this.setState( - { - value, - }, - () => { - if (useControlledStateWithValue) { - onChange(evt, { value, direction }); - } else if (onChange) { - onChange(evt, { value, direction }); - } - } - ); - } - }; - - handleArrowClick = (evt, direction) => { - let value = - typeof this.state.value === 'string' - ? Number(this.state.value) - : this.state.value; - const { disabled, min, max, step, onChange, onClick } = this.props; - const conditional = - direction === 'down' - ? (min !== undefined && value > min) || min === undefined - : (max !== undefined && value < max) || max === undefined; - - if (!disabled && conditional) { - value = direction === 'down' ? value - step : value + step; - value = capMax(max, capMin(min, value)); - evt.persist(); - evt.imaginaryTarget = this._inputRef; - this.setState( - { - value, - }, - () => { - //TO-DO v11: update these events to return the same things --> evt, {value, direction} - if (useControlledStateWithValue) { - onClick && onClick(evt, { value, direction }); - onChange && onChange(evt, { value, direction }); - } else { - // value added as a 3rd argument rather than in same obj so it doesn't break in v10 - onClick && onClick(evt, direction, value); - onChange && onChange(evt, direction, value); - } - } - ); - } - }; + return ( +
+
+