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 let formatedPhone = phone?.replace(/^\+0*/, '+');
55 if (country.iso2 === 'fr')
56 formatedPhone = formatedPhone?.replace(/^\+330/, '+33');
57 setPhone(formatedPhone);
58 if (isPhoneValid(formatedPhone))
59 onChange({phone: formatedPhone, country: country.iso2, error: false});
60 else onChange({phone: '', country: '', error: true});
61 },
62 });
63
64 return (
65 <TextField
66 fullWidth
67 required={required}
68 error={inputValue && (!phone || value !== phone)}
69 {...textFieldProps}
70 label={label}
71 value={inputValue}
72 onChange={handlePhoneValueChange}
73 type="tel"
74 inputRef={inputRef}
75 slotProps={{
76 input: {
77 startAdornment: (
78 <InputAdornment position="start" sx={{mr: 0.5, ml: -2}}>
79 <Select
80 MenuProps={menuProps}
81 sx={selectSx}
82 value={country.iso2}
83 onChange={e => setCountry(e.target.value)}
84 renderValue={value => (
85 <FlagImage iso2={value} style={{display: 'flex'}} />
86 )}
87 >
88 {defaultCountries.map(c => {
89 const country = parseCountry(c);
90 return (
91 <MenuItem key={country.iso2} value={country.iso2}>
92 <FlagImage
93 iso2={country.iso2}
94 style={{marginRight: '8px'}}
95 />
96 <Typography marginRight="8px">{country.name}</Typography>
97 <Typography color="gray">+{country.dialCode}</Typography>
98 </MenuItem>
99 );
100 })}
101 </Select>
102 </InputAdornment>
103 ),
104 },
105 }}
106 />
107 );
108};
109
110const phoneUtil = PhoneNumberUtil.getInstance();
111const isPhoneValid = (phone: string) => {
112 try {
113 return phoneUtil.isValidNumber(phoneUtil.parseAndKeepRawInput(phone));
114 } catch (error) {
115 return false;
116 }
117};
118
119const selectSx = {
120 width: 'max-content',
121 // Remove default outline (display only on focus)
122 fieldset: {
123 display: 'none',
124 },
125 '&.Mui-focused:has(div[aria-expanded="false"])': {
126 fieldset: {
127 display: 'block',
128 },
129 },
130 // Update default spacing
131 '.MuiSelect-select': {
132 padding: '8px',
133 paddingRight: '24px !important',
134 },
135 svg: {
136 right: 0,
137 },
138};
139
140const menuProps: Partial<MenuProps> = {
141 style: {
142 height: '300px',
143 width: '360px',
144 top: '10px',
145 left: '-34px',
146 },
147 transformOrigin: {
148 vertical: 'top',
149 horizontal: 'left',
150 },
151};
152
153export default PhoneInput;