Skip to content

Commit

Permalink
Add Currency mask formatting onBlur (#251)
Browse files Browse the repository at this point in the history
* Add Currency mask formatting onBlur

* Fix tests

* Use a more realistic monthly income

* Debounce calling onBlur until value has been updated

* Add en-US locale string

* Ensure onBlur; Accept empty strings

* Fallback to empty string so field doesn't start uncontrolled

* Add test for pre-masked values

* Add test for null and undefined value test

* Correct Mask comment

* Remove unnecesary object.assign
  • Loading branch information
sawyerh authored Mar 7, 2018
1 parent 4fe7049 commit 4326116
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ReactDOM.render(
label="Monthly income"
mask="currency"
name="currency_example"
value="2500"
/>,
document.getElementById('js-example')
);
149 changes: 149 additions & 0 deletions packages/core/src/components/TextField/Mask.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import PropTypes from 'prop-types';
import React from 'react';

/**
* A Mask component renders a controlled input field. When the
* field is blurred, it applies formatting to improve the readability
* of the value.
*/
export class Mask extends React.PureComponent {
constructor(props) {
super(props);
this.field = React.Children.only(this.props.children);
this.state = {
value: this.maskedValue(this.initialValue())
};
}

componentDidUpdate() {
if (this.debouncedOnBlurEvent) {
this.field.props.onBlur(this.debouncedOnBlurEvent);
this.debouncedOnBlurEvent = null;
}
}

/**
* @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;
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') {
value = value.trim();

if (this.props.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'));
}
}

return value;
}

/**
* To avoid a jarring experience for screen readers, we only
* add/remove characters after the field has been blurred,
* rather than when the user is typing in the field
* @param {Object} evt
*/
handleBlur(evt) {
const value = this.maskedValue(evt.target.value);

// We only debounce the onBlur when we know for sure that
// this component will re-render (AKA when the value changes)
// and when an onBlur callback is present
const debounce =
value !== this.state.value &&
typeof this.field.props.onBlur === 'function';

if (debounce) {
// We need to retain a reference to the event after the callback
// has been called. We pass this onto the consuming app's onBlur
// only after the value has been manipulated – this way, the
// value returned by event.target.value is the value after masking
evt.persist();
this.debouncedOnBlurEvent = evt;
}

this.setState({
value
});

if (!debounce && typeof this.field.props.onBlur === 'function') {
// If we didn't debounce the onBlur event, then we need to
// call the onBlur callback from here
this.field.props.onBlur(evt);
}
}

handleChange(evt) {
this.setState({ value: evt.target.value });

if (typeof this.field.props.onChange === 'function') {
this.field.props.onChange(evt);
}
}

initialValue() {
return this.field.props.value || this.field.props.defaultValue;
}

render() {
return React.cloneElement(this.field, {
defaultValue: undefined,
onBlur: evt => this.handleBlur(evt),
onChange: evt => this.handleChange(evt),
value: this.state.value
});
}
}

Mask.propTypes = {
/** Pass the input as the child */
children: PropTypes.node.isRequired,
mask: PropTypes.string.isRequired
};

export default Mask;
118 changes: 118 additions & 0 deletions packages/core/src/components/TextField/Mask.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { mount, shallow } from 'enzyme';
import Mask from './Mask';
import React from 'react';

function render(customProps = {}, inputProps = {}, deep = false) {
const component = (
<Mask {...customProps}>
<input name="foo" type="text" {...inputProps} />
</Mask>
);

return {
props: customProps,
wrapper: deep ? mount(component) : shallow(component)
};
}

describe('Mask', function() {
it('calls onBlur when the value is the same', () => {
const onBlur = jest.fn();
const wrapper = render(
{ mask: 'currency' },
{ value: '123', onBlur: onBlur }
).wrapper;

wrapper.simulate('blur', { target: { value: '123' }, persist: jest.fn() });

expect(onBlur.mock.calls.length).toBe(1);
});

it('calls onBlur when the value changes', () => {
const onBlur = jest.fn();
const wrapper = render(
{ mask: 'currency' },
{ value: '123', onBlur: onBlur },
true
).wrapper;

wrapper
.find('input')
.simulate('blur', { target: { value: '1234' }, persist: jest.fn() });

expect(onBlur.mock.calls.length).toBe(1);
});

it('calls onChange', () => {
const onChange = jest.fn();
const wrapper = render(
{ mask: 'currency' },
{ value: '123', onChange: onChange }
).wrapper;

wrapper.simulate('change', { target: { value: '123' } });

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');

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('');
});

it('accepts already masked value', () => {
const data = render({ mask: 'currency' }, { value: '1,234.50' });
const input = data.wrapper.find('input');

expect(input.prop('value')).toBe('1,234.50');
});

it('adds commas to value with decimal ending in 0', () => {
const data = render({ mask: 'currency' }, { value: '12345678.90' });
const input = data.wrapper.find('input');

expect(input.prop('value')).toBe('12,345,678.90');
});

it('adds commas to value with decimal ending in non-zero number', () => {
const data = render({ mask: 'currency' }, { value: '1234.95' });
const input = data.wrapper.find('input');

expect(input.prop('value')).toBe('1,234.95');
});

it('adds commas to value with no decimal', () => {
const data = render({ mask: 'currency' }, { value: '1234' });
const input = data.wrapper.find('input');

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' }
);
const input = data.wrapper.find('input');

expect(input.prop('value')).toBe('12,345,678.90');
});
});
});
7 changes: 4 additions & 3 deletions packages/core/src/components/TextField/TextField.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import FormLabel from '../FormLabel/FormLabel';
import Mask from './Mask';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
Expand Down Expand Up @@ -32,8 +33,8 @@ export class TextField extends React.PureComponent {

return maskName ? (
<div className={`ds-c-field-mask ds-c-field-mask--${maskName}`}>
{this.renderMask()}
{field}
{this.renderMaskOverlay()}
<Mask mask={maskName}>{field}</Mask>
</div>
) : (
field
Expand All @@ -43,7 +44,7 @@ export class TextField extends React.PureComponent {
/**
* UI overlayed on top of a field to support certain masks
*/
renderMask() {
renderMaskOverlay() {
if (this.props.mask) {
const content = {
currency: '$'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ exports[`TextField masked renders currency mask 1`] = `
>
$
</div>
<input
aria-label="Foo. Enter amount in dollars."
className="ds-c-field ds-c-field--currency"
id="textfield_29"
name="spec-field"
type="text"
/>
<Mask
mask="currency"
>
<input
aria-label="Foo. Enter amount in dollars."
className="ds-c-field ds-c-field--currency"
id="textfield_29"
name="spec-field"
type="text"
/>
</Mask>
</div>
</div>
`;
2 changes: 1 addition & 1 deletion packages/core/src/components/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function getDirectories(paths) {
return paths.filter(filePath => fs.lstatSync(filePath).isDirectory());
}

const ignoredComponents = ['Step', 'SubStep', 'StepLink', 'ReviewLink'];
const ignoredComponents = ['Mask', 'ReviewLink', 'Step', 'SubStep', 'StepLink'];

describe('Components index', () => {
it("exports all components except ones we don't want to expose", () => {
Expand Down

0 comments on commit 4326116

Please sign in to comment.