UNPKG

@cimpress/react-components

Version:
410 lines (409 loc) 18.9 kB
var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import React, { Component } from 'react'; import { allCountries } from 'country-telephone-data'; import { parseNumber, formatNumber, AsYouType, isValidNumber } from 'libphonenumber-js'; import { components } from 'react-select'; import { css, cx } from '@emotion/css'; import { Select } from './Select'; import { SelectWrapper } from './SelectWrapper'; import { TextField } from './TextInput'; import emojiFlags from './CurrencySelector/isoUnicode'; import cvar from './theme/cvar'; const isEmptyObject = (obj) => Object.entries(obj).length === 0; const emojiStyle = css ` display: inline-block; width: 18px; > img { width: 100%; vertical-align: middle; } `; const overflowStyle = css({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }); const CustomPhoneSelect = (props) => { const { value, label, type } = props; const flag = emojiFlags[value.toUpperCase()] || ''; const outerStyle = type === 'option' ? undefined : overflowStyle; return (React.createElement("div", { className: outerStyle }, React.createElement("span", { className: emojiStyle, dangerouslySetInnerHTML: { __html: flag } }), "\u00A0 ", `${label}`)); }; const CustomOption = (_a) => { var { data } = _a, props = __rest(_a, ["data"]); return (React.createElement(components.Option, Object.assign({}, props), React.createElement(CustomPhoneSelect, Object.assign({}, data, props)))); }; const CustomSingleValue = (_a) => { var { data } = _a, props = __rest(_a, ["data"]); return (React.createElement(components.SingleValue, Object.assign({}, props), React.createElement(CustomPhoneSelect, Object.assign({}, data, props)))); }; /** * Builds a select option (dropdown entry) for a given country * @param {*} country * @returns select option for the country */ const buildCountrySelectOptions = (country) => { const { name, dialCode, iso2 } = country; let label = name; // Make USA and UK easier to find by typing if (iso2 === 'us') { label = `${label} (USA)`; } else if (iso2 === 'gb') { label = `${label} (UK)`; } if (dialCode) { label = `${label} (+${dialCode})`; } return { label, value: iso2, }; }; /** * Composes a structure that can be displayed in react-select * (or another selector that supports grouped options) * @param {*} allCountryOptionsList - select options for all the countries in one list * @param {*} allCountryOptionsHash - a lookup table of all the select options * @param {*} countryGroups - set of country code groups * @returns the group structure of select options to be displayed in the country selector */ const buildOptionGroupsResult = (allCountryOptionsList, allCountryOptionsHash, countryGroups) => { const resultCountryOptions = []; Object.keys(countryGroups).forEach(group => { if (countryGroups[group] === '*') { resultCountryOptions.push({ label: group, options: allCountryOptionsList, // use 'all countries' for a group with '*' filter }); } else if (Array.isArray(countryGroups[group])) { resultCountryOptions.push({ label: group, options: countryGroups[group].map((countryCode) => allCountryOptionsHash[countryCode.toLowerCase()]), }); } }); return resultCountryOptions; }; /** * Builds a list (or a grouped structure) of select options to display in the country selector * @param {*} countryGroups - optional set of country code groups * Example: * { * 'Frequent': ['us', 'gb'], * 'All': '*' * } * @returns the list/group structure of select options to be displayed in the country selector */ export const buildCountryList = (countryGroups) => { const allCountryOptionsList = []; const allCountryOptionsHash = {}; // Build the list of select options for all countries allCountries.forEach(country => { if (Boolean(country.dialCode) && Boolean(country.iso2)) { const countryOption = buildCountrySelectOptions(country); allCountryOptionsList.push(countryOption); allCountryOptionsHash[country.iso2] = countryOption; } }); // Prepare and return the result if (!countryGroups) { return allCountryOptionsList; } return buildOptionGroupsResult(allCountryOptionsList, allCountryOptionsHash, countryGroups); }; export const getCountryCallingCode = (countryCode) => { const { dialCode } = allCountries.find(({ iso2 }) => iso2 === countryCode) || {}; return dialCode ? `+${dialCode}` : ''; }; function parseInvalidPhoneNumber(invalidPhoneNumber = '') { // pick out phone and ext. const phoneParts = invalidPhoneNumber.split(/[A-Za-z]+/); let parsedPhone = {}; if (phoneParts.length === 1) { // Option 1 - we only have digits parsedPhone = { phone: invalidPhoneNumber, ext: '', }; } else if (phoneParts.length > 1) { // Option 2 - the phone number has both digits and alpha characters. // We can only do our best here. For now, assume the first segment of // digits is the phone number and the last segment of digits is an // extension. parsedPhone = { phone: phoneParts[0], ext: phoneParts[phoneParts.length - 1].replace(/\D/g, ''), }; } return parsedPhone; } // Determines the number of digits before the caret/cursor function getDigitsBeforeCaret(caretLocation, phone) { const characters = phone.split(''); let digitsBeforeCaret = 0; for (let i = 0; i < caretLocation; i++) { if (characters[i].match(/\d|\+/)) { digitsBeforeCaret++; } } return digitsBeforeCaret; } /* eslint-disable consistent-return */ function findMatchingCountrySelectOption(countrySelectOptions, countryCode, index = 0) { if (index >= countrySelectOptions.length) { return; } const option = countrySelectOptions[index]; // Check whether this is a plain option or an option describing a sub group of options if (option.value) { return option.value === countryCode ? option : findMatchingCountrySelectOption(countrySelectOptions, countryCode, index + 1); } if (option.options) { return (findMatchingCountrySelectOption(option.options, countryCode) || findMatchingCountrySelectOption(countrySelectOptions, countryCode, index + 1)); } } /* eslint-enable consistent-return */ const defaultCountryGroups = buildCountryList(); const phoneCss = css({ display: 'flex', }); const phoneCountryCss = css({ marginRight: cvar('spacing-4'), minWidth: '175px', }); const phoneNumberCss = css({ marginRight: cvar('spacing-4'), flexGrow: 2, minWidth: '275px', }); const phoneExtensionCss = css({ width: '15%', minWidth: '70px', }); export class Phone extends Component { constructor(props) { super(props); Object.defineProperty(this, "caretCorrection", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "wasDeletePressed", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "phoneNumberRef", { enumerable: true, configurable: true, writable: true, value: void 0 }); // Parse any initialValue we receive. const { country = '', phone = '', ext = '' } = this.parseInitialPhoneNumber(this.props.initialValue); this.state = { countryCode: country.toLowerCase(), minimumPhoneNumber: getCountryCallingCode(country.toLowerCase()) || '', phone: formatNumber(phone, country, 'INTERNATIONAL'), extension: ext, isFocusPhoneNumber: false, }; this.phoneNumberRef = React.createRef(); // this ref is used to focus on phone number field this.onChangeCountryCode = this.onChangeCountryCode.bind(this); this.onPhoneNumberChange = this.onPhoneNumberChange.bind(this); this.onExtensionChange = this.onExtensionChange.bind(this); this.onPhoneNumberKeyPress = this.onPhoneNumberKeyPress.bind(this); } // If the component is fed an intial value, the parent component may want to // treat the initial parsing of the phone number as a change event so it can // receive a properly formatted version of the number immediately. componentDidMount() { const { phone, extension } = this.state; this.props.triggerChangeOnMount && this.onChange(phone, extension); } // NOTE: This is not a 'controlled component' in React's eyes. It is up to us // to ensure that the cursor does not reset to the end of the phone text field // after each time we predictively format the number. componentDidUpdate(prevProps) { this.caretCorrection && this.caretCorrection(); delete this.caretCorrection; // When the passed in initial value is updated the state value should also be updated in order to render // proper value in the component if (this.props.initialValue !== prevProps.initialValue) { const { country = '', phone = '', ext = '' } = this.parseInitialPhoneNumber(this.props.initialValue); this.setState(Object.assign(Object.assign({}, (country && { countryCode: country.toLowerCase() })), { phone: formatNumber(phone, country, 'INTERNATIONAL'), extension: ext })); } // Focus on the phone number field after country code has changed // Doing it here takes care of the situations where phone number field is disabled initially if (this.state.isFocusPhoneNumber) { this.phoneNumberRef.current && this.phoneNumberRef.current.focus(); this.setState({ isFocusPhoneNumber: false }); } } onChangeCountryCode(event) { const countryCode = event === null || event === void 0 ? void 0 : event.value; if (!countryCode) { // Clearing the country code is equivalent to clearing the entire // component of its value. You can't have a telephone number without the // country code. this.setState({ countryCode: '', phone: '', extension: '', }); this.props.onChange(undefined); } else { const countryCallingCode = getCountryCallingCode(countryCode); this.setState({ countryCode, minimumPhoneNumber: countryCallingCode, phone: new AsYouType().input(countryCallingCode), extension: '', isFocusPhoneNumber: true, // set the focus on the phone number field }); this.props.onChange({ number: countryCallingCode, isValid: false }); } } // Remember whether this key press was delete (i.e. remove characters from // from left to right). This will prove to be useful when determining the new // position of the cursor/caret after the change has been processed. onPhoneNumberKeyPress(event) { this.wasDeletePressed = event.key === 'Delete'; } // Callback for when the phone input field changes. Because the phone number // value is computed based on what the user enters, React will overwrite the // value each time and place the input caret/cursor at the end. This callback // manually resets the caret/cursor back to where the user previously had it. onPhoneNumberChange(event) { var _a, _b, _c, _d; let phone = (_b = (_a = event === null || event === void 0 ? void 0 : event.target) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : ''; const { minimumPhoneNumber } = this.state; let newCaretLocation; // If the number doesn't start with the proper country code or if the // formatter results in empty string, reset the number because our user // is being silly. const shouldResetNumber = !phone.replace(/\s+/g, '').startsWith(minimumPhoneNumber) || !new AsYouType().input(phone); if (shouldResetNumber) { phone = new AsYouType().input(minimumPhoneNumber); newCaretLocation = phone.length; } else { const caretLocation = (_d = (_c = event === null || event === void 0 ? void 0 : event.target) === null || _c === void 0 ? void 0 : _c.selectionStart) !== null && _d !== void 0 ? _d : 0; const digitsBeforeCaret = getDigitsBeforeCaret(caretLocation, phone); // Update the phone number's formatting and then determine the new // caret location. phone = new AsYouType().input(phone); newCaretLocation = this.getNewCaretLocation(digitsBeforeCaret, phone); } // Configure a caret correction 'callback' for running after the component // has been rerendered. const { target } = event; this.caretCorrection = () => { target.setSelectionRange(newCaretLocation, newCaretLocation); }; this.onChange(phone, this.state.extension); } onExtensionChange(event) { var _a, _b; let extension = (_b = (_a = event === null || event === void 0 ? void 0 : event.target) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : ''; // remove any non numeric characters from the extension extension = extension.replace(/\D/g, ''); this.onChange(this.state.phone, extension); } onChange(phone, extension) { // build an E.123 formatted number with the extension const formattedNumber = extension ? `${phone} ext. ${extension}` : phone; this.setState({ phone, extension }); let payload; if (formattedNumber) { payload = { number: formattedNumber, isValid: isValidNumber(formattedNumber), }; } this.props.onChange(payload); } // Determines the new location of the caret/cursor by ensuring // the proper number of digits still preceed it. getNewCaretLocation(digitsBeforeCaret, phone) { let digitsSeen = 0; let caretPosition = 0; const characters = phone.split(''); for (let i = 0; i < characters.length; i++) { if (characters[i].match(/\d|\+/)) { digitsSeen++; if (digitsSeen === digitsBeforeCaret) { caretPosition = i + 1; // place the caret immediately after break; } } } // If the user had pressed the Delete key (i.e. removing digits from // left to right), we should place the cursor after any trailing white // space to make the delete experience feel more natural. if (this.wasDeletePressed && characters[caretPosition] === ' ') { caretPosition++; } return caretPosition; } parseInitialPhoneNumber(number) { if (!number) { return {}; } // Assume a fallback country code of US in case the number isn't in // international format...because 'Murica? The output of parseNumber will be // an empty object if it fails due to an invalid phone number format. let parsedPhone = parseNumber(number, { defaultCountry: 'US', extended: false, }); if (number && isEmptyObject(parsedPhone)) { parsedPhone = parseInvalidPhoneNumber(this.props.initialValue); } return parsedPhone; } render() { const { disableBsStyling, disableStatus, className, countrySelectLabel, countrySelectClassName, countrySelectMenuStyle, phoneTextInputLabel, phoneTextInputClassName, extensionTextInputLabel, extensionTextInputClassName, selectedSelect, countryGroups, style, } = this.props; const { countryCode, phone, extension } = this.state; const countrySelectOptions = countryGroups ? buildCountryList(countryGroups) : defaultCountryGroups; const countrySelectValue = findMatchingCountrySelectOption(countrySelectOptions, countryCode); // This lets disableStatus override disableBsStyling while still allowing backwards compatability const status = disableStatus === undefined ? !disableBsStyling : !disableStatus; return (React.createElement("div", { className: cx('crc-phone', phoneCss, className), style: style, "data-testid": this.props['data-testid'] }, React.createElement(SelectWrapper // This is the v2+ way of handling custom styling for various parts of the select , { // This is the v2+ way of handling custom styling for various parts of the select components: { Option: CustomOption, SingleValue: CustomSingleValue, }, name: "country", selectedSelect: selectedSelect || Select, value: countrySelectValue, onChange: this.onChangeCountryCode, options: countrySelectOptions, label: countrySelectLabel || 'Country Code...', className: cx(phoneCountryCss, countrySelectClassName), menuContainerStyle: countrySelectMenuStyle || {} }), React.createElement(TextField, { name: "phone", label: phoneTextInputLabel || 'Telephone (Allowed characters: 0-9)', value: phone, onChange: this.onPhoneNumberChange, onFocus: e => e.target.setSelectionRange(phone.length, phone.length), onKeyDown: this.onPhoneNumberKeyPress, type: "tel", className: cx(phoneNumberCss, phoneTextInputClassName), disabled: !countryCode, status: status && countryCode && !isValidNumber(phone) ? 'error' : undefined, ref: this.phoneNumberRef }), React.createElement(TextField, { name: "extension", label: extensionTextInputLabel || 'Ext.', value: extension, onChange: this.onExtensionChange, type: "tel", className: cx(phoneExtensionCss, extensionTextInputClassName), disabled: !countryCode }))); } } //# sourceMappingURL=Phone.js.map