Files
emaui/libs/ui/src/lib/input/PhoneInput.tsx

220 lines
7.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberFromString,
type CountryCode,
} from 'libphonenumber-js';
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
type CountryOption = ComboboxItem & { name: string };
// Search by country name ("united"), dial code ("+1") or ISO prefix ("us") —
// the closed control's label alone ("+1") isn't enough to find a country.
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
const q = search.trim().toLowerCase();
if (!q) return options;
return (options as CountryOption[]).filter(
(o) =>
o.name.toLowerCase().includes(q) ||
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().startsWith(q),
);
};
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => {
const o = option as CountryOption;
return (
<Group gap="xs" wrap="nowrap" justify="space-between" flex={1}>
<Group gap="xs" wrap="nowrap">
<CountryFlag code={o.value} />
<Text fz="sm">{o.name}</Text>
</Group>
<Text fz="xs" c="dimmed">{o.label}</Text>
</Group>
);
};
export interface PhoneInputProps {
/** E.164 (`+14155552671`), or '' when empty. */
value: string;
onChange: (value: string) => void;
/**
* Fired when focus leaves a field that has digits in it — wire to the
* form's `trigger`. A blank field is left to submit-time validation, like
* the form's other inputs, so tabbing past it doesn't raise an error.
*/
onBlur?: () => void;
label?: React.ReactNode;
placeholder?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
withAsterisk?: boolean;
disabled?: boolean;
readOnly?: boolean;
}
/**
* International phone entry: a searchable country/dial-code select beside a
* national-number text box, WhatsApp/Telegram style. Controlled —
* `value`/`onChange` carry the E.164 string.
*
* The typed digits live in local state rather than being re-derived from
* `value` on every render: an incomplete number doesn't parse, so deriving
* would blank the box between keystrokes.
*/
export function PhoneInput({
value,
onChange,
onBlur,
label,
placeholder,
description,
error,
required,
withAsterisk,
disabled,
readOnly,
}: PhoneInputProps) {
const { t, i18n } = useTranslation();
const lang = resolveLang(i18n.language);
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
const [country, setCountry] = useState<CountryCode>(initialCountry);
const [national, setNational] = useState(() =>
formatNational(toNationalDigits(value, initialCountry), initialCountry),
);
// Mantine keeps the selected label ("+251") as the search text, so typing
// would search for "+251u". Cleared while the dropdown is open instead.
const [search, setSearch] = useState('');
// What this field last pushed upward, so an echo of our own value isn't
// mistaken for the parent resetting the form.
const emitted = useRef(value);
useEffect(() => {
if (value === emitted.current) return;
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
const next = parsedCountry ?? country;
if (parsedCountry) setCountry(parsedCountry);
setNational(formatNational(toNationalDigits(value, next), next));
emitted.current = value;
// Adopting an outside change only — `country` is state written here, so
// re-running on it would fight the user's typing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const countryOptions = useMemo<CountryOption[]>(
() =>
COUNTRY_CODES.map((code) => ({
value: code,
label: `+${getCountryCallingCode(code)}`,
name: getCountryName(code, lang),
})).sort((a, b) => a.name.localeCompare(b.name, lang)),
[lang],
);
function push(next: string) {
emitted.current = next;
onChange(next);
}
function applyDigits(digits: string, forCountry: CountryCode) {
// Keystroke past the longest possible number is ignored, not stored.
if (exceedsMaxLength(digits, forCountry)) return;
// Once the number is valid, show the true national part: someone who
// types a trunk prefix (0911111111) or the country code (251911111111)
// shouldn't end up with it doubled beside the "+251" selector. Not
// before: a partial number can parse too, and rewriting it mid-typing
// makes digits vanish under the caret.
const parsed = parsePhoneNumberFromString(digits, forCountry);
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
push(toE164(digits, forCountry));
}
function handleText(text: string) {
// A full `+<code><number>` arriving at once (paste, autofill, or a test
// driver's `.fill()`) is parsed standalone and switches the country,
// rather than being read under whatever country was already selected.
const trimmed = text.trim();
if (trimmed.startsWith('+')) {
const parsed = parsePhoneNumberFromString(trimmed);
if (parsed?.country) {
setCountry(parsed.country);
setNational(formatNational(parsed.nationalNumber, parsed.country));
push(parsed.number);
return;
}
}
applyDigits(nextNationalDigits(text, national), country);
}
function handleCountryChange(next: string | null) {
if (!next) return;
const code = next as CountryCode;
setCountry(code);
// A number carried over from a longer plan is cut to fit the new one.
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
}
return (
<TextInput
type="tel"
inputMode="tel"
autoComplete="tel"
label={label}
description={description}
error={error}
required={required}
withAsterisk={withAsterisk}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder}
value={national}
onChange={(e) => handleText(e.currentTarget.value)}
// Blur of the whole field, not of the number box: moving into the
// country select mustn't validate a half-typed number.
wrapperProps={{
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
},
}}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={
<Select
aria-label={t('phone.countryCode', 'Country code')}
data={countryOptions}
value={country}
onChange={handleCountryChange}
renderOption={renderCountryOption}
filter={filterCountries}
searchable
searchValue={search}
onSearchChange={setSearch}
onDropdownOpen={() => setSearch('')}
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
allowDeselect={false}
disabled={disabled || readOnly}
variant="unstyled"
size="xs"
w={92}
maxDropdownHeight={320}
comboboxProps={{ width: 260, position: 'bottom-start' }}
leftSection={<CountryFlag code={country} />}
leftSectionWidth={30}
/>
}
/>
);
}