From a5305c7bf4a9860fa454e490e2377ccdbebc3e41 Mon Sep 17 00:00:00 2001 From: Gary Kang <42440452+kangaree@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:44:37 -0500 Subject: [PATCH] [PLAY-1731] Text Input Masking for React: Currency, Zip Code, Postal Code, SSN (#3986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What does this PR do?** - Create a `mask` prop for React Text Input kit which masks the value as users type. - "currency" ($1,234.56), "zipCode" (12345), "postalCode" (12345-6789), and "ssn" (123-45-6789) - Create a `handleChange` function that handles masking (if `mask` prop is filled in) and then the `onChange` prop after - Add Jest tests **Screenshots:** Screenshots to visualize your addition/change ![Screenshot 2024-12-06 at 4 37 40 PM](https://github.com/user-attachments/assets/803813fe-5e46-41d5-8a34-cc8c9c707e20) **How to test?** Steps to confirm the desired behavior: 1. Go to /kits/text_input/react#mask 2. Test the text input masks 3. Also, test the other docs to make sure "unmasked" inputs work as usual. #### Checklist: - [x] **LABELS** Add a label: `enhancement`, `bug`, `improvement`, `new kit`, `deprecated`, or `breaking`. See [Changelog & Labels](https://github.com/powerhome/playbook/wiki/Changelog-&-Labels) for details. - [x] **DEPLOY** I have added the `milano` label to show I'm ready for a review. - [x] **TESTS** I have added test coverage to my code. --- .../playbook/pb_text_input/_text_input.tsx | 38 ++++- .../pb_text_input/docs/_text_input_mask.jsx | 88 +++++++++++ .../playbook/pb_text_input/docs/example.yml | 1 + .../playbook/pb_text_input/docs/index.js | 1 + .../playbook/pb_text_input/inputMask.ts | 64 ++++++++ .../playbook/pb_text_input/text_input.test.js | 141 +++++++++++++++++- 6 files changed, 328 insertions(+), 5 deletions(-) create mode 100755 playbook/app/pb_kits/playbook/pb_text_input/docs/_text_input_mask.jsx create mode 100644 playbook/app/pb_kits/playbook/pb_text_input/inputMask.ts diff --git a/playbook/app/pb_kits/playbook/pb_text_input/_text_input.tsx b/playbook/app/pb_kits/playbook/pb_text_input/_text_input.tsx index eee9a28704..599d30f40f 100755 --- a/playbook/app/pb_kits/playbook/pb_text_input/_text_input.tsx +++ b/playbook/app/pb_kits/playbook/pb_text_input/_text_input.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react' +import React, { forwardRef, ChangeEvent } from 'react' import classnames from 'classnames' import { globalProps, GlobalProps, domSafeProps } from '../utilities/globalProps' @@ -10,6 +10,8 @@ import Caption from '../pb_caption/_caption' import Body from '../pb_body/_body' import Icon from '../pb_icon/_icon' +import { INPUTMASKS } from './inputMask' + type TextInputProps = { aria?: { [key: string]: string }, className?: string, @@ -22,6 +24,7 @@ type TextInputProps = { inline?: boolean, name: string, label: string, + mask?: 'currency' | 'zipCode' | 'postalCode' | 'ssn', onChange: (e: React.FormEvent) => void, placeholder: string, required?: boolean, @@ -47,6 +50,7 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef htmlOptions = {}, id, inline = false, + mask = null, name, label, onChange = () => { void 0 }, @@ -90,6 +94,33 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef /> ) + const isMaskedInput = mask && mask in INPUTMASKS + + const handleChange = (e: ChangeEvent) => { + if (isMaskedInput) { + const inputValue = e.target.value + + let cursorPosition = e.target.selectionStart; + const isAtEnd = cursorPosition === inputValue.length; + + const formattedValue = INPUTMASKS[mask].format(inputValue) + e.target.value = formattedValue + + // Keep cursor position + if (!isAtEnd) { + // Account for extra characters (e.g., commas added/removed in currency) + if (formattedValue.length - inputValue.length === 1) { + cursorPosition = cursorPosition + 1 + } else if (mask === "currency" && formattedValue.length - inputValue.length === -1) { + cursorPosition = cursorPosition - 1 + } + e.target.selectionStart = e.target.selectionEnd = cursorPosition + } + } + + onChange(e) + } + const childInput = children ? children.type === "input" : undefined const textInput = ( @@ -101,8 +132,9 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef id={id} key={id} name={name} - onChange={onChange} - placeholder={placeholder} + onChange={isMaskedInput ? handleChange : onChange} + pattern={isMaskedInput ? INPUTMASKS[mask]?.pattern : undefined} + placeholder={placeholder || (isMaskedInput ? INPUTMASKS[mask]?.placeholder : undefined)} ref={ref} required={required} type={type} diff --git a/playbook/app/pb_kits/playbook/pb_text_input/docs/_text_input_mask.jsx b/playbook/app/pb_kits/playbook/pb_text_input/docs/_text_input_mask.jsx new file mode 100755 index 0000000000..3af05e3036 --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_text_input/docs/_text_input_mask.jsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' + +import Caption from '../../pb_caption/_caption' +import TextInput from '../../pb_text_input/_text_input' +import Title from '../../pb_title/_title' + +const TextInputMask = (props) => { + const [ssn, setSSN] = useState('') + const handleOnChangeSSN = ({ target }) => { + setSSN(target.value) + } + const ref = React.createRef() + + const [formFields, setFormFields] = useState({ + currency: '', + zipCode: '', + postalCode: '', + ssn: '', + }) + + const handleOnChangeFormField = ({ target }) => { + const { name, value } = target + setFormFields({ ...formFields, [name]: value }) + } + + return ( +
+ + + + + +
+
+ + {'Event Handler Props'} + +
+ {'onChange'} + +
+ + + + {ssn !== '' && ( + {`SSN is: ${ssn}`} + )} +
+ ) +} + +export default TextInputMask diff --git a/playbook/app/pb_kits/playbook/pb_text_input/docs/example.yml b/playbook/app/pb_kits/playbook/pb_text_input/docs/example.yml index 38b0140121..7c7fcd7991 100755 --- a/playbook/app/pb_kits/playbook/pb_text_input/docs/example.yml +++ b/playbook/app/pb_kits/playbook/pb_text_input/docs/example.yml @@ -16,6 +16,7 @@ examples: - text_input_add_on: Add On - text_input_inline: Inline - text_input_no_label: No Label + - text_input_mask: Mask swift: - text_input_default_swift: Default diff --git a/playbook/app/pb_kits/playbook/pb_text_input/docs/index.js b/playbook/app/pb_kits/playbook/pb_text_input/docs/index.js index d3eb8dfb33..e770ce2c6f 100755 --- a/playbook/app/pb_kits/playbook/pb_text_input/docs/index.js +++ b/playbook/app/pb_kits/playbook/pb_text_input/docs/index.js @@ -5,3 +5,4 @@ export { default as TextInputDisabled } from './_text_input_disabled.jsx' export { default as TextInputAddOn } from './_text_input_add_on.jsx' export { default as TextInputInline } from './_text_input_inline.jsx' export { default as TextInputNoLabel } from './_text_input_no_label.jsx' +export { default as TextInputMask } from './_text_input_mask.jsx' diff --git a/playbook/app/pb_kits/playbook/pb_text_input/inputMask.ts b/playbook/app/pb_kits/playbook/pb_text_input/inputMask.ts new file mode 100644 index 0000000000..2b98530d97 --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_text_input/inputMask.ts @@ -0,0 +1,64 @@ +type InputMask = { + format: (value: string) => string + pattern: string + placeholder: string +} + +type InputMaskDictionary = { + [key in 'currency' | 'zipCode' | 'postalCode' | 'ssn']: InputMask +} + +const formatCurrency = (value: string): string => { + const numericValue = value.replace(/[^0-9]/g, '').slice(0, 15) + + if (!numericValue) return '' + + const dollars = parseFloat((parseInt(numericValue) / 100).toFixed(2)) + if (dollars === 0) return '' + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }).format(dollars) +} + +const formatBasicPostal = (value: string): string => { + return value.replace(/\D/g, '').slice(0, 5) +} + +const formatExtendedPostal = (value: string): string => { + const cleaned = value.replace(/\D/g, '').slice(0, 9) + return cleaned.replace(/(\d{5})(?=\d)/, '$1-') +} + +const formatSSN = (value: string): string => { + const cleaned = value.replace(/\D/g, '').slice(0, 9) + return cleaned + .replace(/(\d{5})(?=\d)/, '$1-') + .replace(/(\d{3})(?=\d)/, '$1-') +} + +export const INPUTMASKS: InputMaskDictionary = { + currency: { + format: formatCurrency, + // eslint-disable-next-line no-useless-escape + pattern: '^\\$\\d{1,3}(?:,\\d{3})*(?:\\.\\d{2})?$', + placeholder: '$0.00', + }, + zipCode: { + format: formatBasicPostal, + pattern: '\\d{5}', + placeholder: '12345', + }, + postalCode: { + format: formatExtendedPostal, + pattern: '\\d{5}-\\d{4}', + placeholder: '12345-6789', + }, + ssn: { + format: formatSSN, + pattern: '\\d{3}-\\d{2}-\\d{4}', + placeholder: '123-45-6789', + }, +} diff --git a/playbook/app/pb_kits/playbook/pb_text_input/text_input.test.js b/playbook/app/pb_kits/playbook/pb_text_input/text_input.test.js index 174d0bcd61..d901dd55f5 100644 --- a/playbook/app/pb_kits/playbook/pb_text_input/text_input.test.js +++ b/playbook/app/pb_kits/playbook/pb_text_input/text_input.test.js @@ -1,5 +1,5 @@ -import React from 'react' -import { render, screen } from '../utilities/test-utils' +import React, { useState } from 'react' +import { render, screen, fireEvent, within } from '../utilities/test-utils' import TextInput from './_text_input' @@ -89,3 +89,140 @@ test('returns additional class name', () => { const kit = screen.getByTestId(testId) expect(kit).toHaveClass(`${kitClass} mb_lg`) }) + + +const TextInputCurrencyMask = (props) => { + const [currency, setValue] = useState('') + const handleOnChange = ({ target }) => { + setValue(target.value) + } + + return ( + + ) +} + +test('returns masked currency value', () => { + render( + + ) + + const kit = screen.getByTestId(testId) + + const input = within(kit).getByRole('textbox'); + + fireEvent.change(input, { target: { value: '123456' } }); + + expect(input.value).toBe('$1,234.56') + + fireEvent.change(input, { target: { value: '1' } }); + + expect(input.value).toBe('$0.01') + + fireEvent.change(input, { target: { value: '' } }); + + expect(input.value).toBe('') +}) + +const TextInputZipCodeMask = (props) => { + const [zipCode, setValue] = useState('') + const handleOnChange = ({ target }) => { + setValue(target.value) + } + + return ( + + ) +} + +test('returns masked zip code value', () => { + render( + + ) + + const kit = screen.getByTestId(testId) + + const input = within(kit).getByRole('textbox'); + + fireEvent.change(input, { target: { value: '123456' } }); + + expect(input.value).toBe('12345') +}) + +const TextInputPostalCodeMask = (props) => { + const [postalCode, setValue] = useState('') + const handleOnChange = ({ target }) => { + setValue(target.value) + } + + return ( + + ) +} + +test('returns masked postal code value', () => { + render( + + ) + + const kit = screen.getByTestId(testId) + + const input = within(kit).getByRole('textbox'); + + fireEvent.change(input, { target: { value: '123456789' } }); + + expect(input.value).toBe('12345-6789') +}) + +const TextInputSSNMask = (props) => { + const [ssn, setValue] = useState('') + const handleOnChange = ({ target }) => { + setValue(target.value) + } + + return ( + + ) +} + +test('returns masked ssn value', () => { + render( + + ) + + const kit = screen.getByTestId(testId) + + const input = within(kit).getByRole('textbox'); + + fireEvent.change(input, { target: { value: '123456789' } }); + + expect(input.value).toBe('123-45-6789') +})