diff --git a/content/docs/components/inputs/phone-input.mdx b/content/docs/components/inputs/phone-input.mdx index 1678179..7b11491 100644 --- a/content/docs/components/inputs/phone-input.mdx +++ b/content/docs/components/inputs/phone-input.mdx @@ -96,44 +96,80 @@ import { PhoneInput } from "@/components/ui/phone-input"; export function PhoneField() { const [phone, setPhone] = useState(""); + const [countryCode, setCountryCode] = useState("US"); return ( { setPhone(value); console.log(country.dialCode); }} + onCountryChange={(country) => setCountryCode(country.code)} + placeholder="Enter phone number" /> ); } ``` +### Country control + +The country follows the same controlled/uncontrolled convention as the number: +pass `country` + `onCountryChange` to control it, or `defaultCountry` to seed +the uncontrolled default. When `country` is controlled, the parent has the final +say — honour `onCountryChange` if you want dial-code detection (below) to +switch countries. + +### Dial-code detection + +Type or paste a number that starts with `+` and the component switches the +country to match, stripping the dial code and the separator right after it from +the field — the prefix the `+` declares outranks a manually picked country: + +- Pasting `+44 20 7946 0958` selects GB and leaves `20 7946 0958` in the field. +- A `+` typed in front of digits that are **already in the field** never strips + them: the dial code is only consumed when its digits were entered after the + `+`. Type `+1` in front of an existing number and the country switches to US + once the `1` lands — and the number keeps every digit it had. +- The **longest** matching dial code wins, so `+880…` picks Bangladesh even if a + shorter code also matches. +- **Shared dial codes** keep the currently selected country when it is one of + the countries sharing the matched code, and otherwise pick the first one in + the offered list — so `+1` never jumps away from US or CA mid-typing. The same + rule covers any shared code in a custom `countries` list, such as `+7` if you + offer both RU and KZ. +- A dial code that is not in your `countries` list is left alone — the country + only ever switches to a country you actually offer. +- After a switch, the country menu still works normally: picking a country + overrides the detected one, and nothing re-detects until the next time the + value starts with `+`. + ## Props -| Prop | Type | Required | Description | -| ------------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | -| `value` | `string` | No | Phone number — pass this to control the component | -| `defaultValue` | `string` | No | Initial phone number when uncontrolled | -| `onValueChange` | `(phone: string, country: Country) => void` | No | Callback when phone number or country changes | -| `defaultCountry` | `string` | No | Default country code (ISO 3166-1 alpha-2, e.g. `"US"`, `"GB"`, `"IN"`) | -| `countries` | `Country[]` | No | Countries offered, in display order (default: `COUNTRIES`) | -| `placeholder` | `string` | No | Placeholder text | -| `disabled` | `boolean` | No | Disable the input | -| `showFlags` | `boolean` | No | Show country flag next to dial code (default: `true`) | -| `size` | `"sm" \| "default" \| "lg"` | No | Field height (default: `"default"`) | -| `align` | `"start" \| "end"` | No | Edge of the field the menu lines up with (default: `"start"`) | -| `side` | `"top" \| "bottom"` | No | Preferred menu side; flips when there is no room (default: `"bottom"`) | -| `searchPlaceholder` | `string` | No | Placeholder for the country search (default: `"Search countries..."`) | -| `emptyMessage` | `ReactNode` | No | Shown when the search matches nothing (default: `"No countries found."`) | -| `labels` | `Partial` | No | Override the built-in English UI text (see below) | -| `displayLocale` | `string` | No | Locale for country names, e.g. `"fr"` (default: English names) | -| `maxHeight` | `number \| string` | No | Max height of the scrolling country list (default: `"15rem"`) | -| `label` | `string` | No | Accessible label for the input | -| `className` | `string` | No | Classes for the root wrapper | -| `classNames` | `PhoneInputClassNames` | No | Per-part classes: `root`, `field`, `trigger`, `input`, `content`, `search`, `list`, `item`, `empty` | +| Prop | Type | Required | Description | +| ------------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `value` | `string` | No | Phone number — pass this to control the component | +| `defaultValue` | `string` | No | Initial phone number when uncontrolled | +| `onValueChange` | `(phone: string, country: Country) => void` | No | Callback when phone number or country changes | +| `country` | `string` | No | Selected country code (ISO 3166-1 alpha-2) — pass this to control the country | +| `onCountryChange` | `(country: Country) => void` | No | Callback when the selected country changes — from the menu or dial-code detection | +| `defaultCountry` | `string` | No | Initial country code when uncontrolled (ISO 3166-1 alpha-2, e.g. `"US"`, `"GB"`, `"IN"`; default: `"US"`) | +| `countries` | `Country[]` | No | Countries offered, in display order (default: `COUNTRIES`) | +| `placeholder` | `string` | No | Placeholder text | +| `disabled` | `boolean` | No | Disable the input | +| `showFlags` | `boolean` | No | Show country flag next to dial code (default: `true`) | +| `size` | `"sm" \| "default" \| "lg"` | No | Field height (default: `"default"`) | +| `align` | `"start" \| "end"` | No | Edge of the field the menu lines up with (default: `"start"`) | +| `side` | `"top" \| "bottom"` | No | Preferred menu side; flips when there is no room (default: `"bottom"`) | +| `searchPlaceholder` | `string` | No | Placeholder for the country search (default: `"Search countries..."`) | +| `emptyMessage` | `ReactNode` | No | Shown when the search matches nothing (default: `"No countries found."`) | +| `labels` | `Partial` | No | Override the built-in English UI text (see below) | +| `displayLocale` | `string` | No | Locale for country names, e.g. `"fr"` (default: English names) | +| `maxHeight` | `number \| string` | No | Max height of the scrolling country list (default: `"15rem"`) | +| `label` | `string` | No | Accessible label for the input | +| `className` | `string` | No | Classes for the root wrapper | +| `classNames` | `PhoneInputClassNames` | No | Per-part classes: `root`, `field`, `trigger`, `input`, `content`, `search`, `list`, `item`, `empty` | Any other prop — `name`, `required`, `autoComplete`, `aria-invalid`, `data-*` — is spread onto the underlying ``, and the ref is forwarded to it, so the @@ -158,22 +194,30 @@ Pair Phone Input with the [Babelize SDK](https://docs.babelize.co/sdk) for local ```tsx "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { PhoneInput } from "@/components/ui/phone-input"; import { useBabelize } from "@babelize/sdk/react"; export function ContactPhone() { const { locale } = useBabelize(); const [phone, setPhone] = useState(""); + const [countryCode, setCountryCode] = useState("US"); + + // The locale picks the country — until the user overrides it through the + // menu or by pasting a `+` number, which `onCountryChange` must honour. + useEffect(() => { + setCountryCode(locale === "en" ? "US" : locale === "de" ? "DE" : "GB"); + }, [locale]); return ( setCountryCode(country.code)} onValueChange={(value, country) => { setPhone(value); // Validate against locale-specific rules }} - defaultCountry={locale === "en" ? "US" : locale === "de" ? "DE" : "GB"} placeholder="Enter phone number" /> ); @@ -332,7 +376,8 @@ scrolls. - **ARIA labels** — `role="combobox"` search field with `aria-activedescendant`, plus `aria-expanded`, `aria-haspopup`, and `aria-selected` - **Themeable** — styled with shadcn/ui tokens (`border-input`, `bg-popover`, `ring-ring`), so it follows your theme and dark mode - **Animated menu** — fades in and out with `motion`, honouring reduced-motion settings, and flips upward when there is no room below -- **Controlled/uncontrolled** — works with or without `value` prop +- **Controlled/uncontrolled** — works with or without `value` and `country` +- **Dial-code detection** — typing or pasting `+44…` switches the country to GB and strips the prefix; shared codes (`+1`, `+7`) keep the current selection ## Supported Countries diff --git a/src/registry/components/phone-input.tsx b/src/registry/components/phone-input.tsx index a5d39f1..79760f5 100644 --- a/src/registry/components/phone-input.tsx +++ b/src/registry/components/phone-input.tsx @@ -81,7 +81,14 @@ export interface PhoneInputProps extends Omit< defaultValue?: string; /** Callback when the number or the selected country changes */ onValueChange?: (phone: string, country: Country) => void; - /** ISO 3166-1 alpha-2 code of the initially selected country (default: "US") */ + /** + * Selected country code (ISO 3166-1 alpha-2). Provide this to control the + * component — pair it with `onCountryChange`. + */ + country?: string; + /** Callback when the selected country changes — from the menu or dial-code detection */ + onCountryChange?: (country: Country) => void; + /** Initial selected country code when uncontrolled (default: "US") */ defaultCountry?: string; /** * Countries offered in the menu, in display order (default: `COUNTRIES`). @@ -207,7 +214,69 @@ const INPUT_SIZES = { } as const; function getCountryByCode(code: string, list: Country[] = COUNTRIES): Country | undefined { - return list.find((c) => c.code === code.toUpperCase()); + // Case-insensitive on both sides: the lookup arg arrives in any case + // ("gb", "GB"), and a hand-written `countries` list may not be uppercase. + return list.find((c) => c.code.toUpperCase() === code.toUpperCase()); +} + +/** + * Detect an explicit `+` dial-code prefix in what the user typed or pasted. + * Returns the country it resolves to plus the value with the prefix stripped — + * including any separator that immediately followed the code (`+44 20` leaves + * `20`, not ` 20`) — or `undefined` when the input does not start with a dial + * code that is actually offered: detection must never land on a country + * outside `countries`. + * + * The longest matching dial code wins (`+880` beats `+8` for a list that has + * both). Shared dial codes (`+1`, `+7`) keep `current` when it is one of the + * sharing countries, otherwise the first one in the offered list wins. + */ +function detectCountryPrefix( + input: string, + countries: Country[], + current: Country, +): { country: Country; rest: string } | undefined { + if (!input.startsWith("+")) return undefined; + let bestDialCode: string | undefined; + for (const c of countries) { + if (input.startsWith(c.dialCode) && c.dialCode.length > (bestDialCode?.length ?? 0)) { + bestDialCode = c.dialCode; + } + } + if (!bestDialCode) return undefined; + const withCode = countries.filter((c) => c.dialCode === bestDialCode); + const country = withCode.find((c) => c.code === current.code) ?? withCode[0]; + if (!country) return undefined; + return { + country, + rest: input.slice(bestDialCode.length).replace(/^[\s.-]+/, ""), + }; +} + +/** + * Describe how `prev` became `next` as one insertion and one deletion at the + * same index: the common prefix comes off the front, the common suffix off + * the back. Digit ambiguity ("9192" → "+99192" aligns at 1 or 2) biases `at` + * to the right, which under-counts fresh characters — the safe direction, + * because an under-count skips detection instead of eating a digit. + */ +function diffAt(prev: string, next: string): { at: number; inserted: string; deleted: string } { + let at = 0; + const front = Math.min(prev.length, next.length); + while (at < front && prev[at] === next[at]) at += 1; + let back = 0; + while ( + back < prev.length - at && + back < next.length - at && + prev[prev.length - 1 - back] === next[next.length - 1 - back] + ) { + back += 1; + } + return { + at, + inserted: next.slice(at, next.length - back), + deleted: prev.slice(at, prev.length - back), + }; } export const PhoneInput = React.forwardRef(function PhoneInput( @@ -215,6 +284,8 @@ export const PhoneInput = React.forwardRef(fu value, defaultValue, onValueChange, + country, + onCountryChange, defaultCountry = "US", countries = COUNTRIES, placeholder = "Phone number", @@ -235,8 +306,25 @@ export const PhoneInput = React.forwardRef(fu }, forwardedRef, ) { - const [selectedCountry, setSelectedCountry] = React.useState( - () => getCountryByCode(defaultCountry, countries) ?? countries[0] ?? COUNTRIES[0], + const [countryCode, setCountryCode] = useControllableState(country, defaultCountry, (code) => { + // An unknown code resolves the same way the display does, so a callback + // never reports a country the field is not showing. + onCountryChange?.(getCountryByCode(code, countries) ?? countries[0] ?? COUNTRIES[0]); + }); + const selectedCountry = React.useMemo( + () => getCountryByCode(countryCode, countries) ?? countries[0] ?? COUNTRIES[0], + [countryCode, countries], + ); + // Report a country change only on a real transition: re-picking the current + // country, or keeping it across a shared dial code, is not a change. + const pickCountry = React.useCallback( + (next: Country) => { + // Compare against what is displayed, not the raw code: `country="gb"` + // resolves to GB, and an unknown code falls back to `countries[0]` — + // either way the visible country must match before we report a change. + if (next.code !== selectedCountry.code) setCountryCode(next.code); + }, + [selectedCountry.code, setCountryCode], ); const [inputValue, setInputValue] = useControllableState(value, defaultValue ?? "", undefined); const [open, setOpen] = React.useState(false); @@ -250,6 +338,20 @@ export const PhoneInput = React.forwardRef(fu const searchRef = React.useRef(null); const triggerRef = React.useRef(null); const popupRef = React.useRef(null); + // Characters entered after the current leading "+". Dial-code detection may + // only consume a code whose digits all live in that zone: digits that were + // already in the field when the "+" was typed are part of the number, not a + // prefix the user declared. See handleInputChange. + const freshDigitsRef = React.useRef(0); + // The last value this handler wrote. When the next event sees a different + // one, the value changed externally (a controlled `value` prop, a + // programmatic set) and its "+"-run has unknown provenance — restart the + // fresh count at 0 rather than trusting a stale one. + const handledValueRef = React.useRef(null); + // True when the last thing we did was strip a dial code, so the separator + // the user types next — one event later, once the value is already empty — + // still belongs to the prefix rather than opening the number. + const stripJustHappenedRef = React.useRef(false); React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement); @@ -310,7 +412,7 @@ export const PhoneInput = React.forwardRef(fu onSelect: (index) => { const picked = filtered[index]; if (!picked) return; - setSelectedCountry(picked); + pickCountry(picked); onValueChange?.(inputValue, picked); inputRef.current?.focus(); }, @@ -349,9 +451,85 @@ export const PhoneInput = React.forwardRef(fu }, [open, preferredSide]); const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInputValue(newValue); - onValueChange?.(newValue, selectedCountry); + const next = e.target.value; + const prev = inputValue; + const wroteBefore = handledValueRef.current; + handledValueRef.current = next; + + // A separator typed right after the dial code was stripped belongs to the + // prefix the code just claimed: drop it instead of letting it open the + // number. Only a separator arriving at the front of an otherwise empty, + // unbroken value counts — a dash typed mid-number is the user's own + // formatting, and a value we did not write is not ours to swallow. + if ( + stripJustHappenedRef.current && + wroteBefore !== null && + wroteBefore === prev && + prev === "" && + /^[\s.-]+$/.test(next) + ) { + handledValueRef.current = ""; + return; + } + stripJustHappenedRef.current = false; + + // Keep the count of characters typed after the leading "+" in step with + // the edit, before any detection runs on the result. + let fresh = wroteBefore === null || wroteBefore === prev ? freshDigitsRef.current : 0; + if (!next.startsWith("+")) { + fresh = 0; + } else if (!prev.startsWith("+")) { + // The leading "+" just appeared. Everything the value already held was + // revealed under it — except characters this same event inserted after + // it, which is how a pasted code counts as declared. + const { inserted } = diffAt(prev, next); + fresh = inserted.startsWith("+") ? Math.max(0, inserted.length - 1) : 0; + } else { + const { at, inserted, deleted } = diffAt(prev, next); + const boundary = 1 + fresh; // first index of the pre-existing tail + if (inserted && deleted) { + // Replacement: grow for the new characters only when they land at or + // before the boundary, and only give back the deleted part that was + // inside the fresh zone. + if (at <= boundary) { + fresh += inserted.length - Math.min(deleted.length, Math.max(0, boundary - at)); + fresh = Math.max(0, fresh); + } + } else if (inserted) { + if (at <= boundary) fresh += inserted.length; + } else if (deleted) { + if (at < boundary) fresh = Math.max(0, fresh - deleted.length); + } + } + freshDigitsRef.current = fresh; + + const match = detectCountryPrefix(next, countries, selectedCountry); + if (!match) { + setInputValue(next); + onValueChange?.(next, selectedCountry); + return; + } + const dialLen = next.length - match.rest.length; + if (dialLen > 1 + fresh) { + // The digits of this "code" were already in the field before the "+": + // they belong to the number, so the "+" is an ordinary character here. + // Leave the value and the country alone — no switch, no strip. + setInputValue(next); + onValueChange?.(next, selectedCountry); + return; + } + // An explicit `+` prefix outranks a manually picked country: switch (or + // confirm, for a shared dial code), then strip what the trigger now shows. + // With `country` controlled, the parent's next `country` prop has the final + // say — `onCountryChange` fires with the intended country either way. + pickCountry(match.country); + setInputValue(match.rest); + handledValueRef.current = match.rest; + stripJustHappenedRef.current = true; + // The strip leaves a new run behind: if it still starts with "+", that + // plus now sits at index 0 with the not-yet-consumed typed digits after it. + freshDigitsRef.current = match.rest.startsWith("+") ? Math.max(0, fresh - dialLen) : 0; + onValueChange?.(match.rest, match.country); }; const handleKeyDown = (event: React.KeyboardEvent) => { diff --git a/test/phone-input.test.tsx b/test/phone-input.test.tsx index 29a71ca..05f832b 100644 --- a/test/phone-input.test.tsx +++ b/test/phone-input.test.tsx @@ -104,6 +104,318 @@ describe("PhoneInput", () => { }); }); +describe("PhoneInput country control and detection", () => { + const numberField = () => screen.getByRole("textbox", { name: "Phone number" }); + + it("follows an externally changed country prop", async () => { + function Harness() { + const [code, setCode] = React.useState("IN"); + return ( + <> + + + + ); + } + render(); + expect(screen.getByText("+91")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "set" })); + expect(screen.getByText("+44")).toBeInTheDocument(); + }); + + it("reports a picked country through onCountryChange, and only on a real change", async () => { + const onCountryChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Select country" })); + await userEvent.click(screen.getByRole("option", { name: /India/ })); + + expect(onCountryChange).toHaveBeenCalledTimes(1); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "IN" })); + + // Re-picking the country that is already selected is not a change. + await userEvent.click(screen.getByRole("button", { name: "Select country" })); + await userEvent.click(screen.getByRole("option", { name: /India/ })); + expect(onCountryChange).toHaveBeenCalledTimes(1); + }); + + it("does not report a change for a lowercase country code's own country", async () => { + const onCountryChange = vi.fn(); + render(); + expect(screen.getByText("+44")).toBeInTheDocument(); + + // "gb" resolves to GB for display — re-picking GB is not a transition. + await userEvent.click(screen.getByRole("button", { name: "Select country" })); + await userEvent.click(screen.getByRole("option", { name: /United Kingdom/ })); + expect(onCountryChange).not.toHaveBeenCalled(); + + // A country that actually differs still reports. + await userEvent.click(screen.getByRole("button", { name: "Select country" })); + await userEvent.click(screen.getByRole("option", { name: /France/ })); + expect(onCountryChange).toHaveBeenCalledTimes(1); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "FR" })); + }); + + it("does not report a change when picking the country an unknown code falls back to", async () => { + const onCountryChange = vi.fn(); + render(); + // An unknown code displays the first offered country. + expect(screen.getByText("+1")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Select country" })); + await userEvent.click(screen.getByRole("option", { name: /United States/ })); + expect(onCountryChange).not.toHaveBeenCalled(); + }); + + it("keeps a fixed controlled country when the parent ignores onCountryChange", async () => { + const onCountryChange = vi.fn(); + render(); + expect(screen.getByText("+91")).toBeInTheDocument(); + + await userEvent.click(numberField()); + await userEvent.paste("+44 20 7946 0958"); + + // The parent owns the country, so the trigger stays put — but detection + // still strips the prefix and reports the country it intended. + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("20 7946 0958"); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "GB" })); + }); + + it("selects GB when a +44 number is pasted, stripping the prefix", async () => { + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render(); + + await userEvent.click(numberField()); + await userEvent.paste("+44 20 7946 0958"); + + expect(numberField()).toHaveValue("20 7946 0958"); + expect(screen.getByText("+44")).toBeInTheDocument(); + expect(onCountryChange).toHaveBeenCalledTimes(1); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "GB" })); + expect(onValueChange).toHaveBeenLastCalledWith( + "20 7946 0958", + expect.objectContaining({ code: "GB" }), + ); + }); + + it("detects a dial code typed character by character", async () => { + render(); + + await userEvent.type(numberField(), "+919876543210"); + + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("9876543210"); + }); + + it("drops a dash typed right after the dial code is stripped", async () => { + render(); + + await userEvent.type(numberField(), "+91-9876543210"); + + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("9876543210"); + }); + + it("drops a dot typed right after the dial code is stripped", async () => { + render(); + + await userEvent.type(numberField(), "+91.9876543210"); + + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("9876543210"); + }); + + it("drops a space typed right after the dial code is stripped", async () => { + render(); + + await userEvent.type(numberField(), "+91 9876543210"); + + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("9876543210"); + }); + + it("trims only the separator directly after a pasted dial code", async () => { + const onCountryChange = vi.fn(); + render(); + + await userEvent.click(numberField()); + await userEvent.paste("+44-20-7946"); + + // The dash after +44 is consumed with the code; the one inside the number + // is the user's formatting and stays. + expect(screen.getByText("+44")).toBeInTheDocument(); + expect(numberField()).toHaveValue("20-7946"); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "GB" })); + }); + + it("leaves anything that is not whitespace, dash or dot after the dial code", async () => { + render(); + + await userEvent.click(numberField()); + await userEvent.paste("+1 (416) 555"); + + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(numberField()).toHaveValue("(416) 555"); + }); + + it("keeps CA when a +1 number is pasted while CA is selected", async () => { + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render( + , + ); + + await userEvent.click(numberField()); + await userEvent.paste("+1 416 555 0123"); + + // The shared +1 code keeps Canada — the flag in the trigger says so. + expect(screen.getByText("\u{1F1E8}\u{1F1E6}")).toBeInTheDocument(); + expect(onCountryChange).not.toHaveBeenCalled(); + expect(onValueChange).toHaveBeenLastCalledWith( + "416 555 0123", + expect.objectContaining({ code: "CA" }), + ); + }); + + it("switches away from a manually picked country when the pasted prefix says otherwise", async () => { + render(); + + await userEvent.click(numberField()); + await userEvent.paste("+91 98765 43210"); + + expect(screen.getByText("+91")).toBeInTheDocument(); + expect(numberField()).toHaveValue("98765 43210"); + }); + + it("matches the longest dial code when the offered list has overlapping codes", async () => { + const overlapping = [ + { code: "AA", dialCode: "+1", name: "Alpha", flag: "\u{1F1E6}\u{1F1E7}" }, + { code: "BB", dialCode: "+123", name: "Beta", flag: "\u{1F1E7}\u{1F1E8}" }, + ]; + const onCountryChange = vi.fn(); + render( + , + ); + + await userEvent.click(numberField()); + await userEvent.paste("+12345"); + + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "BB" })); + expect(screen.getByText("+123")).toBeInTheDocument(); + expect(numberField()).toHaveValue("45"); + }); + + it("ignores a pasted dial code that is not among the offered countries", async () => { + const subset = COUNTRIES.filter((c) => ["US", "CA"].includes(c.code)); + const onCountryChange = vi.fn(); + render(); + + await userEvent.click(numberField()); + await userEvent.paste("+44 20 7946 0958"); + + expect(screen.getByText("\u{1F1FA}\u{1F1F8}")).toBeInTheDocument(); + expect(onCountryChange).not.toHaveBeenCalled(); + expect(numberField()).toHaveValue("+44 20 7946 0958"); + }); + + it("does not consume digits that were already in the field when + is typed", async () => { + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render( + , + ); + + const input = numberField(); + await userEvent.click(input); + input.setSelectionRange(0, 0); + await userEvent.keyboard("+"); + + // The 91 under the caret is part of the number, not a declared code: the + // value and the country must both stay put until the code is typed in full. + expect(input).toHaveValue("+9192"); + expect(onCountryChange).not.toHaveBeenCalled(); + expect(onValueChange).toHaveBeenLastCalledWith( + "+9192", + expect.objectContaining({ code: "IN" }), + ); + }); + + it("switches to US when +1 is typed in front of an existing number", async () => { + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render( + , + ); + + const input = numberField(); + await userEvent.click(input); + input.setSelectionRange(0, 0); + // "+" alone changes nothing, then "1" completes a code the user typed. + await userEvent.keyboard("+1"); + + expect(input).toHaveValue("9192"); + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(onCountryChange).toHaveBeenCalledTimes(1); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "US" })); + expect(onValueChange).toHaveBeenLastCalledWith("9192", expect.objectContaining({ code: "US" })); + }); + + it("never lets a typed code span digits that were already there", async () => { + const onCountryChange = vi.fn(); + render( + , + ); + + const input = numberField(); + await userEvent.click(input); + input.setSelectionRange(0, 0); + // "+4" would read as +49 (Germany) only by borrowing the old 9 — refused. + await userEvent.keyboard("+4"); + expect(input).toHaveValue("+49192"); + expect(onCountryChange).not.toHaveBeenCalled(); + + // A second 4 makes it +44 with both digits typed after the "+" — GB, and + // the pre-existing tail survives the strip. + await userEvent.keyboard("4"); + expect(input).toHaveValue("9192"); + expect(onCountryChange).toHaveBeenCalledTimes(1); + expect(onCountryChange).toHaveBeenCalledWith(expect.objectContaining({ code: "GB" })); + }); + + it("leaves a + typed at the end of the number alone", async () => { + const onCountryChange = vi.fn(); + render( + , + ); + + const input = numberField(); + await userEvent.click(input); + input.setSelectionRange(input.value.length, input.value.length); + await userEvent.keyboard("+"); + + expect(input).toHaveValue("9192+"); + expect(onCountryChange).not.toHaveBeenCalled(); + }); +}); + describe("PhoneInput keyboard navigation", () => { const searchBox = () => screen.getByRole("combobox", { name: "Search countries" }); const activeOptionId = () => searchBox().getAttribute("aria-activedescendant");