Last active
July 23, 2025 11:26
-
-
Save rhyek/f56fbe372455ba8618f5becd67bb1658 to your computer and use it in GitHub Desktop.
Mantine v7 Phone Input with country select
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef, useState } from 'react'; | |
import { | |
useCombobox, | |
Combobox, | |
Group, | |
CheckIcon, | |
ScrollArea, | |
InputBase, | |
ActionIcon, | |
type InputBaseProps, | |
type PolymorphicComponentProps, | |
} from '@mantine/core'; | |
import { useUncontrolled } from '@mantine/hooks'; | |
import { IconChevronDown } from '@tabler/icons-react'; | |
import countries from 'i18n-iso-countries'; | |
import es from 'i18n-iso-countries/langs/es.json'; | |
import { | |
getExampleNumber, | |
type CountryCode, | |
parsePhoneNumberFromString, | |
getCountries, | |
AsYouType, | |
} from 'libphonenumber-js'; | |
import examples from 'libphonenumber-js/mobile/examples'; | |
import { IMaskInput } from 'react-imask'; | |
countries.registerLocale(es); | |
function getFlagEmoji(countryCode: string) { | |
const codePoints = countryCode | |
.toUpperCase() | |
.split('') | |
.map((char) => 127397 + char.charCodeAt(0)); | |
return String.fromCodePoint(...codePoints); | |
} | |
const libIsoCountries = countries.getNames('es', { select: 'official' }); | |
const libPhoneNumberCountries = getCountries(); | |
const countryOptionsDataMap = Object.fromEntries( | |
libPhoneNumberCountries | |
.map((code) => { | |
const name = libIsoCountries[code]; | |
const emoji = getFlagEmoji(code); | |
if (!name || !emoji) return null; | |
return [ | |
code, | |
{ | |
code, | |
name, | |
emoji, | |
}, | |
] as [ | |
CountryCode, | |
{ | |
code: CountryCode; | |
name: string; | |
emoji: string; | |
}, | |
]; | |
}) | |
.filter((o) => !!o), | |
); | |
const countryOptionsData = Object.values(countryOptionsDataMap); | |
type Country = (typeof countryOptionsData)[number]; | |
function getFormat(countryCode: CountryCode) { | |
const example = getExampleNumber(countryCode, examples)!.formatNational(); | |
const mask = example.replace(/\d/g, '0'); | |
return { example, mask }; | |
} | |
function getInitialDataFromValue( | |
value: string | undefined, | |
options: { | |
initialCountryCode: string; | |
}, | |
): { | |
country: Country; | |
format: ReturnType<typeof getFormat>; | |
localValue: string; | |
} { | |
const defaultValue = { | |
country: countryOptionsDataMap[options.initialCountryCode], | |
format: getFormat(options.initialCountryCode as CountryCode), | |
localValue: '', | |
}; | |
if (!value) return defaultValue; | |
const phoneNumber = parsePhoneNumberFromString(value); | |
if (!phoneNumber) return defaultValue; | |
if (!phoneNumber.country) return defaultValue; | |
return { | |
country: countryOptionsDataMap[phoneNumber.country], | |
localValue: phoneNumber.formatNational(), | |
format: getFormat(phoneNumber.country), | |
}; | |
} | |
export type PhoneInputProps = { | |
initialCountryCode?: string; | |
defaultValue?: string; | |
} & Omit< | |
PolymorphicComponentProps<typeof IMaskInput, InputBaseProps>, | |
'onChange' | 'defaultValue' | |
> & { onChange: (value: string | null) => void }; | |
export function PhoneInput({ | |
initialCountryCode = 'GT', | |
value: _value, | |
onChange: _onChange, | |
defaultValue, | |
...props | |
}: PhoneInputProps) { | |
const [value, onChange] = useUncontrolled({ | |
value: _value, | |
defaultValue, | |
onChange: _onChange, | |
}); | |
const initialData = useRef( | |
getInitialDataFromValue(value, { | |
initialCountryCode: initialCountryCode, | |
}), | |
); | |
const [country, setCountry] = useState(initialData.current.country); | |
const [format, setFormat] = useState(initialData.current.format); | |
const [localValue, setLocalValue] = useState(initialData.current.localValue); | |
const inputRef = useRef<HTMLInputElement>(null); | |
const lastNotifiedValue = useRef<string | null>(value ?? ''); | |
useEffect(() => { | |
let value = ''; | |
if (localValue.trim().length > 0) { | |
const asYouType = new AsYouType(country.code); | |
asYouType.input(localValue); | |
value = asYouType.getNumber()?.number ?? ''; | |
} | |
if (value !== lastNotifiedValue.current) { | |
lastNotifiedValue.current = value; | |
onChange(value); | |
} | |
}, [country.code, localValue]); | |
useEffect(() => { | |
if (typeof value !== 'undefined' && value !== lastNotifiedValue.current) { | |
const initialData = getInitialDataFromValue(value, { | |
initialCountryCode, | |
}); | |
lastNotifiedValue.current = value; | |
setCountry(initialData.country); | |
setFormat(initialData.format); | |
setLocalValue(initialData.localValue); | |
} | |
}, [value]); | |
const { readOnly, disabled } = props; | |
const leftSectionWidth = 54; | |
return ( | |
<InputBase | |
{...props} | |
component={IMaskInput} | |
inputRef={inputRef} | |
leftSection={ | |
<CountrySelect | |
disabled={disabled || readOnly} | |
country={country} | |
setCountry={(country) => { | |
setCountry(country); | |
setFormat(getFormat(country.code)); | |
setLocalValue(''); | |
if (inputRef.current) { | |
inputRef.current.focus(); | |
} | |
}} | |
leftSectionWidth={leftSectionWidth} | |
/> | |
} | |
leftSectionWidth={leftSectionWidth} | |
styles={{ | |
input: { | |
paddingLeft: `calc(${leftSectionWidth}px + var(--mantine-spacing-sm))`, | |
}, | |
section: { | |
borderRight: '1px solid var(--mantine-color-default-border)', | |
}, | |
}} | |
inputMode="numeric" | |
mask={format.mask} | |
unmask={true} | |
value={localValue} | |
onAccept={(value) => setLocalValue(value)} | |
/> | |
); | |
} | |
function CountrySelect({ | |
country, | |
setCountry, | |
disabled, | |
leftSectionWidth, | |
}: { | |
country: Country; | |
setCountry: (country: Country) => void; | |
disabled: boolean | undefined; | |
leftSectionWidth: number; | |
}) { | |
const [search, setSearch] = useState(''); | |
const selectedRef = useRef<HTMLDivElement>(null); | |
const combobox = useCombobox({ | |
onDropdownClose: () => { | |
combobox.resetSelectedOption(); | |
setSearch(''); | |
}, | |
onDropdownOpen: () => { | |
combobox.focusSearchInput(); | |
setTimeout(() => { | |
selectedRef.current?.scrollIntoView({ | |
behavior: 'instant', | |
block: 'center', | |
}); | |
}, 0); | |
}, | |
}); | |
const options = countryOptionsData | |
.filter((item) => | |
item.name.toLowerCase().includes(search.toLowerCase().trim()), | |
) | |
.map((item) => ( | |
<Combobox.Option | |
ref={item.code === country.code ? selectedRef : undefined} | |
value={item.code} | |
key={item.code} | |
> | |
<Group gap="xs"> | |
{item.code === country.code && <CheckIcon size={12} />} | |
<span> | |
{item.emoji} {item.name} | |
</span> | |
</Group> | |
</Combobox.Option> | |
)); | |
useEffect(() => { | |
if (search) { | |
combobox.selectFirstOption(); | |
} | |
}, [search]); | |
return ( | |
<Combobox | |
store={combobox} | |
width={250} | |
position="bottom-start" | |
withArrow | |
onOptionSubmit={(val) => { | |
setCountry(countryOptionsDataMap[val]); | |
combobox.closeDropdown(); | |
}} | |
> | |
<Combobox.Target withAriaAttributes={false}> | |
<ActionIcon | |
variant="transparent" | |
onClick={() => combobox.toggleDropdown()} | |
size="lg" | |
tabIndex={-1} | |
disabled={disabled} | |
w={leftSectionWidth} | |
c="dimmed" | |
> | |
<Group gap={2}> | |
{country.emoji} | |
<IconChevronDown size={14} /> | |
</Group> | |
</ActionIcon> | |
</Combobox.Target> | |
<Combobox.Dropdown> | |
<Combobox.Search | |
value={search} | |
onChange={(event) => setSearch(event.currentTarget.value)} | |
placeholder="Buscar país" | |
/> | |
<Combobox.Options> | |
<ScrollArea.Autosize mah={200} type="scroll"> | |
{options.length > 0 ? ( | |
options | |
) : ( | |
<Combobox.Empty>No encontrado</Combobox.Empty> | |
)} | |
</ScrollArea.Autosize> | |
</Combobox.Options> | |
</Combobox.Dropdown> | |
</Combobox> | |
); | |
} |
Great work! Thanks a lot - this saved me a lot of time :)
You're welcome! :-)
Thanks a lot!
I experienced an issue where the component would crash the app if I input 1 as the first digit when entering a US number. (I don't know if it's an invalid syntax for US numbers as I'm not American). It seemed to come from this line:
value = asYouType.getNumber()!.number;
I replaced the null assertion operator (!.
) with an optional chaining operator (?.
)
Thanks, @YewoMhango. I tried value = asYouType.getNumber()?.number ?? ''
and it seems to work. Updated the gist.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Great work! Thanks a lot - this saved me a lot of time :)