Skip to content

Commit

Permalink
Masked TextFields to behave as controlled component when value prop i…
Browse files Browse the repository at this point in the history
…s used (#281)

* Mask can now support controlled TextFields. ESLint is yelling at me at the moment, but I want to get these changes into version control before I start trying alternatives that will make ESLint happy.

* This checks to see if our React version supports getDerivedStateFromProps in componentDidUpdate to kind of polyfill the new lifecycle method. A more official alternative would be https://github.com/reactjs/react-lifecycles-compat. I don't really like the idea of either. I'm undecided as of yet whether to introduce the polyfill or to just continue to use componentDidUpdate in all cases with a note about changing it in the future when we know all our users are using 16.3+

* This is a working example with a polyfill.

* Got rid of the compiled version and fixed eslint errors

* Resetting the example to master because we don't actually want it in the docs site.

* But we do want to be clear that the examples are uncontrolled components

* Add unit tests

* Moved maskValue above the component because that's where the other un-exported helper functions are.
  • Loading branch information
pwolfert authored Oct 1, 2018
1 parent 305315d commit 752e14f
Show file tree
Hide file tree
Showing 6 changed files with 1,083 additions and 38 deletions.
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"downshift": "^1.28.2",
"ev-emitter": "^1.1.1",
"lodash.uniqueid": "^4.0.1",
"react-aria-modal": "^2.11.1"
"react-aria-modal": "^2.11.1",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"prop-types": "^15.0.0 || ^16.0.0",
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/components/TextField/Mask.example.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const Example = () => {
mask="currency"
name="currency_example"
onBlur={evt => handleBlur(evt, 'currency')}
value="2500"
defaultValue="2500"
/>

<TextField
Expand All @@ -25,23 +25,23 @@ const Example = () => {
name="phone_example"
onBlur={evt => handleBlur(evt, 'phone')}
type="tel"
value="1234567890"
defaultValue="1234567890"
/>

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

<TextField
label="Zip code"
mask="zip"
name="zip_example"
onBlur={evt => handleBlur(evt, 'zip')}
value="123456789"
defaultValue="123456789"
/>
</div>
);
Expand Down
78 changes: 47 additions & 31 deletions packages/core/src/components/TextField/Mask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Style guide: components.masked-field
*/
import PropTypes from 'prop-types';
import React from 'react';
import { polyfill } from 'react-lifecycles-compat';

// Deliminate chunks of integers
const deliminatedMaskRegex = {
Expand Down Expand Up @@ -85,6 +86,27 @@ function toNumber(value) {
return b ? parseFloat(`${a}.${b}`) : parseInt(a);
}

/**
* Returns the value with additional masking characters
* @param {String} value
* @returns {String}
*/
function maskValue(value = '', mask) {
if (value && typeof value === 'string') {
value = value.trim();

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

return value;
}

/*
`<TextField mask={...}>`
Expand All @@ -104,12 +126,30 @@ Style guide: components.masked-field.react
* field is blurred, it applies formatting to improve the readability
* of the value.
*/
export class Mask extends React.PureComponent {
class _Mask extends React.PureComponent {
static getDerivedStateFromProps(props, state) {
const fieldProps = React.Children.only(props.children).props;
const isControlled = fieldProps.value !== undefined;
if (isControlled) {
const { mask } = props;
if (unmask(fieldProps.value, mask) !== unmask(state.value, mask)) {
return {
value: maskValue(fieldProps.value || '', mask)
};
}
}
return null;
}

constructor(props) {
super(props);

const field = this.field();
const initialValue = field.props.value || field.props.defaultValue;
// console.log('initial value', initialValue, maskValue(initialValue, props.mask), props.mask)

this.state = {
value: this.maskedValue(this.initialValue())
value: maskValue(initialValue, props.mask)
};
}

Expand All @@ -129,28 +169,6 @@ export class Mask extends React.PureComponent {
return React.Children.only(this.props.children);
}

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

return value;
}

/**
* To avoid a jarring experience for screen readers, we only
* add/remove characters after the field has been blurred,
Expand All @@ -159,7 +177,7 @@ export class Mask extends React.PureComponent {
* @param {React.Element} field - Child TextField
*/
handleBlur(evt, field) {
const value = this.maskedValue(evt.target.value);
const value = maskValue(evt.target.value, this.props.mask);

// We only debounce the onBlur when we know for sure that
// this component will re-render (AKA when the value changes)
Expand Down Expand Up @@ -199,11 +217,6 @@ export class Mask extends React.PureComponent {
}
}

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

render() {
const field = this.field();

Expand All @@ -216,7 +229,7 @@ export class Mask extends React.PureComponent {
}
}

Mask.propTypes = {
_Mask.propTypes = {
/** Pass the input as the child */
children: PropTypes.node.isRequired,
mask: PropTypes.string.isRequired
Expand Down Expand Up @@ -246,4 +259,7 @@ export function unmask(value, mask) {
return value;
}

const Mask = polyfill(_Mask);

export { Mask };
export default Mask;
36 changes: 36 additions & 0 deletions packages/core/src/components/TextField/Mask.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,42 @@ describe('Mask', function() {
expect(input.prop('value')).toBe('1,234');
});

describe('Controlled component behavior', () => {
it('will not cause masking until blur when value prop still matches unmasked input', () => {
const { wrapper } = render({ mask: 'currency' }, { value: '1000' }, true);
const input = () => wrapper.find('input');

expect(input().prop('value')).toBe('1,000');
// Simulate user typing input and the component calling onChange, and that
// cascading back down to a new prop for the input.
input()
.props()
.onChange({ target: { value: '1,0000' } });
wrapper.setProps({
children: <input name="foo" type="text" value="10000" />
});
expect(input().prop('value')).toBe('1,0000');

input().simulate('blur', {
target: { value: '1,0000' },
persist: jest.fn()
});
expect(input().prop('value')).toBe('10,000');
});

it('will change the value of the input when value prop changes (beyond unmasked/masked differences)', () => {
const { wrapper } = render({ mask: 'currency' }, { value: '1000' }, true);
const input = () => wrapper.find('input');

expect(input().prop('value')).toBe('1,000');
// Make sure we can change the value
wrapper.setProps({
children: <input name="foo" type="text" value="2000" />
});
expect(input().prop('value')).toBe('2,000');
});
});

describe('Currency', () => {
it('accepts already masked value', () => {
const data = render({ mask: 'currency' }, { value: '1,234.50' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ exports[`TextField masks renders currency mask 1`] = `
>
$
</div>
<Mask
<_Mask
mask="currency"
>
<input
Expand All @@ -28,7 +28,7 @@ exports[`TextField masks renders currency mask 1`] = `
name="spec-field"
type="text"
/>
</Mask>
</_Mask>
</div>
</div>
`;
Loading

0 comments on commit 752e14f

Please sign in to comment.