Skip to content

Commit

Permalink
Add zip, ssn, and phone masks (#256)
Browse files Browse the repository at this point in the history
* Add zip code mask; Rename doc page

* Add zip code unmask option

* Use regex for chunking

* Add SSN mask

* Add additional test; Rename currency field

* Remove zip code unmask method

* Document reasoning for not unmasking zip code

* Add phone mask

* Support unexpectedly long mask values

* Support unmasking zip value

* Refactor organization of class methods
  • Loading branch information
sawyerh authored Mar 12, 2018
1 parent c9c79cf commit 0579b7b
Show file tree
Hide file tree
Showing 8 changed files with 349 additions and 149 deletions.
18 changes: 0 additions & 18 deletions packages/core/src/components/TextField/CurrencyField.example.jsx

This file was deleted.

56 changes: 0 additions & 56 deletions packages/core/src/components/TextField/CurrencyField.scss
Original file line number Diff line number Diff line change
@@ -1,36 +1,5 @@
@import '@cmsgov/design-system-support/src/settings/index';

/*
Currency field
A currency field is an enhanced input field that provides visual and non-visual
cues to a user to enter an amount of money in a specified currency.
Markup:
<label class="ds-c-label" for="currency_field">
Monthly income
</label>
<div class="ds-c-field-mask ds-c-field-mask--currency">
<div class="ds-c-field__before ds-c-field__before--currency">$</div>
<input type="text" aria-label="Enter amount in dollars" class="ds-c-field ds-c-field--currency" id="currency_field" name="currency_example">
</div>
Style guide: components.currency-field
*/

/*
`<TextField mask="currency">`
Passing a `mask="currency"` prop into the `TextField` component will
enable formatting to occur when the field is blurred. To "unmask" the
value, you can import the `unmaskValue` method.
@react-component TextField
@react-example CurrencyField
Style guide: components.currency-field.react
*/
.ds-c-field__before--currency {
// Increase currency size so it serves more as an icon and
// better aligns with the input value text
Expand All @@ -44,28 +13,3 @@ Style guide: components.currency-field.react
// Normal padding + space for the icon + normal padding
padding-left: $input-padding + 4px + $input-padding;
}

/*
---
## When to use
- Use the Currency input component anytime users should enter an amount of money in a particular currency, like dollars.
**[View the "Text field" guidance for additional guidance and best practices.]({{root}}/patterns/text-field#guidance)**
## Accessibility
- Ensure the field includes an `aria-label` describing the expected currency for users using assistive devices.
## Related patterns
- [Text field]({{root}}/components/text-field/)
## Learn more
- [Form Guidelines]({{root}}/guidelines/forms/)
- [GOV.UK - Currency input testing and research](https://github.com/alphagov/govuk-design-system/wiki/Currency-input-testing-and-research)
Style guide: components.currency-field.guidance
*/
50 changes: 50 additions & 0 deletions packages/core/src/components/TextField/Mask.example.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import TextField, { unmaskValue } from './TextField';
import React from 'react';
import ReactDOM from 'react-dom';
// import { unmaskValue } from '@cmsgov/design-system-core';

function handleBlur(evt, mask) {
console.log('Unmasked value:', unmaskValue(evt.target.value, mask));
}

const Example = () => {
return (
<div>
<TextField
ariaLabel="Enter monthly income amount in dollars."
label="Currency"
mask="currency"
name="currency_example"
onBlur={evt => handleBlur(evt, 'currency')}
value="2500"
/>

<TextField
label="Phone number"
mask="phone"
name="phone_example"
onBlur={evt => handleBlur(evt, 'phone')}
type="tel"
value="1234567890"
/>

<TextField
label="Social security number (SSN)"
mask="ssn"
name="ssn_example"
onBlur={evt => handleBlur(evt, 'ssn')}
value="123456789"
/>

<TextField
label="Zip code"
mask="zip"
name="zip_example"
onBlur={evt => handleBlur(evt, 'zip')}
value="123456789"
/>
</div>
);
};

ReactDOM.render(<Example />, document.getElementById('js-example'));
153 changes: 109 additions & 44 deletions packages/core/src/components/TextField/Mask.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,104 @@
/*
Masked field
A masked field is an enhanced input field that provides visual and non-visual
cues to a user about the expected value.
Style guide: components.masked-field
*/
import PropTypes from 'prop-types';
import React from 'react';

// Deliminate chunks of integers
const deliminatedMaskRegex = {
phone: /(\d{3})(\d{1,3})?(\d+)?/,
ssn: /(\d{3})(\d{1,2})?(\d+)?/,
zip: /(\d{5})(\d+)/
};

/**
* Split value into groups and insert a hyphen deliminator between each
* @param {String} value
* @param {RegExp} rx - Regular expression with capturing groups
* @returns {String}
*/
function deliminateRegexGroups(value, rx) {
const matches = toInt(value).match(rx);

if (matches && matches.length > 1) {
value = matches
.slice(1)
.filter(a => !!a) // remove undefined groups
.join('-');
}

return value;
}

/**
* Format a string using fixed-point notation, similar to Number.prototype.toFixed
* though a decimal is only fixed if the string included a decimal already
* @param {String} value - A stringified number (i.e. "1234")
* @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String}
*/
function stringWithFixedDigits(value, digits = 2) {
const decimalRegex = /\.[\d]+$/;

// Check for existing decimal
const decimal = value.match(decimalRegex);

if (decimal) {
const fixedDecimal = parseFloat(decimal)
.toFixed(digits)
.match(decimalRegex)[0];

return value.replace(decimal, fixedDecimal);
}

return value;
}

/**
* Remove all non-digits
* @param {String} value
* @returns {String}
*/
function toInt(value) {
return value.replace(/\D+/g, '');
}

/**
* Convert string into a number (positive or negative float or integer)
* @param {String} value
* @returns {Number}
*/
function toNumber(value) {
if (typeof value !== 'string') return value;

// 0 = number, 1 = decimals
const parts = value.split('.');
const digitsRegex = /^-|\d/g; // include a check for a beginning "-" for negative numbers
const a = parts[0].match(digitsRegex).join('');
const b = parts.length >= 2 && parts[1].match(digitsRegex).join('');

return b ? parseFloat(`${a}.${b}`) : parseInt(a);
}

/*
`<TextField mask={...}>`
Passing a `mask` prop into the `TextField` component with a valid value will
enable formatting to occur when the field is blurred. To "unmask" the
value, you can import and call the `unmaskValue` method.
@react-component TextField
@react-example Mask
Style guide: components.masked-field.react
*/

/**
* A Mask component renders a controlled input field. When the
* field is blurred, it applies formatting to improve the readability
Expand All @@ -22,60 +120,22 @@ export class Mask extends React.PureComponent {
}
}

/**
* @param {String} value
* @returns {Number}
*/
toNumber(value) {
if (typeof value !== 'string') return value;

// 0 = number, 1 = decimals
const parts = value.split('.');
const digitsRegex = /^-|\d/g; // include a check for a beginning "-" for negative numbers
const a = parts[0].match(digitsRegex).join('');
const b = parts.length >= 2 && parts[1].match(digitsRegex).join('');

return b ? parseFloat(`${a}.${b}`) : parseInt(a);
}

/**
* Format a string using fixed-point notation, similar to Number.prototype.toFixed
* though a decimal is only fixed if the string included a decimal already
* @param {String} value - A stringified number (i.e. "1234")
* @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String}
*/
stringWithFixedDigits(value, digits = 2) {
const decimalRegex = /\.[\d]+$/;

// Check for existing decimal
const decimal = value.match(decimalRegex);

if (decimal) {
const fixedDecimal = parseFloat(decimal)
.toFixed(digits)
.match(decimalRegex)[0];

return value.replace(decimal, fixedDecimal);
}

return value;
}

/**
* Returns the value with additional masking characters
* @param {String} value
* @returns {String}
*/
maskedValue(value = '') {
if (value && typeof value === 'string') {
const { mask } = this.props;
value = value.trim();

if (this.props.mask === 'currency') {
if (mask === 'currency') {
// Format number with commas. If the number includes a decimal,
// ensure it includes two decimal points
value = this.toNumber(value);
value = this.stringWithFixedDigits(value.toLocaleString('en-US'));
value = stringWithFixedDigits(toNumber(value).toLocaleString('en-US'));
} else if (Object.keys(deliminatedMaskRegex).includes(mask)) {
value = deliminateRegexGroups(value, deliminatedMaskRegex[mask]);
}
}

Expand Down Expand Up @@ -153,11 +213,16 @@ Mask.propTypes = {
* @returns {String}
*/
export function unmask(value, mask) {
if (!value) return value;
if (!value || typeof value !== 'string') return value;

value = value.trim();

if (mask === 'currency') {
// Preserve only digits, decimal point, or negative symbol
value = value.match(/^-|[\d.]/g).join('');
} else if (Object.keys(deliminatedMaskRegex).includes(mask)) {
// Remove the deliminators and revert to single ungrouped string
value = toInt(value);
}

return value;
Expand Down
Loading

0 comments on commit 0579b7b

Please sign in to comment.