/* eslint-disable no-useless-escape */ import { Children, PureComponent, cloneElement, isValidElement } from 'react'; import type * as React from 'react'; import classNames from 'classnames'; import { maskValue, unmaskValue } from './maskHelpers'; // TODO: Remove `maskValue` and `unmaskValue` exports with next major release (v3.x.x) export { maskValue, unmaskValue }; /** * Note: Chrome appends a /v modifier to regular expressions, which enables all unicode character set features. As a result, * - and { } characters must be escaped. */ const maskPattern = { phone: '[0-9\\-]*', ssn: '[0-9\\-*]*', zip: '[0-9\\-]*', currency: '[0-9.,\\-]*', }; const maskOverlayContent = { currency: '$', }; export type MaskMask = 'currency' | 'phone' | 'ssn' | 'zip'; export interface MaskProps { /** * Must contain a `TextField` component */ children: React.ReactNode; /** * The type of mask */ mask?: MaskMask; } export class Mask extends PureComponent { constructor(props: MaskProps) { super(props); const field = this.field(); const initialValue = field.props.value || field.props.defaultValue; this.state = { value: maskValue(initialValue, props.mask), }; } componentDidUpdate(prevProps: MaskProps): void { if (this.debouncedOnBlurEvent) { this.field().props.onBlur(this.debouncedOnBlurEvent); this.debouncedOnBlurEvent = null; } const fieldProps = this.field().props; const prevField = Children.only(prevProps.children); const prevFieldProps = isValidElement(prevField) ? prevField.props : {}; const isControlled = fieldProps.value !== undefined; if (isControlled && prevFieldProps.value !== fieldProps.value) { const { mask } = this.props; // For controlled components, the value prop should ideally be changed by // the controlling component once we've called onChange with our updates. // If the change was triggered this way through user input, then the prop // given should match our internal state when unmasked. If what we're // given and what we have locally don't match, that means the controlling // component has made its own unrelated change, so we should update our // state and mask this new value. if (unmaskValue(fieldProps.value, mask) !== unmaskValue(this.state.value, mask)) { const value = maskValue(fieldProps.value || '', mask); this.setState({ value }); } } } debouncedOnBlurEvent: any; /** * Get the child text field. Called as a method so that * updates to the field cause the mask to re-render * @returns {React.ReactElement} Child TextField */ field(): React.ReactElement { return Children.only(this.props.children as React.ReactElement); } /** * 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 * @param {React.Element} field - Child TextField */ handleBlur(evt: React.ChangeEvent, field: React.ReactElement): void { 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) // and when an onBlur callback is present const debounce = value !== this.state.value && typeof 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 field.props.onBlur === 'function') { // If we didn't debounce the onBlur event, then we need to // call the onBlur callback from here field.props.onBlur(evt); } } /** * @param {Object} evt * @param {React.Element} field - Child TextField */ handleChange(evt: React.ChangeEvent, field: React.ReactElement): void { this.setState({ value: evt.target.value }); if (typeof field.props.onChange === 'function') { field.props.onChange(evt); } } render() { const { mask } = this.props; const field = this.field(); const modifiedTextField = cloneElement(field, { defaultValue: undefined, fieldClassName: classNames(field.props.fieldClassName, `ds-c-field--${mask}`), onBlur: (evt) => this.handleBlur(evt, field), onChange: (evt) => this.handleChange(evt, field), value: this.state.value, type: 'text', inputMode: 'numeric', pattern: maskPattern[this.props.mask], }); // UI overlayed on top of a field to support certain masks const maskOverlay = maskOverlayContent[mask] ? (
{maskOverlayContent[mask]}
) : null; return (
{maskOverlay} {modifiedTextField}
); } } export default Mask;