-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Currency mask formatting onBlur (#251)
* 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
Showing
6 changed files
with
284 additions
and
11 deletions.
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
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 |
---|---|---|
@@ -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; |
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 |
---|---|---|
@@ -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'); | ||
}); | ||
}); | ||
}); |
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
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
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