From 0579b7b7bce0cf6ab6d5243a4357d3718e67213c Mon Sep 17 00:00:00 2001 From: Sawyer Hollenshead Date: Mon, 12 Mar 2018 18:25:45 -0400 Subject: [PATCH] Add zip, ssn, and phone masks (#256) * 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 --- .../TextField/CurrencyField.example.jsx | 18 -- .../components/TextField/CurrencyField.scss | 56 ----- .../src/components/TextField/Mask.example.jsx | 50 ++++ .../core/src/components/TextField/Mask.jsx | 153 +++++++++---- .../src/components/TextField/Mask.test.jsx | 213 +++++++++++++++--- .../src/components/TextField/TextField.jsx | 2 +- .../src/components/TextField/TextField.scss | 2 +- packages/core/yarn.lock | 4 + 8 files changed, 349 insertions(+), 149 deletions(-) delete mode 100644 packages/core/src/components/TextField/CurrencyField.example.jsx create mode 100644 packages/core/src/components/TextField/Mask.example.jsx diff --git a/packages/core/src/components/TextField/CurrencyField.example.jsx b/packages/core/src/components/TextField/CurrencyField.example.jsx deleted file mode 100644 index d10c56fefc..0000000000 --- a/packages/core/src/components/TextField/CurrencyField.example.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import TextField, { unmaskValue } from './TextField'; -import React from 'react'; -import ReactDOM from 'react-dom'; - -ReactDOM.render( - { - // import { unmaskValue } from '@cmsgov/design-system-core'; - console.log('Unmasked value:', unmaskValue(e.target.value, 'currency')); - }} - value="2500" - />, - document.getElementById('js-example') -); diff --git a/packages/core/src/components/TextField/CurrencyField.scss b/packages/core/src/components/TextField/CurrencyField.scss index 407a719080..994e6d3f01 100644 --- a/packages/core/src/components/TextField/CurrencyField.scss +++ b/packages/core/src/components/TextField/CurrencyField.scss @@ -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: - -
-
$
- -
- -Style guide: components.currency-field -*/ - -/* -`` - -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 @@ -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 -*/ diff --git a/packages/core/src/components/TextField/Mask.example.jsx b/packages/core/src/components/TextField/Mask.example.jsx new file mode 100644 index 0000000000..087c0ba6af --- /dev/null +++ b/packages/core/src/components/TextField/Mask.example.jsx @@ -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 ( +
+ handleBlur(evt, 'currency')} + value="2500" + /> + + handleBlur(evt, 'phone')} + type="tel" + value="1234567890" + /> + + handleBlur(evt, 'ssn')} + value="123456789" + /> + + handleBlur(evt, 'zip')} + value="123456789" + /> +
+ ); +}; + +ReactDOM.render(, document.getElementById('js-example')); diff --git a/packages/core/src/components/TextField/Mask.jsx b/packages/core/src/components/TextField/Mask.jsx index 52ea231d18..5036795eb4 100644 --- a/packages/core/src/components/TextField/Mask.jsx +++ b/packages/core/src/components/TextField/Mask.jsx @@ -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); +} + +/* +`` + +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 @@ -22,46 +120,6 @@ 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 @@ -69,13 +127,15 @@ export class Mask extends React.PureComponent { */ 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]); } } @@ -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; diff --git a/packages/core/src/components/TextField/Mask.test.jsx b/packages/core/src/components/TextField/Mask.test.jsx index c1cd5301c7..43a6667ae0 100644 --- a/packages/core/src/components/TextField/Mask.test.jsx +++ b/packages/core/src/components/TextField/Mask.test.jsx @@ -2,6 +2,9 @@ import Mask, { unmask } from './Mask'; import { mount, shallow } from 'enzyme'; import React from 'react'; +// Some tests are generated. When a new mask is added, add it here: +const masks = ['currency', 'ssn', 'zip']; + function render(customProps = {}, inputProps = {}, deep = false) { const component = ( @@ -16,6 +19,31 @@ function render(customProps = {}, inputProps = {}, deep = false) { } describe('Mask', function() { + masks.forEach(mask => { + describe(`${mask} fallbacks`, () => { + it('renders a blank controlled field when value is empty', () => { + const data = render({ mask: mask }, { value: '' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + + it('renders a blank controlled field when value is null', () => { + const data = render({ mask: mask }, { value: null }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + + it('renders a blank controlled field when value is undefined', () => { + const data = render({ mask: mask }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + }); + }); + it('calls onBlur when the value is the same', () => { const onBlur = jest.fn(); const wrapper = render( @@ -55,28 +83,14 @@ describe('Mask', function() { expect(onChange.mock.calls.length).toBe(1); }); - describe('Currency', () => { - it('renders a blank controlled field when value is empty', () => { - const data = render({ mask: 'currency' }, { value: '' }); - const input = data.wrapper.find('input'); + it('changes to a controlled field using defaultValue', () => { + const data = render({ mask: 'currency' }, { defaultValue: '1234' }); + const input = data.wrapper.find('input'); - expect(input.prop('value')).toBe(''); - }); - - it('renders a blank controlled field when value is null', () => { - const data = render({ mask: 'currency' }, { value: null }); - const input = data.wrapper.find('input'); - - expect(input.prop('value')).toBe(''); - }); - - it('renders a blank controlled field when value is undefined', () => { - const data = render({ mask: 'currency' }); - const input = data.wrapper.find('input'); - - expect(input.prop('value')).toBe(''); - }); + expect(input.prop('value')).toBe('1,234'); + }); + describe('Currency', () => { it('accepts already masked value', () => { const data = render({ mask: 'currency' }, { value: '1,234.50' }); const input = data.wrapper.find('input'); @@ -105,21 +119,139 @@ describe('Mask', function() { expect(input.prop('value')).toBe('1,234'); }); - it('adds commas to defaultValue and replaces with value prop', () => { - const data = render( - { mask: 'currency' }, - { defaultValue: '12345678.90' } - ); + it('accepts negative values', () => { + const data = render({ mask: 'currency' }, { value: '-1,234' }); const input = data.wrapper.find('input'); - expect(input.prop('value')).toBe('12,345,678.90'); + expect(input.prop('value')).toBe('-1,234'); + }); + }); + + describe('Phone', () => { + it('accepts partial phone #', () => { + const data = render({ mask: 'phone' }, { value: '123' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123'); }); - it('accepts negative values', () => { - const data = render({ mask: 'currency' }, { value: '-1,234' }); + it('accepts unexpectedly long value', () => { + const data = render({ mask: 'phone' }, { value: '123456789000' }); const input = data.wrapper.find('input'); - expect(input.prop('value')).toBe('-1,234'); + // Yes, this is invalid, but it should be up to to the app + // to surface an error in these cases. The mask shouldn't + // be changing the raw value a user has entered. + expect(input.prop('value')).toBe('123-456-789000'); + }); + + it('accepts masked phone #', () => { + const data = render({ mask: 'phone' }, { value: '123-456-7890' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-456-7890'); + }); + + it('masks phone #', () => { + const data = render({ mask: 'phone' }, { value: '1234567890' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-456-7890'); + }); + }); + + describe('SSN', () => { + it('accepts partial ssn', () => { + const data = render({ mask: 'ssn' }, { value: '123' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123'); + }); + + it('accepts unexpectedly long value', () => { + const data = render({ mask: 'ssn' }, { value: '1234567890' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45-67890'); + }); + + it('accepts masked ssn', () => { + const data = render({ mask: 'ssn' }, { value: '123-45-6789' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45-6789'); + }); + + it('accepts ssn masked with different characters', () => { + const data = render({ mask: 'ssn' }, { value: '123 45 6789' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45-6789'); + }); + + it('masks full ssn', () => { + const data = render({ mask: 'ssn' }, { value: '123456789' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45-6789'); + }); + + it('masks partial (5) ssn', () => { + const data = render({ mask: 'ssn' }, { value: '12345' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45'); + }); + + it('masks partial (7) ssn', () => { + const data = render({ mask: 'ssn' }, { value: '1234567' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123-45-67'); + }); + }); + + describe('Zip code', () => { + it('accepts partial zip code', () => { + const data = render({ mask: 'zip' }, { value: '123' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('123'); + }); + + it('accepts unexpectedly long value', () => { + const data = render({ mask: 'zip' }, { value: '1234567890' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12345-67890'); + }); + + it('accepts five-digit zip code', () => { + const data = render({ mask: 'zip' }, { value: '12345' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12345'); + }); + + it('accepts nine-digit zip code', () => { + const data = render({ mask: 'zip' }, { value: '123456789' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12345-6789'); + }); + + it('accepts partial +4 zip code', () => { + const data = render({ mask: 'zip' }, { value: '1234567' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12345-67'); + }); + + it('accepts masked nine-digit zip code', () => { + const data = render({ mask: 'zip' }, { value: '12345-6789' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12345-6789'); }); }); }); @@ -148,4 +280,27 @@ describe('unmask', () => { expect(unmask('1,234,000.50', name)).toBe('1234000.50'); expect(unmask('-1,234,000.50', name)).toBe('-1234000.50'); }); + + it('removes mask from zip code', () => { + const name = 'zip'; + + expect(unmask('', name)).toBe(''); + expect(unmask(' 12345 ', name)).toBe('12345'); + expect(unmask('12345-6789', name)).toBe('123456789'); + }); + + it('removes mask from ssn value', () => { + const name = 'ssn'; + + expect(unmask('', name)).toBe(''); + expect(unmask(' 123-45-6789 ', name)).toBe('123456789'); + expect(unmask('123456789', name)).toBe('123456789'); + }); + + it('removes mask from phone number', () => { + const name = 'phone'; + + expect(unmask('', name)).toBe(''); + expect(unmask(' 123-456-7890 ', name)).toBe('1234567890'); + }); }); diff --git a/packages/core/src/components/TextField/TextField.jsx b/packages/core/src/components/TextField/TextField.jsx index 848e5f692e..27baf37324 100644 --- a/packages/core/src/components/TextField/TextField.jsx +++ b/packages/core/src/components/TextField/TextField.jsx @@ -193,7 +193,7 @@ TextField.propTypes = { * you expect to be entered. Depending on the mask, the * field's appearance and functionality may be affected. */ - mask: PropTypes.oneOf(['currency']), + mask: PropTypes.oneOf(['currency', 'phone', 'ssn', 'zip']), /** * `max` HTML input attribute */ diff --git a/packages/core/src/components/TextField/TextField.scss b/packages/core/src/components/TextField/TextField.scss index b659b535a0..4c18026eb3 100644 --- a/packages/core/src/components/TextField/TextField.scss +++ b/packages/core/src/components/TextField/TextField.scss @@ -192,7 +192,7 @@ The following Sass variables can be overridden to theme a field: ## Related patterns -- [Currency field]({{root}}/components/currency-field/) +- [Masked field]({{root}}/components/masked-field/) - [Date field]({{root}}/components/date-field/) ## Learn more diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index 0e622f5730..12a42f00db 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -30,6 +30,10 @@ focus-trap@^2.0.1: dependencies: tabbable "^1.0.3" +lodash.chunk@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc" + lodash.uniqueid@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.uniqueid/-/lodash.uniqueid-4.0.1.tgz#3268f26a7c88e4f4b1758d679271814e31fa5b26"