From 8f2c06ccb0380043cf0c8c32d8e76d010e2db76f Mon Sep 17 00:00:00 2001 From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> Date: Fri, 8 Dec 2023 15:46:10 +0200 Subject: [PATCH] feat: added FormControl input mask (#2626) --- package-lock.json | 78 ++++++++---- package.json | 6 +- src/DataTable/tests/TableActions.test.jsx | 9 +- src/Form/FormControl.jsx | 8 +- src/Form/form-control.mdx | 143 +++++++++++++++++++++- src/Form/tests/FormControl.test.jsx | 43 ++++++- 6 files changed, 252 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8e3f05809..b42d83975f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "react-colorful": "^5.6.1", "react-dropzone": "^14.2.1", "react-focus-on": "^3.5.4", + "react-imask": "^7.1.3", "react-loading-skeleton": "^3.1.0", "react-popper": "^2.2.5", "react-proptype-conditional-require": "^1.0.4", @@ -2118,6 +2119,23 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.22.15.tgz", + "integrity": "sha512-SAj8oKi8UogVi6eXQXKNPu8qZ78Yzy7zawrlTr0M+IuW/g8Qe9gVDhGcF9h1S69OyACpYoLxEzpjs1M15sI5wQ==", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3/node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, "node_modules/@babel/standalone": { "version": "7.21.8", "dev": true, @@ -7423,15 +7441,15 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.0.1", + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", + "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "^5.0.0", + "aria-query": "5.1.3", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", @@ -7569,28 +7587,11 @@ } } }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "8.20.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "^5.0.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.4.4", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@testing-library/user-event": { "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -13854,9 +13855,10 @@ } }, "node_modules/core-js-pure": { - "version": "3.30.1", + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.32.2.tgz", + "integrity": "sha512-Y2rxThOuNywTjnX/PgA5vWM6CZ9QB9sz9oGeCixV8MqXZO70z/5SHzf9EeBrEBK0PN36DnEBBu9O/aGWzKuMZQ==", "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -21035,6 +21037,17 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.1.3.tgz", + "integrity": "sha512-jZCqTI5Jgukhl2ff+znBQd8BiHOTlnFYCIgggzHYDdoJsHmSSWr1BaejcYBxsjy4ZIs8Rm0HhbOxQcobcdESRQ==", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.6" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "license": "MIT" @@ -31620,6 +31633,21 @@ "react": ">=16.3.0" } }, + "node_modules/react-imask": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.1.3.tgz", + "integrity": "sha512-anCnzdkqpDzNwe7ot76kQSvmnz4Sw7AW/QFjjLh3B87HVNv9e2oHC+1m9hQKSIui2Tqm7w68ooMgDFsCQlDMyg==", + "dependencies": { + "imask": "^7.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "npm": ">=4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-intl": { "version": "5.25.1", "license": "BSD-3-Clause", diff --git a/package.json b/package.json index 4cecab0003..212b8ae98d 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "react-colorful": "^5.6.1", "react-dropzone": "^14.2.1", "react-focus-on": "^3.5.4", + "react-imask": "^7.1.3", "react-loading-skeleton": "^3.1.0", "react-popper": "^2.2.5", "react-proptype-conditional-require": "^1.0.4", @@ -190,5 +191,8 @@ "www", "icons", "dependent-usage-analyzer" - ] + ], + "overrides": { + "@testing-library/dom": "9.3.3" + } } diff --git a/src/DataTable/tests/TableActions.test.jsx b/src/DataTable/tests/TableActions.test.jsx index 0edbc36c5f..2c87632146 100644 --- a/src/DataTable/tests/TableActions.test.jsx +++ b/src/DataTable/tests/TableActions.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import classNames from 'classnames'; import TableActions from '../TableActions'; @@ -216,9 +216,10 @@ describe('', () => { expect(overflowToggle).toBeInTheDocument(); userEvent.click(overflowToggle); - - const buttons = screen.getAllByRole('button'); - expect(buttons.length).toBeGreaterThan(1); + waitFor(() => { + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(1); + }); }); it('renders the correct alt text for the dropdown', () => { diff --git a/src/Form/FormControl.jsx b/src/Form/FormControl.jsx index 9f017387e1..54fc69fca7 100644 --- a/src/Form/FormControl.jsx +++ b/src/Form/FormControl.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import RBFormControl from 'react-bootstrap/FormControl'; +import { IMaskInput } from 'react-imask'; import { useFormGroupContext } from './FormGroupContext'; import FormControlFeedback from './FormControlFeedback'; import FormControlDecoratorGroup from './FormControlDecoratorGroup'; @@ -17,6 +18,7 @@ const FormControl = React.forwardRef(({ floatingLabel, autoResize, onChange, + inputMask, ...props }, ref) => { const { @@ -71,7 +73,7 @@ const FormControl = React.forwardRef(({ className={className} > @@ -122,6 +125,8 @@ FormControl.propTypes = { isInvalid: PropTypes.bool, /** Only for `as="textarea"`. Specifies whether the input can be resized according to the height of content. */ autoResize: PropTypes.bool, + /** Specifies what format to use for the input mask. */ + inputMask: PropTypes.string, }; FormControl.defaultProps = { @@ -140,6 +145,7 @@ FormControl.defaultProps = { isValid: undefined, isInvalid: undefined, autoResize: false, + inputMask: undefined, }; export default FormControl; diff --git a/src/Form/form-control.mdx b/src/Form/form-control.mdx index 2afb344968..47c4d8b7a2 100644 --- a/src/Form/form-control.mdx +++ b/src/Form/form-control.mdx @@ -43,7 +43,6 @@ or [select attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element } ``` - ## Input types ```jsx live @@ -163,6 +162,148 @@ or [select attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element } ``` +## Input masks +Paragon uses the [react-imask](https://www.npmjs.com/package/react-imask) library, +which allows you to add masks of different types for inputs. +To create your own mask, you need to pass the required mask pattern (`+{1} (000) 000-0000`) to the `inputMask` property.
+See [react-imask](https://imask.js.org) for documentation on available props. + +```jsx live +() => { + {/* start example state */} + const [maskType, setMaskType] = useState('phone'); + {/* end example state */} + + const inputsWithMask = { + phone: ( + <> +

Phone

+ + } + floatingLabel="What is your phone number?" + /> + + + ), + creditCard: (<> +

Credit card

+ + } + floatingLabel="What is your credit card number?" + /> + + ), + securePassword: (<> +

Secure password

+ + } + floatingLabel="What is your password?" + /> + + ), + OTPpassword: (<> +

OTP password

+ + } + floatingLabel="What is your OPT password?" + /> + + ), + price: ( + <> +

Course priсe

+ + } + floatingLabel="What is the price of this course?" + /> + + + ), + }; + + const [value, setValue] = useState(''); + + const handleChange = (e) => setValue(e.target.value); + + return ( + <> + {/* start example form block */} + + {/* end example form block */} + + {inputsWithMask[maskType]} + + ); +} +``` + +## Input masks with clear value +To get a value without a mask, you need to use `onChange` instead of `onAccept` to handle changes. + +```jsx live +() => { + const [value, setValue] = useState(''); + + return ( + <> + + } + trailingElement={} + floatingLabel="What is your phone number?" + value={value} + // depending on prop above first argument is + // `value` if `unmask=false`, + // `unmaskedValue` if `unmask=true`, + // `typedValue` if `unmask='typed'` + onAccept={(_, mask) => setValue(mask._unmaskedValue)} + /> + + Unmasked value: {JSON.stringify(value)} + + ); +} +``` + ## Textarea autoResize `autoResize` prop allows input to be resized according to the content height. diff --git a/src/Form/tests/FormControl.test.jsx b/src/Form/tests/FormControl.test.jsx index 95044c3f75..3388013bd6 100644 --- a/src/Form/tests/FormControl.test.jsx +++ b/src/Form/tests/FormControl.test.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,8 +8,27 @@ const ref = { current: null, }; +let unmaskedInputValue; + +// eslint-disable-next-line react/prop-types +function Component({ isClearValue }) { + const [inputValue, setInputValue] = useState(''); + unmaskedInputValue = inputValue; + + return ( + (!isClearValue ? setInputValue(e.target.value) : null)} + /* eslint-disable-next-line no-underscore-dangle */ + onAccept={(_, mask) => (isClearValue ? setInputValue(mask._unmaskedValue) : null)} + data-testid="form-control-with-mask" + /> + ); +} + describe('FormControl', () => { - it('textarea changes its height with autoResize prop', async () => { + it('textarea changes its height with autoResize prop', () => { const useReferenceSpy = jest.spyOn(React, 'useRef').mockReturnValue(ref); const onChangeFunc = jest.fn(); const inputText = 'new text'; @@ -26,9 +45,27 @@ describe('FormControl', () => { expect(useReferenceSpy).toHaveBeenCalledTimes(1); expect(ref.current.style.height).toBe('0px'); - await userEvent.type(textarea, inputText); + userEvent.type(textarea, inputText); expect(onChangeFunc).toHaveBeenCalledTimes(inputText.length); expect(ref.current.style.height).toEqual(`${ref.current.scrollHeight + ref.current.offsetHeight}px`); }); + + it('should apply and accept input mask for phone numbers', () => { + render(); + + const input = screen.getByTestId('form-control-with-mask'); + userEvent.type(input, '5555555555'); + expect(input.value).toBe('+1 (555) 555-5555'); + }); + + it('should be cleared from the mask elements value', () => { + render(); + + const input = screen.getByTestId('form-control-with-mask'); + userEvent.type(input, '5555555555'); + + expect(input.value).toBe('+1 (555) 555-5555'); + expect(unmaskedInputValue).toBe('15555555555'); + }); });