mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
220 lines
7.8 KiB
TypeScript
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}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
}
|