UNPKG

@cimpress/react-components

Version:
442 lines (441 loc) 21.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; 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; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Phone = exports.getCountryCallingCode = exports.buildCountryList = void 0; const react_1 = __importStar(require("react")); const country_telephone_data_1 = require("country-telephone-data"); const libphonenumber_js_1 = require("libphonenumber-js"); const react_select_1 = require("react-select"); const css_1 = require("@emotion/css"); const Select_1 = require("./Select"); const SelectWrapper_1 = require("./SelectWrapper"); const TextInput_1 = require("./TextInput"); const isoUnicode_1 = __importDefault(require("./CurrencySelector/isoUnicode")); const cvar_1 = __importDefault(require("./theme/cvar")); const isEmptyObject = (obj) => Object.entries(obj).length === 0; const emojiStyle = (0, css_1.css) ` display: inline-block; width: 18px; > img { width: 100%; vertical-align: middle; } `; const overflowStyle = (0, css_1.css)({ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }); const CustomPhoneSelect = (props) => { const { value, label, type } = props; const flag = isoUnicode_1.default[value.toUpperCase()] || ''; const outerStyle = type === 'option' ? undefined : overflowStyle; return (react_1.default.createElement("div", { className: outerStyle }, react_1.default.createElement("span", { className: emojiStyle, dangerouslySetInnerHTML: { __html: flag } }), "\u00A0 ", `${label}`)); }; const CustomOption = (_a) => { var { data } = _a, props = __rest(_a, ["data"]); return (react_1.default.createElement(react_select_1.components.Option, Object.assign({}, props), react_1.default.createElement(CustomPhoneSelect, Object.assign({}, data, props)))); }; const CustomSingleValue = (_a) => { var { data } = _a, props = __rest(_a, ["data"]); return (react_1.default.createElement(react_select_1.components.SingleValue, Object.assign({}, props), react_1.default.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 */ const buildCountryList = (countryGroups) => { const allCountryOptionsList = []; const allCountryOptionsHash = {}; // Build the list of select options for all countries country_telephone_data_1.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); }; exports.buildCountryList = buildCountryList; const getCountryCallingCode = (countryCode) => { const { dialCode } = country_telephone_data_1.allCountries.find(({ iso2 }) => iso2 === countryCode) || {}; return dialCode ? `+${dialCode}` : ''; }; exports.getCountryCallingCode = getCountryCallingCode; 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 = (0, exports.buildCountryList)(); const phoneCss = (0, css_1.css)({ display: 'flex', }); const phoneCountryCss = (0, css_1.css)({ marginRight: (0, cvar_1.default)('spacing-4'), minWidth: '175px', }); const phoneNumberCss = (0, css_1.css)({ marginRight: (0, cvar_1.default)('spacing-4'), flexGrow: 2, minWidth: '275px', }); const phoneExtensionCss = (0, css_1.css)({ width: '15%', minWidth: '70px', }); class Phone extends react_1.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: (0, exports.getCountryCallingCode)(country.toLowerCase()) || '', phone: (0, libphonenumber_js_1.formatNumber)(phone, country, 'INTERNATIONAL'), extension: ext, isFocusPhoneNumber: false, }; this.phoneNumberRef = react_1.default.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: (0, libphonenumber_js_1.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 = (0, exports.getCountryCallingCode)(countryCode); this.setState({ countryCode, minimumPhoneNumber: countryCallingCode, phone: new libphonenumber_js_1.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 libphonenumber_js_1.AsYouType().input(phone); if (shouldResetNumber) { phone = new libphonenumber_js_1.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 libphonenumber_js_1.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: (0, libphonenumber_js_1.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 = (0, libphonenumber_js_1.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 ? (0, exports.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_1.default.createElement("div", { className: (0, css_1.cx)('crc-phone', phoneCss, className), style: style, "data-testid": this.props['data-testid'] }, react_1.default.createElement(SelectWrapper_1.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_1.Select, value: countrySelectValue, onChange: this.onChangeCountryCode, options: countrySelectOptions, label: countrySelectLabel || 'Country Code...', className: (0, css_1.cx)(phoneCountryCss, countrySelectClassName), menuContainerStyle: countrySelectMenuStyle || {} }), react_1.default.createElement(TextInput_1.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: (0, css_1.cx)(phoneNumberCss, phoneTextInputClassName), disabled: !countryCode, status: status && countryCode && !(0, libphonenumber_js_1.isValidNumber)(phone) ? 'error' : undefined, ref: this.phoneNumberRef }), react_1.default.createElement(TextInput_1.TextField, { name: "extension", label: extensionTextInputLabel || 'Ext.', value: extension, onChange: this.onExtensionChange, type: "tel", className: (0, css_1.cx)(phoneExtensionCss, extensionTextInputClassName), disabled: !countryCode }))); } } exports.Phone = Phone; //# sourceMappingURL=Phone.js.map