145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useRef, useState } from 'react';
|
||
|
|
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { callingCodeFor, formatAsYouType, parsePhone } from '@/lib/i18n/phone';
|
||
|
|
import { detectDefaultCountry, type CountryCode } from '@/lib/i18n/countries';
|
||
|
|
|
||
|
|
export interface PhoneInputValue {
|
||
|
|
/** E.164 form ('+442079460958'). Null when empty or unparseable. */
|
||
|
|
e164: string | null;
|
||
|
|
/** Country selected in the dropdown — drives the AsYouType formatter. */
|
||
|
|
country: CountryCode;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PhoneInputProps {
|
||
|
|
value: PhoneInputValue | null | undefined;
|
||
|
|
onChange: (next: PhoneInputValue) => void;
|
||
|
|
/** Pre-selects the dropdown when `value.country` isn't supplied. */
|
||
|
|
defaultCountry?: CountryCode;
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
required?: boolean;
|
||
|
|
invalid?: boolean;
|
||
|
|
id?: string;
|
||
|
|
'data-testid'?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Phone input with a country flag dropdown + format-as-you-type.
|
||
|
|
*
|
||
|
|
* Wire shape: emits `{ e164, country }` on every change. E.164 is null
|
||
|
|
* while the input is too short to parse — that's a form-validation
|
||
|
|
* concern, not an input concern. Pasting an international number
|
||
|
|
* (`+1 415…`) auto-switches the country dropdown to match.
|
||
|
|
*
|
||
|
|
* Implementation notes:
|
||
|
|
* - The visible string is always the AsYouType national-format,
|
||
|
|
* so users see e.g. "020 7946 0958" while typing GB digits.
|
||
|
|
* - The `country` prop drives the AsYouType context; flipping
|
||
|
|
* countries reformats the same digits against the new country
|
||
|
|
* (matches what users expect when they fix a wrong country pick).
|
||
|
|
*/
|
||
|
|
export function PhoneInput({
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
defaultCountry,
|
||
|
|
placeholder = 'Phone number',
|
||
|
|
disabled,
|
||
|
|
required,
|
||
|
|
invalid,
|
||
|
|
id,
|
||
|
|
'data-testid': testId,
|
||
|
|
}: PhoneInputProps) {
|
||
|
|
const [country, setCountry] = useState<CountryCode>(
|
||
|
|
() => value?.country ?? defaultCountry ?? detectDefaultCountry('US'),
|
||
|
|
);
|
||
|
|
const [display, setDisplay] = useState(() => {
|
||
|
|
if (!value?.e164) return '';
|
||
|
|
const parsed = parsePhone(value.e164, value.country);
|
||
|
|
return parsed.national ?? '';
|
||
|
|
});
|
||
|
|
// Track whether the user has typed since mount — keeps a controlled-from-props
|
||
|
|
// value sync on first render only.
|
||
|
|
const initialized = useRef(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (initialized.current) return;
|
||
|
|
initialized.current = true;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
function emit(rawDigits: string, currentCountry: CountryCode) {
|
||
|
|
const parsed = parsePhone(rawDigits, currentCountry);
|
||
|
|
onChange({ e164: parsed.e164, country: currentCountry });
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleInput(raw: string) {
|
||
|
|
// Paste-detect: if user pasted an international format, parse it
|
||
|
|
// and flip the country dropdown to match — better UX than asking
|
||
|
|
// them to also click the dropdown.
|
||
|
|
if (raw.startsWith('+')) {
|
||
|
|
const parsed = parsePhone(raw);
|
||
|
|
if (parsed.country) {
|
||
|
|
setCountry(parsed.country);
|
||
|
|
const reformatted = formatAsYouType(raw, parsed.country);
|
||
|
|
setDisplay(reformatted);
|
||
|
|
emit(raw, parsed.country);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const formatted = formatAsYouType(raw, country);
|
||
|
|
setDisplay(formatted);
|
||
|
|
emit(formatted, country);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleCountryChange(next: CountryCode | null) {
|
||
|
|
if (!next) return;
|
||
|
|
setCountry(next);
|
||
|
|
// Re-run the formatter against the new country so the visible
|
||
|
|
// string stays consistent with the dropdown.
|
||
|
|
const reformatted = formatAsYouType(display, next);
|
||
|
|
setDisplay(reformatted);
|
||
|
|
emit(reformatted, next);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'flex items-stretch gap-1.5',
|
||
|
|
invalid && '[&_input]:border-destructive [&_button[role=combobox]]:border-destructive',
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<CountryCombobox
|
||
|
|
value={country}
|
||
|
|
onChange={handleCountryChange}
|
||
|
|
compact
|
||
|
|
clearable={false}
|
||
|
|
disabled={disabled}
|
||
|
|
data-testid={testId ? `${testId}-country` : undefined}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-1 items-stretch overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
|
||
|
|
<span className="flex select-none items-center bg-muted/40 px-2 text-sm tabular-nums text-muted-foreground">
|
||
|
|
{callingCodeFor(country)}
|
||
|
|
</span>
|
||
|
|
<Input
|
||
|
|
id={id}
|
||
|
|
type="tel"
|
||
|
|
inputMode="tel"
|
||
|
|
autoComplete="tel"
|
||
|
|
placeholder={placeholder}
|
||
|
|
value={display}
|
||
|
|
disabled={disabled}
|
||
|
|
required={required}
|
||
|
|
onChange={(e) => handleInput(e.target.value)}
|
||
|
|
// Strip the inner Input's own border so it sits flush with the country prefix.
|
||
|
|
className="flex-1 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||
|
|
data-testid={testId}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|