Last active
December 6, 2025 01:34
-
-
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> | |
| ); | |
| } |
Yes, It's happening for a few countries where the phone number length is
allowed to have multiple. There after entering the minimum length it's
taking correct input but while setting value back it's taking 0 after
country code to match the maximum number.
…On Fri, 31 Oct 2025 at 18:19, Yewo Mhango ***@***.***> wrote:
***@***.**** commented on this gist.
------------------------------
I also noticed another issue recently where the "Maximum Update Depth
Exceeded" error
<https://typeofnan.dev/fix-the-maximum-update-depth-exceeded-error-in-react/>
gets triggered if you enter a 0 as the first digit in the phone number
(though it doesn't happen for all country codes). So my solution was to
replace the first useEffect in PhoneInput with an event handler that gets
passed to onAccept directly
—
Reply to this email directly, view it on GitHub
<https://gist.github.com/rhyek/f56fbe372455ba8618f5becd67bb1658#gistcomment-5839579>
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/BN6EHDNSUQDCHOKPSZNMMPT32NLFVBFHORZGSZ3HMVZKMY3SMVQXIZNMON2WE2TFMN2F65DZOBS2WR3JON2EG33NNVSW45FGORXXA2LDOOIYFJDUPFYGLJDHNFZXJJLWMFWHKZNJGEZTGMZRGQ2TKMNKMF2HI4TJMJ2XIZLTSOBKK5TBNR2WLKBXHAZDOMRXGYYKI3TBNVS2QYLDORXXEX3JMSBKK5TBNR2WLJDUOJ2WLJDOMFWWLO3UNBZGKYLEL5YGC4TUNFRWS4DBNZ2F6YLDORUXM2LUPGBKK5TBNR2WLJDHNFZXJJDOMFWWLK3UNBZGKYLEL52HS4DF>
.
You are receiving this email because you commented on the thread.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>
.
Author
@YewoMhango @suman-eleanorcare could you share a way to reproduce the issue? Like country and number. And the workaround code if you don’t mind. I’ll test soon.
@YewoMhango @suman-eleanorcare could you share a way to reproduce the issue? Like country and number. And the workaround code if you don’t mind. I’ll test soon.
An example of a country code which reproduced the issue was selecting the UK. But I don't have access to my computer currently, so I can't send the workaround code immediately
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I also noticed another issue recently where the "Maximum Update Depth Exceeded" error gets triggered if you enter a
0as the first digit in the phone number (though it doesn't happen for all country codes). So my solution was to replace the firstuseEffectinPhoneInputwith an event handler that gets passed toonAcceptdirectly