Skip to content

Commit

Permalink
[PLAY-1731] Text Input Masking for React: Currency, Zip Code, Postal …
Browse files Browse the repository at this point in the history
…Code, SSN (#3986)

**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.
  • Loading branch information
kangaree authored Dec 18, 2024
1 parent f07a662 commit a5305c7
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 5 deletions.
38 changes: 35 additions & 3 deletions playbook/app/pb_kits/playbook/pb_text_input/_text_input.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -22,6 +24,7 @@ type TextInputProps = {
inline?: boolean,
name: string,
label: string,
mask?: 'currency' | 'zipCode' | 'postalCode' | 'ssn',
onChange: (e: React.FormEvent<HTMLInputElement>) => void,
placeholder: string,
required?: boolean,
Expand All @@ -47,6 +50,7 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
htmlOptions = {},
id,
inline = false,
mask = null,
name,
label,
onChange = () => { void 0 },
Expand Down Expand Up @@ -90,6 +94,33 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
/>
)

const isMaskedInput = mask && mask in INPUTMASKS

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 = (
Expand All @@ -101,8 +132,9 @@ const TextInput = (props: TextInputProps, ref: React.LegacyRef<HTMLInputElement>
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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<TextInput
label="Currency"
mask="currency"
name="currency"
onChange={handleOnChangeFormField}
value={formFields.currency}
{...props}
/>
<TextInput
label="Zip Code"
mask="zipCode"
name="zipCode"
onChange={handleOnChangeFormField}
value={formFields.zipCode}
{...props}
/>
<TextInput
label="Postal Code"
mask="postalCode"
name="postalCode"
onChange={handleOnChangeFormField}
value={formFields.postalCode}
{...props}
/>
<TextInput
label="SSN"
mask="ssn"
name="ssn"
onChange={handleOnChangeFormField}
value={formFields.ssn}
{...props}
/>

<br />
<br />

<Title>{'Event Handler Props'}</Title>

<br />
<Caption>{'onChange'}</Caption>

<br />

<TextInput
label="SSN"
mask="ssn"
onChange={handleOnChangeSSN}
placeholder="Enter SSN"
ref={ref}
value={ssn}
{...props}
/>

{ssn !== '' && (
<React.Fragment>{`SSN is: ${ssn}`}</React.Fragment>
)}
</div>
)
}

export default TextInputMask
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions playbook/app/pb_kits/playbook/pb_text_input/docs/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
64 changes: 64 additions & 0 deletions playbook/app/pb_kits/playbook/pb_text_input/inputMask.ts
Original file line number Diff line number Diff line change
@@ -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',
},
}
141 changes: 139 additions & 2 deletions playbook/app/pb_kits/playbook/pb_text_input/text_input.test.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 (
<TextInput
mask="currency"
onChange={handleOnChange}
value={currency}
{...props}
/>
)
}

test('returns masked currency value', () => {
render(
<TextInputCurrencyMask
data={{ testid: testId }}
/>
)

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 (
<TextInput
mask="zipCode"
onChange={handleOnChange}
value={zipCode}
{...props}
/>
)
}

test('returns masked zip code value', () => {
render(
<TextInputZipCodeMask
data={{ testid: testId }}
/>
)

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 (
<TextInput
mask="postalCode"
onChange={handleOnChange}
value={postalCode}
{...props}
/>
)
}

test('returns masked postal code value', () => {
render(
<TextInputPostalCodeMask
data={{ testid: testId }}
/>
)

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 (
<TextInput
mask="ssn"
onChange={handleOnChange}
value={ssn}
{...props}
/>
)
}

test('returns masked ssn value', () => {
render(
<TextInputSSNMask
data={{ testid: testId }}
/>
)

const kit = screen.getByTestId(testId)

const input = within(kit).getByRole('textbox');

fireEvent.change(input, { target: { value: '123456789' } });

expect(input.value).toBe('123-45-6789')
})

0 comments on commit a5305c7

Please sign in to comment.