@cimpress/react-components
Version:
React components to support the MCP styleguide
442 lines (441 loc) • 21.1 kB
JavaScript
"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