-
Notifications
You must be signed in to change notification settings - Fork 87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: text input mask fixes #2581
Merged
rogeruiz
merged 7 commits into
trussworks:main
from
nicholasguyett:nicholasguyett/text-input-mask-fixes
Sep 8, 2023
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
329075e
Add support for defaultValue to TextInputMask
nicholasguyett 62935ae
Fix input ref passthrough
nicholasguyett 6bda380
Remove redundant pass-through props
nicholasguyett 854e433
Simplify prop type declaration
nicholasguyett a83d719
Fix onChange hook
nicholasguyett 1f60250
Add support for defaultValue and remove redundant value state
nicholasguyett a6c05e8
Ensure TextInputMask can be used as a controlled input
nicholasguyett File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,53 @@ | ||
/* eslint-disable security/detect-object-injection */ | ||
import React, { useState } from 'react' | ||
import React, { useEffect, useState } from 'react' | ||
import classnames from 'classnames' | ||
import { TextInput, TextInputProps } from '../TextInput/TextInput' | ||
|
||
type MaskProps = { | ||
export type AllProps = TextInputProps & { | ||
mask: string | ||
charset?: string | ||
} | ||
|
||
export type AllProps = TextInputProps & MaskProps | ||
function maskString(value: string, mask: string, charset?: string) { | ||
const maskData = charset || mask | ||
|
||
const strippedValue = charset | ||
? value.replace(/\W/g, '') | ||
: value.replace(/\D/g, '') | ||
const charIsInteger = (v: string) => !Number.isNaN(parseInt(v, 10)) | ||
const charIsLetter = (v: string) => (v ? v.match(/[A-Z]/i) : false) | ||
const maskedNumber = '_#dDmMyY9' | ||
const maskedLetter = 'A' | ||
let newValue = '' | ||
for (let m = 0, v = 0; m < maskData.length; m++) { | ||
const isInt = charIsInteger(strippedValue[v]) | ||
const isLet = charIsLetter(strippedValue[v]) | ||
const matchesNumber = maskedNumber.indexOf(maskData[m]) >= 0 | ||
const matchesLetter = maskedLetter.indexOf(maskData[m]) >= 0 | ||
if ((matchesNumber && isInt) || (charset && matchesLetter && isLet)) { | ||
newValue += strippedValue[v++] | ||
} else if ( | ||
strippedValue[v] === undefined || // if no characters left and the pattern is non-special character | ||
(!charset && !isInt && matchesNumber) || | ||
(charset && ((matchesLetter && !isLet) || (matchesNumber && !isLet))) | ||
) { | ||
break | ||
} else { | ||
newValue += maskData[m] | ||
} | ||
} | ||
|
||
return newValue | ||
} | ||
|
||
export const TextInputMask = ({ | ||
id, | ||
name, | ||
type, | ||
className, | ||
validationStatus, | ||
inputSize, | ||
inputRef, | ||
mask, | ||
value: externalValue, | ||
defaultValue, | ||
charset, | ||
onChange, | ||
...inputProps | ||
}: AllProps): React.ReactElement => { | ||
const classes = classnames( | ||
|
@@ -29,42 +57,31 @@ export const TextInputMask = ({ | |
className | ||
) | ||
|
||
const [inputValue, setInputValue] = useState('') | ||
const [maskValue, setMaskValue] = useState(mask) | ||
const [iValue, setIValue] = useState('') | ||
const [value, setValue] = useState( | ||
// Ensure that this component preserves the expected behavior when a user sets the defaultValue | ||
maskString((externalValue ?? defaultValue ?? ``) as string, mask, charset) | ||
) | ||
useEffect(() => { | ||
// Make sure this component behaves correctly when used as a controlled component | ||
setValue( | ||
maskString( | ||
((externalValue ?? defaultValue) as string) ?? ``, | ||
mask, | ||
charset | ||
) | ||
) | ||
}, [externalValue]) | ||
const [maskValue, setMaskValue] = useState(mask.substring(value.length)) | ||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { | ||
const value = e.target.value | ||
const maskData = charset || mask | ||
if (undefined === maskData) return | ||
const strippedValue = charset | ||
? value.replace(/\W/g, '') | ||
: value.replace(/\D/g, '') | ||
const charIsInteger = (v: string) => !Number.isNaN(parseInt(v, 10)) | ||
const charIsLetter = (v: string) => (v ? v.match(/[A-Z]/i) : false) | ||
const maskedNumber = '_#dDmMyY9' | ||
const maskedLetter = 'A' | ||
let newValue = '' | ||
for (let m = 0, v = 0; m < maskData.length; m++) { | ||
const isInt = charIsInteger(strippedValue[v]) | ||
const isLet = charIsLetter(strippedValue[v]) | ||
const matchesNumber = maskedNumber.indexOf(maskData[m]) >= 0 | ||
const matchesLetter = maskedLetter.indexOf(maskData[m]) >= 0 | ||
if ((matchesNumber && isInt) || (charset && matchesLetter && isLet)) { | ||
newValue += strippedValue[v++] | ||
} else if ( | ||
strippedValue[v] === undefined || // if no characters left and the pattern is non-special character | ||
(!charset && !isInt && matchesNumber) || | ||
(charset && ((matchesLetter && !isLet) || (matchesNumber && !isLet))) | ||
) { | ||
break | ||
} else { | ||
newValue += maskData[m] | ||
} | ||
} | ||
const newValue = maskString(e.target.value, mask, charset) | ||
|
||
setMaskValue(mask.substring(newValue.length)) | ||
setIValue(newValue) | ||
setInputValue(newValue) | ||
inputProps.onChange | ||
setValue(newValue) | ||
|
||
// Ensure the new value is available to upstream onChange listeners | ||
e.target.value = newValue | ||
Comment on lines
+81
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch here on ensuring other |
||
|
||
onChange?.(e) | ||
} | ||
|
||
return ( | ||
|
@@ -73,21 +90,16 @@ export const TextInputMask = ({ | |
className="usa-input-mask--content" | ||
aria-hidden | ||
data-testid={`${id}Mask`}> | ||
<i>{iValue}</i> | ||
<i>{value}</i> | ||
{maskValue} | ||
</span> | ||
<TextInput | ||
data-testid="textInput" | ||
className={classes} | ||
id={id} | ||
name={name} | ||
type={type} | ||
ref={inputRef} | ||
maxLength={mask.length} | ||
onChange={handleChange} | ||
value={inputValue} | ||
validationStatus={validationStatus} | ||
inputSize={inputSize} | ||
value={value} | ||
{...inputProps} | ||
/> | ||
</span> | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work on this and using
useEffect
API hook.