UNPKG

material-ui-phone-number-2

Version:

A material-ui react component to format phone numbers. Based on Material UI Phone Number, which ceased to be maintained.

896 lines (756 loc) 26.8 kB
import React from 'react' import PropTypes from 'prop-types' import Flags from 'country-flag-icons/react/3x2' import { styled, TextField, InputAdornment, IconButton, Menu, Divider, NativeSelect } from '@mui/material' import countryData from '../country_data' import Item from './Item' import debounce from 'lodash.debounce' import memoize from 'lodash.memoize' const IconButtonStyled = styled(IconButton)({ width: 'min-content', minWidth: 30, padding: 0, height: 30, '& svg': { width: 'auto', height: '1em', }, }) const FlagIconStyled = styled(Item)({ width: 16, marginRight: 8, }) const NativeSelectStyled = styled(NativeSelect)({ width: 30, height: 30, padding: 8, }) class MaterialUiPhoneNumber extends React.Component { flags = {} guessSelectedCountry = memoize((inputNumber, onlyCountries, defaultCountry) => { const secondBestGuess = onlyCountries.find((item) => item.iso2 === defaultCountry) || {} if (inputNumber.trim() === '') return secondBestGuess const bestGuess = onlyCountries.reduce( (selectedCountry, country) => { if (inputNumber.startsWith(country.dialCode)) { if (country.dialCode.length > selectedCountry.dialCode.length) { return country } if ( country.dialCode.length === selectedCountry.dialCode.length && country.priority < selectedCountry.priority ) { return country } } return selectedCountry }, { dialCode: '', priority: 10001 } ) if (!bestGuess.name) return secondBestGuess return bestGuess }) constructor(props) { super(props) let filteredCountries = countryData.allCountries if (props.disableAreaCodes) filteredCountries = this.deleteAreaCodes(filteredCountries) if (props.regions) filteredCountries = this.filterRegions(props.regions, filteredCountries) const onlyCountries = this.excludeCountries( this.getOnlyCountries(props.onlyCountries, filteredCountries), props.excludeCountries, ) const preferredCountries = filteredCountries.filter((country) => props.preferredCountries.some((preferredCountry) => preferredCountry === country.iso2), ) const inputNumber = props.value || '' let countryGuess if (inputNumber.length > 1) { // Country detect by value field countryGuess = this.guessSelectedCountry( inputNumber.replace(/\D/g, '').substring(0, 6), onlyCountries, props.defaultCountry, ) || 0 } else if (props.defaultCountry) { // Default country countryGuess = onlyCountries.find((item) => item.iso2 === props.defaultCountry) || 0 } else { // Empty params countryGuess = 0 } const countryGuessIndex = countryData.allCountries.findIndex((item) => item === countryGuess) const dialCode = inputNumber.length < 2 && countryGuess && !inputNumber.replace(/\D/g, '').startsWith(countryGuess.dialCode) ? countryGuess.dialCode : '' const formattedNumber = inputNumber === '' && countryGuess === 0 ? '' : this.formatNumber( (props.disableCountryCode ? '' : dialCode) + inputNumber.replace(/\D/g, ''), this.getInputMask(countryGuess) || (countryGuess.name ? countryGuess.format : undefined), ) this.state = { formattedNumber, placeholder: props.placeholder, onlyCountries, preferredCountries, defaultCountry: props.defaultCountry, selectedCountry: countryGuess, highlightCountryIndex: countryGuessIndex, queryString: '', freezeSelection: false, debouncedQueryStingSearcher: debounce(this.searchCountry, 100), anchorEl: null, } } componentDidMount() { if (document.addEventListener) { document.addEventListener('keydown', this.handleKeydown) } } componentDidUpdate({ value: prevValue }) { const { defaultCountry: prevDefaultCountry, formattedNumber } = this.state const { defaultCountry, value } = this.props if (defaultCountry && defaultCountry !== prevDefaultCountry) { this.updateDefaultCountry(defaultCountry) } if (typeof value === 'string' && value !== prevValue && value !== formattedNumber) { this.updateFormattedNumber(value) } } componentWillUnmount() { if (document.removeEventListener) { document.removeEventListener('keydown', this.handleKeydown) } } getProbableCandidate = memoize((queryString) => { if (!queryString || queryString.length === 0) { return null } const { onlyCountries } = this.state // don't include the preferred countries in search const probableCountries = onlyCountries.filter( (country) => country.name.toLowerCase().startsWith(queryString.toLowerCase()), ) return probableCountries[0] }) getOnlyCountries = (onlyCountriesArray, filteredCountries) => { if (onlyCountriesArray.length === 0) return filteredCountries return filteredCountries.filter((country) => onlyCountriesArray.some((element) => element === country.iso2)) } excludeCountries = (selectedCountries, excludedCountries) => { if (excludedCountries.length === 0) { return selectedCountries } return selectedCountries.filter((selCountry) => !excludedCountries.includes(selCountry.iso2)) } filterRegions = (regions, filteredCountries) => { if (typeof regions === 'string') { const region = regions return filteredCountries.filter((country) => country.regions.some((element) => element === region)) } return filteredCountries.filter((country) => { const matches = regions.map((region) => country.regions.some((element) => element === region)) return matches.some((el) => el) }) } // Countries array methods deleteAreaCodes = (filteredCountries) => filteredCountries.filter((country) => country.isAreaCode !== true) // Hooks for updated props updateDefaultCountry = (country) => { const { onlyCountries } = this.state const { disableCountryCode } = this.props const newSelectedCountry = onlyCountries.find((item) => item.iso2 === country) this.setState({ defaultCountry: country, selectedCountry: newSelectedCountry, formattedNumber: disableCountryCode ? '' : `+${newSelectedCountry.dialCode}`, }) } // View methods scrollTo = (country) => { if (!country) { return } const container = this.dropdownContainerRef if (!container || !document.body) { return } container.scrollTop = country.offsetTop } formatNumber = (text, patternArg) => { const { disableCountryCode, enableLongNumbers, autoFormat } = this.props let pattern if (disableCountryCode && patternArg) { pattern = patternArg.split(' ') pattern.shift() pattern = pattern.join(' ') } else { pattern = patternArg } if (!text || text.length === 0) { return disableCountryCode ? '' : '+' } // for all strings with length less than 3, just return it (1, 2 etc.) // also return the same text if the selected country has no fixed format if ((text && text.length < 2) || !pattern || !autoFormat) { return disableCountryCode ? text : `+${text}` } const formattedObject = pattern.split("").reduce( (acc, character) => { if (acc.remainingText.length === 0) { return acc } if (character !== '.') { return { formattedText: acc.formattedText + character, remainingText: acc.remainingText, } } const [ head, ...tail ] = acc.remainingText return { formattedText: acc.formattedText + head, remainingText: tail, } }, { formattedText: '', remainingText: text.split(''), }, ) let formattedNumber if (enableLongNumbers) { formattedNumber = formattedObject.formattedText + formattedObject.remainingText.join('') } else { formattedNumber = formattedObject.formattedText } // Always close brackets if (formattedNumber.includes('(') && !formattedNumber.includes(')')) formattedNumber += ')' return formattedNumber } // Put the cursor to the end of the input (usually after a focus event) cursorToEnd = () => { const { isModernBrowser } = this.props const input = this.inputRef if (input) { input.focus() if (isModernBrowser) { const len = input.value.length input.setSelectionRange(len, len) } } } getElement = (index) => this.flags[`flag_no_${index}`] // return country data from state getCountryData = () => { const { selectedCountry } = this.state if (!selectedCountry) return {} return { name: selectedCountry.name || '', dialCode: selectedCountry.dialCode || '', countryCode: selectedCountry.iso2 || '', } } handleInput = (e) => { let { selectedCountry: newSelectedCountry, freezeSelection } = this.state const { selectedCountry, formattedNumber: oldFormattedText, onlyCountries, defaultCountry } = this.state const { disableCountryCode, countryCodeEditable, isModernBrowser, onChange } = this.props let formattedNumber = disableCountryCode ? '' : '+' if (!countryCodeEditable) { const updatedInput = `+${newSelectedCountry.dialCode}` if (e.target.value.length < updatedInput.length) { return } } // Does not exceed 15 digit phone number limit if (e.target.value.replace(/\D/g, '').length > 15) { return } // if the input is the same as before, must be some special key like enter etc. if (e.target.value === oldFormattedText) { return } // ie hack if (e.preventDefault) { e.preventDefault() } else { e.returnValue = false } if (e.target.value.length > 0) { // before entering the number in new format, lets check if the dial code now matches some other country const inputNumber = e.target.value.replace(/\D/g, '') // we don't need to send the whole number to guess the country... only the first 6 characters are enough // the guess country function can then use memoization much more effectively since the set of input it // gets has drastically reduced if (!freezeSelection || selectedCountry.dialCode.length > inputNumber.length) { newSelectedCountry = this.guessSelectedCountry(inputNumber.substring(0, 6), onlyCountries, defaultCountry) freezeSelection = false } // let us remove all non numerals from the input formattedNumber = this.formatNumber(inputNumber, this.getInputMask(newSelectedCountry)) } let caretPosition = e.target.selectionStart const diff = formattedNumber.length - oldFormattedText.length this.setState( { formattedNumber, freezeSelection, selectedCountry: newSelectedCountry.dialCode ? newSelectedCountry : selectedCountry, }, () => { if (isModernBrowser) { if (diff > 0) { caretPosition -= diff } const lastChar = formattedNumber.charAt(formattedNumber.length - 1) if (lastChar === ')') { this.inputRef.setSelectionRange(formattedNumber.length - 1, formattedNumber.length - 1) } else if (caretPosition > 0 && oldFormattedText.length >= formattedNumber.length) { this.inputRef.setSelectionRange(caretPosition, caretPosition) } } if (onChange) { onChange(formattedNumber, this.getCountryData()) } }, ) } getInputMask = (country) => { const mask = this.props.masks[country.iso2] if (mask) return mask return country.format } handleRefInput = (ref) => { const { inputRef, InputProps } = this.props this.inputRef = ref let refProp if (inputRef) { refProp = inputRef } else if (InputProps && InputProps.ref) { refProp = InputProps.ref } if (refProp) { if (typeof refProp === 'function') { refProp(ref) } else { refProp.current = ref } } } handleInputClick = (e) => { const { onClick } = this.props if (onClick) { onClick(e, this.getCountryData()) } } handleFlagItemClick = (country) => { const { formattedNumber, selectedCountry, onlyCountries } = this.state const { onChange } = this.props const currentSelectedCountry = selectedCountry const nextSelectedCountry = typeof country === "string" ? onlyCountries.find((countryItem) => countryItem.iso2 === country) : onlyCountries.find((item) => item === country) const unformattedNumber = formattedNumber.replace(' ', '').replace('(', '').replace(')', '').replace('-', '') const newNumber = unformattedNumber.length > 1 ? unformattedNumber.replace(currentSelectedCountry.dialCode, nextSelectedCountry.dialCode) : nextSelectedCountry.dialCode const newFormattedNumber = this.formatNumber(newNumber.replace(/\D/g, ''), this.getInputMask(nextSelectedCountry)) this.setState( { anchorEl: null, selectedCountry: nextSelectedCountry, freezeSelection: true, formattedNumber: newFormattedNumber, }, () => { this.cursorToEnd() if (onChange) { onChange(newFormattedNumber, this.getCountryData()) } }, ) } handleInputFocus = (e) => { const { selectedCountry } = this.state const { disableCountryCode, onFocus } = this.props // if the input is blank, insert dial code of the selected country if (this.inputRef) { if (this.inputRef.value === '+' && selectedCountry && !disableCountryCode) { this.setState( { formattedNumber: `+${selectedCountry.dialCode}`, }, () => setTimeout(this.cursorToEnd, 10), ) } } this.setState({ placeholder: '' }) if (onFocus) { onFocus(e, this.getCountryData()) } setTimeout(this.cursorToEnd, 10) } handleInputBlur = (e) => { const { placeholder, onBlur } = this.props if (!e.target.value) { this.setState({ placeholder }) } if (onBlur) { onBlur(e, this.getCountryData()) } } getHighlightCountryIndex = (direction) => { const { highlightCountryIndex: oldHighlightCountryIndex, onlyCountries, preferredCountries } = this.state // had to write own function because underscore does not have findIndex. lodash has it const highlightCountryIndex = oldHighlightCountryIndex + direction if (highlightCountryIndex < 0 || highlightCountryIndex >= onlyCountries.length + preferredCountries.length) { return highlightCountryIndex - direction } return highlightCountryIndex } searchCountry = () => { const { queryString, onlyCountries, preferredCountries } = this.state const probableCandidate = this.getProbableCandidate(queryString) || onlyCountries[0] const probableCandidateIndex = onlyCountries.findIndex((item) => item === probableCandidate) + preferredCountries.length this.scrollTo(this.getElement(probableCandidateIndex), true) this.setState({ queryString: '', highlightCountryIndex: probableCandidateIndex, }) } handleKeydown = (e) => { const { anchorEl, highlightCountryIndex, preferredCountries, onlyCountries, queryString, debouncedQueryStingSearcher, } = this.state const { keys, disabled } = this.props if (!anchorEl || disabled) return // ie hack if (e.preventDefault) { e.preventDefault() } else { e.returnValue = false } const moveHighlight = (direction) => { this.setState( { highlightCountryIndex: this.getHighlightCountryIndex(direction), }, () => { this.scrollTo(this.getElement(highlightCountryIndex + preferredCountries.length), true) }, ) } switch (e.which) { case keys.DOWN: moveHighlight(1) break case keys.UP: moveHighlight(-1) break case keys.ENTER: this.handleFlagItemClick(onlyCountries[highlightCountryIndex], e) break case keys.ESC: this.setState( { anchorEl: null, }, this.cursorToEnd, ) break default: if ((e.which >= keys.A && e.which <= keys.Z) || e.which === keys.SPACE) { this.setState( { queryString: queryString + String.fromCharCode(e.which), }, debouncedQueryStingSearcher, ) } } } handleInputKeyDown = (e) => { const { keys, onEnterKeyPress, onKeyDown } = this.props if (e.which === keys.ENTER && onEnterKeyPress) { onEnterKeyPress(e) } if (onKeyDown) { onKeyDown(e) } } checkIfValid = () => { const { formattedNumber } = this.state const { isValid } = this.props return isValid(formattedNumber.replace(/\D/g, '')) } updateFormattedNumber = (number) => { const { onlyCountries, defaultCountry } = this.state const { disableCountryCode } = this.props let countryGuess let inputNumber = number let formattedNumber // if inputNumber does not start with '+', then use default country's dialing prefix, // otherwise use logic for finding country based on country prefix. if (!inputNumber.startsWith('+')) { countryGuess = onlyCountries.find((item) => item.iso2 === defaultCountry) const dialCode = countryGuess && !inputNumber.replace(/\D/g, '').startsWith(countryGuess.dialCode) ? countryGuess.dialCode : '' formattedNumber = this.formatNumber( (disableCountryCode ? '' : dialCode) + inputNumber.replace(/\D/g, ''), countryGuess ? this.getInputMask(countryGuess) : undefined, ) } else { inputNumber = inputNumber.replace(/\D/g, '') countryGuess = this.guessSelectedCountry(inputNumber.substring(0, 6), onlyCountries, defaultCountry) formattedNumber = this.formatNumber(inputNumber, this.getInputMask(countryGuess)) } this.setState({ selectedCountry: countryGuess, formattedNumber }) } getDropdownProps = () => { const { selectedCountry, anchorEl, preferredCountries, onlyCountries } = this.state const { dropdownClass, localization, disableDropdown, native } = this.props onlyCountries.sort((a, b) => { const localizedA = localization[a.name] || a.name const localizedB = localization[b.name] || b.name return localizedA.localeCompare(localizedB) }) const isSelected = (country) => Boolean(selectedCountry && selectedCountry.dialCode === country.dialCode) const FlagComponent = Flags[selectedCountry.iso2.toUpperCase()] return disableDropdown ? {} : { startAdornment: ( <InputAdornment sx={{ position: 'relative' }} position="start"> {native ? ( <> <NativeSelectStyled id="country-menu" open={Boolean(anchorEl)} onClose={() => this.setState({ anchorEl: null })} onChange={(e) => this.handleFlagItemClick(e.target.value)} IconComponent={Boolean(FlagComponent) && FlagComponent} disableUnderline > {!!preferredCountries.length && preferredCountries.map((country, index) => ( <Item key={`preferred_${country.iso2}_${index}`} itemRef={(node) => { this.flags[`flag_no_${index}`] = node }} name={country.name} iso2={country.iso2} dialCode={country.dialCode} localization={localization && localization[country.name]} native /> ))} {onlyCountries.map((country, index) => ( <Item key={`preferred_${country.iso2}_${index}`} itemRef={(node) => { this.flags[`flag_no_${index}`] = node }} name={country.name} iso2={country.iso2} dialCode={country.dialCode} localization={localization && localization[country.name]} native /> ))} </NativeSelectStyled> </> ) : ( <> <IconButtonStyled aria-owns={anchorEl ? 'country-menu' : null} aria-label="Select country" onClick={(e) => this.setState({ anchorEl: e.currentTarget })} aria-haspopup > {Boolean(FlagComponent) && <FlagComponent className="margin" />} </IconButtonStyled> <Menu className={dropdownClass} id="country-menu" anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => this.setState({ anchorEl: null })} > {!!preferredCountries.length && preferredCountries.map((country, index) => ( <FlagIconStyled key={`preferred_${country.iso2}_${index}`} itemRef={(node) => { this.flags[`flag_no_${index}`] = node }} selected={isSelected(country)} onClick={() => this.handleFlagItemClick(country)} name={country.name} iso2={country.iso2} dialCode={country.dialCode} localization={localization && localization[country.name]} /> ))} {!!preferredCountries.length && <Divider />} {onlyCountries.map((country, index) => ( <FlagIconStyled key={`preferred_${country.iso2}_${index}`} itemRef={(node) => { this.flags[`flag_no_${index}`] = node }} selected={isSelected(country)} onClick={() => this.handleFlagItemClick(country)} name={country.name} iso2={country.iso2} dialCode={country.dialCode} localization={localization && localization[country.name]} /> ))} </Menu> </> )} </InputAdornment> ), } } render() { const { formattedNumber, placeholder: statePlaceholder } = this.state const { // start placeholder props native, defaultCountry, excludeCountries, onlyCountries, preferredCountries, dropdownClass, autoFormat, disableAreaCodes, isValid, disableCountryCode, disableDropdown, enableLongNumbers, countryCodeEditable, onEnterKeyPress, isModernBrowser, keys, localization, placeholder, regions, onChange, value, // end placeholder props inputClass, error, InputProps, ...restProps } = this.props const dropdownProps = this.getDropdownProps() return ( <TextField placeholder={statePlaceholder} value={formattedNumber} className={inputClass} inputRef={this.handleRefInput} error={error || !this.checkIfValid()} onChange={this.handleInput} onClick={this.handleInputClick} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} onKeyDown={this.handleInputKeyDown} type="tel" InputProps={{ ...dropdownProps, ...InputProps, }} {...restProps} /> ) } } MaterialUiPhoneNumber.propTypes = { excludeCountries: PropTypes.arrayOf(PropTypes.string), onlyCountries: PropTypes.arrayOf(PropTypes.string), preferredCountries: PropTypes.arrayOf(PropTypes.string), defaultCountry: PropTypes.string, masks: PropTypes.object, value: PropTypes.string, placeholder: PropTypes.string, disabled: PropTypes.bool, error: PropTypes.bool, variant: PropTypes.string, native: PropTypes.bool, inputClass: PropTypes.string, dropdownClass: PropTypes.string, InputProps: PropTypes.object, inputProps: PropTypes.object, inputRef: PropTypes.func, autoFormat: PropTypes.bool, disableAreaCodes: PropTypes.bool, disableCountryCode: PropTypes.bool, disableDropdown: PropTypes.bool, enableLongNumbers: PropTypes.bool, countryCodeEditable: PropTypes.bool, regions: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), localization: PropTypes.object, onChange: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, onClick: PropTypes.func, onKeyDown: PropTypes.func, isValid: PropTypes.func, isModernBrowser: PropTypes.func, onEnterKeyPress: PropTypes.func, keys: PropTypes.object, } MaterialUiPhoneNumber.defaultProps = { excludeCountries: [], onlyCountries: [], preferredCountries: [], defaultCountry: '', masks: {}, placeholder: '+1 (702) 123-4567', disabled: false, error: false, variant: 'standard', native: false, inputClass: '', dropdownClass: '', autoFormat: true, disableAreaCodes: false, isValid: (inputNumber) => countryData.allCountries.some( (country) => inputNumber.startsWith(country.dialCode) || country.dialCode.startsWith(inputNumber), ), disableCountryCode: false, disableDropdown: false, enableLongNumbers: false, countryCodeEditable: true, regions: '', localization: {}, onEnterKeyPress: () => {}, onChange: () => {}, isModernBrowser: () => (document.createElement ? Boolean(document.createElement('input').setSelectionRange) : false), keys: { UP: 38, DOWN: 40, RIGHT: 39, LEFT: 37, ENTER: 13, ESC: 27, PLUS: 43, A: 65, Z: 90, SPACE: 32, }, } MaterialUiPhoneNumber.displayName = 'MuiPhoneNumber' export default MaterialUiPhoneNumber