frontend/components/PhoneInput/index.tsx (view raw)
1import React, {useState} from 'react';
2import {PhoneNumberUtil} from 'google-libphonenumber';
3import {
4 InputAdornment,
5 MenuItem,
6 MenuProps,
7 Select,
8 TextField,
9 TextFieldProps,
10 Typography,
11} from '@mui/material';
12import {
13 CountryIso2,
14 defaultCountries,
15 FlagImage,
16 parseCountry,
17 usePhoneInput,
18} from 'react-international-phone';
19import 'react-international-phone/style.css';
20
21interface Props {
22 value: string;
23 required?: boolean;
24 onChange: ({
25 phone,
26 country,
27 }: {
28 phone: string;
29 country: CountryIso2 | '';
30 error: boolean;
31 }) => void;
32 label: string;
33}
34
35const PhoneInput = ({
36 value,
37 onChange,
38 label,
39 required,
40 ...textFieldProps
41}: Omit<TextFieldProps, 'onChange'> & Props) => {
42 const [phone, setPhone] = useState(value);
43
44 const browserLocales = navigator.language.split('-');
45 const defaultCountry =
46 browserLocales[browserLocales.length - 1].toLowerCase();
47
48 const {inputValue, handlePhoneValueChange, inputRef, country, setCountry} =
49 usePhoneInput({
50 defaultCountry: defaultCountry || defaultCountries[0][1],
51 value: phone,
52 countries: defaultCountries,
53 onChange: ({phone, country}) => {
54 const formatedPhone = phone?.replace(/^\+0*/, '+');
55 setPhone(formatedPhone);
56 if (isPhoneValid(formatedPhone))
57 onChange({phone: formatedPhone, country: country.iso2, error: false});
58 else onChange({phone: '', country: '', error: true});
59 },
60 });
61
62 return (
63 <TextField
64 fullWidth
65 required={required}
66 error={inputValue && (!phone || value !== phone)}
67 {...textFieldProps}
68 label={label}
69 value={inputValue}
70 onChange={handlePhoneValueChange}
71 type="tel"
72 inputRef={inputRef}
73 slotProps={{
74 input: {
75 startAdornment: (
76 <InputAdornment position="start" sx={{mr: 0.5, ml: -2}}>
77 <Select
78 MenuProps={menuProps}
79 sx={selectSx}
80 value={country.iso2}
81 onChange={e => setCountry(e.target.value)}
82 renderValue={value => (
83 <FlagImage iso2={value} style={{display: 'flex'}} />
84 )}
85 >
86 {defaultCountries.map(c => {
87 const country = parseCountry(c);
88 return (
89 <MenuItem key={country.iso2} value={country.iso2}>
90 <FlagImage
91 iso2={country.iso2}
92 style={{marginRight: '8px'}}
93 />
94 <Typography marginRight="8px">{country.name}</Typography>
95 <Typography color="gray">+{country.dialCode}</Typography>
96 </MenuItem>
97 );
98 })}
99 </Select>
100 </InputAdornment>
101 ),
102 },
103 }}
104 />
105 );
106};
107
108const phoneUtil = PhoneNumberUtil.getInstance();
109const isPhoneValid = (phone: string) => {
110 try {
111 return phoneUtil.isValidNumber(phoneUtil.parseAndKeepRawInput(phone));
112 } catch (error) {
113 return false;
114 }
115};
116
117const selectSx = {
118 width: 'max-content',
119 // Remove default outline (display only on focus)
120 fieldset: {
121 display: 'none',
122 },
123 '&.Mui-focused:has(div[aria-expanded="false"])': {
124 fieldset: {
125 display: 'block',
126 },
127 },
128 // Update default spacing
129 '.MuiSelect-select': {
130 padding: '8px',
131 paddingRight: '24px !important',
132 },
133 svg: {
134 right: 0,
135 },
136};
137
138const menuProps: Partial<MenuProps> = {
139 style: {
140 height: '300px',
141 width: '360px',
142 top: '10px',
143 left: '-34px',
144 },
145 transformOrigin: {
146 vertical: 'top',
147 horizontal: 'left',
148 },
149};
150
151export default PhoneInput;