{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-input",
  "type": "registry:ui",
  "title": "Phone input",
  "description": "A phone field with a country picker, formatting as you type, and E.164 output.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/phone-input/phone-input.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  ClipboardEvent as ReactClipboardEvent,\n  CSSProperties,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/* ------------------------------------------------------------------------------------------------\n * Country table. Patterns format the national significant number (no trunk prefix); `#` is a digit.\n * Valid lengths are the digit capacities of the patterns. Examples are realistic mobile numbers and\n * double as the placeholder. Compact on purpose: no metadata download, no dependency.\n * ---------------------------------------------------------------------------------------------- */\n\nexport interface PhoneCountry {\n  /** ISO 3166-1 alpha-2 code, such as \"US\". */\n  iso: string;\n  name: string;\n  /** Country calling code without the plus, such as \"44\". */\n  dial: string;\n  /** National formats from shortest to longest. `#` is a digit, anything else is a separator. */\n  patterns: string[];\n  /** A national number people may type before the number itself, such as \"0\" in \"07911 123456\". */\n  trunk: string;\n  /** A realistic national number, used for the placeholder and the typing guide. */\n  example: string;\n  /** Area codes that pick this country when several share a calling code, such as Canada inside +1. */\n  areaCodes?: string[];\n}\n\nconst country = (\n  iso: string,\n  name: string,\n  dial: string,\n  patterns: string | string[],\n  example: string,\n  trunk = \"0\",\n  areaCodes?: string[],\n): PhoneCountry => ({\n  iso,\n  name,\n  dial,\n  patterns: Array.isArray(patterns) ? patterns : [patterns],\n  example,\n  trunk,\n  areaCodes,\n});\n\nconst CA_AREA_CODES =\n  \"204 226 236 249 250 263 289 306 343 354 365 367 368 382 387 403 416 418 428 431 437 438 450 468 474 506 514 519 548 579 581 584 587 604 613 639 647 672 683 705 709 742 753 778 780 782 807 819 825 867 873 879 902 905\".split(\n    \" \",\n  );\n\nexport const PHONE_COUNTRIES: PhoneCountry[] = [\n  country(\"US\", \"United States\", \"1\", \"(###) ###-####\", \"2015550123\", \"1\"),\n  country(\"CA\", \"Canada\", \"1\", \"(###) ###-####\", \"5065550123\", \"1\", CA_AREA_CODES),\n  country(\"MX\", \"Mexico\", \"52\", \"## #### ####\", \"2221234567\", \"\"),\n  country(\"BR\", \"Brazil\", \"55\", [\"(##) ####-####\", \"(##) #####-####\"], \"11961234567\"),\n  country(\"AR\", \"Argentina\", \"54\", \"## ####-####\", \"1123456789\"),\n  country(\"CO\", \"Colombia\", \"57\", \"### ### ####\", \"3211234567\", \"\"),\n  country(\"CL\", \"Chile\", \"56\", \"# #### ####\", \"221234567\", \"\"),\n  country(\"PE\", \"Peru\", \"51\", \"### ### ###\", \"912345678\"),\n  country(\"GB\", \"United Kingdom\", \"44\", \"#### ######\", \"7400123456\"),\n  country(\"IE\", \"Ireland\", \"353\", \"## ### ####\", \"850123456\"),\n  country(\"FR\", \"France\", \"33\", \"# ## ## ## ##\", \"612345678\"),\n  country(\"DE\", \"Germany\", \"49\", [\"### #######\", \"### ########\"], \"15123456789\"),\n  country(\"ES\", \"Spain\", \"34\", \"### ## ## ##\", \"612345678\", \"\"),\n  country(\"IT\", \"Italy\", \"39\", [\"### ### ###\", \"### ### ####\"], \"3123456789\", \"\"),\n  country(\"PT\", \"Portugal\", \"351\", \"### ### ###\", \"912345678\", \"\"),\n  country(\"NL\", \"Netherlands\", \"31\", \"# ########\", \"612345678\"),\n  country(\"BE\", \"Belgium\", \"32\", \"### ## ## ##\", \"470123456\"),\n  country(\"CH\", \"Switzerland\", \"41\", \"## ### ## ##\", \"781234567\"),\n  country(\"AT\", \"Austria\", \"43\", [\"### #######\", \"### ########\"], \"6641234567\"),\n  country(\"SE\", \"Sweden\", \"46\", \"## ### ## ##\", \"701234567\"),\n  country(\"NO\", \"Norway\", \"47\", \"### ## ###\", \"40612345\", \"\"),\n  country(\"DK\", \"Denmark\", \"45\", \"## ## ## ##\", \"32123456\", \"\"),\n  country(\"FI\", \"Finland\", \"358\", [\"## ### ####\", \"## ### #####\"], \"412345678\"),\n  country(\"PL\", \"Poland\", \"48\", \"### ### ###\", \"512345678\", \"\"),\n  country(\"CZ\", \"Czechia\", \"420\", \"### ### ###\", \"601123456\", \"\"),\n  country(\"GR\", \"Greece\", \"30\", \"### ### ####\", \"6912345678\", \"\"),\n  country(\"UA\", \"Ukraine\", \"380\", \"## ### ## ##\", \"501234567\"),\n  country(\"TR\", \"Turkey\", \"90\", \"### ### ## ##\", \"5012345678\"),\n  country(\"IL\", \"Israel\", \"972\", \"##-###-####\", \"502345678\"),\n  country(\"AE\", \"United Arab Emirates\", \"971\", \"## ### ####\", \"501234567\"),\n  country(\"SA\", \"Saudi Arabia\", \"966\", \"## ### ####\", \"512345678\"),\n  country(\"EG\", \"Egypt\", \"20\", \"### ### ####\", \"1001234567\"),\n  country(\"NG\", \"Nigeria\", \"234\", \"### ### ####\", \"8021234567\"),\n  country(\"KE\", \"Kenya\", \"254\", \"### ######\", \"712123456\"),\n  country(\"ZA\", \"South Africa\", \"27\", \"## ### ####\", \"711234567\"),\n  country(\"IN\", \"India\", \"91\", \"#####-#####\", \"8123456789\"),\n  country(\"PK\", \"Pakistan\", \"92\", \"### #######\", \"3012345678\"),\n  country(\"CN\", \"China\", \"86\", \"### #### ####\", \"13123456789\"),\n  country(\"JP\", \"Japan\", \"81\", \"##-####-####\", \"9012345678\"),\n  country(\"KR\", \"South Korea\", \"82\", [\"##-###-####\", \"##-####-####\"], \"1020000000\"),\n  country(\"TW\", \"Taiwan\", \"886\", \"### ### ###\", \"912345678\"),\n  country(\"HK\", \"Hong Kong\", \"852\", \"#### ####\", \"51234567\", \"\"),\n  country(\"SG\", \"Singapore\", \"65\", \"#### ####\", \"81234567\", \"\"),\n  country(\"PH\", \"Philippines\", \"63\", \"### ### ####\", \"9051234567\"),\n  country(\"ID\", \"Indonesia\", \"62\", [\"###-###-####\", \"###-####-####\", \"###-####-#####\"], \"81234567890\"),\n  country(\"TH\", \"Thailand\", \"66\", \"## ### ####\", \"812345678\"),\n  country(\"VN\", \"Vietnam\", \"84\", \"## ### ## ##\", \"912345678\"),\n  country(\"AU\", \"Australia\", \"61\", \"### ### ###\", \"412345678\"),\n  country(\"NZ\", \"New Zealand\", \"64\", [\"# ### ####\", \"## ### ####\", \"## #### ####\"], \"211234567\"),\n];\n\nconst BY_ISO = new Map(PHONE_COUNTRIES.map((entry) => [entry.iso, entry]));\nconst capacity = (pattern: string) => pattern.split(\"#\").length - 1;\nconst lengthsOf = (entry: PhoneCountry) => entry.patterns.map(capacity);\n/** A typed trunk prefix earns its own room; without one the longest valid length is the cap. */\nconst capDigits = (entry: PhoneCountry, digits: string) =>\n  digits.slice(0, splitTrunk(entry, digits)[0].length + Math.max(...lengthsOf(entry)));\nconst onlyDigits = (text: string) => text.replace(/\\D/g, \"\");\n\n/** Regional indicator letters. Platforms without flag glyphs show the two letters, which still read. */\nexport const flagOf = (iso: string) =>\n  String.fromCodePoint(\n    ...iso\n      .toUpperCase()\n      .split(\"\")\n      .map((char) => 0x1f1e6 + char.charCodeAt(0) - 65),\n  );\n\nfunction splitTrunk(entry: PhoneCountry, digits: string): [string, string] {\n  return entry.trunk && digits.startsWith(entry.trunk) ? [entry.trunk, digits.slice(entry.trunk.length)] : [\"\", digits];\n}\n\n/** Separators only print while digits follow, so a half typed number never ends in a dangling \") \" or \"-\". */\nfunction applyPattern(digits: string, pattern: string) {\n  let out = \"\",\n    index = 0;\n  for (const char of pattern) {\n    if (index >= digits.length) break;\n    out += char === \"#\" ? digits[index++] : char;\n  }\n  return out + digits.slice(index);\n}\n\nfunction patternFor(entry: PhoneCountry, length: number) {\n  return entry.patterns.find((pattern) => capacity(pattern) >= length) ?? entry.patterns[entry.patterns.length - 1]!;\n}\n\n/** Formats national digits as typed, keeping a typed trunk prefix: \"07911123456\" in the UK reads \"07911 123456\". */\nexport function formatNational(entry: PhoneCountry, digits: string) {\n  const [trunk, rest] = splitTrunk(entry, digits);\n  const pattern = patternFor(entry, rest.length);\n  const body = applyPattern(rest, pattern);\n  if (!trunk) return body;\n  if (!rest) return trunk;\n  return trunk + (pattern.startsWith(\"#\") ? \"\" : \" \") + body;\n}\n\nexport type PhoneStatus = \"empty\" | \"incomplete\" | \"valid\" | \"too-long\";\n\nfunction statusOf(entry: PhoneCountry, digits: string): PhoneStatus {\n  const length = splitTrunk(entry, digits)[1].length;\n  const lengths = lengthsOf(entry);\n  if (!length) return \"empty\";\n  if (lengths.includes(length)) return \"valid\";\n  return length > Math.max(...lengths) ? \"too-long\" : \"incomplete\";\n}\n\nconst toE164 = (entry: PhoneCountry, digits: string) => {\n  const rest = splitTrunk(entry, digits)[1];\n  return rest ? `+${entry.dial}${rest}` : \"\";\n};\n\n/** Reads \"+44 (0)7911 123456\", \"0044 7911…\", or \"+14165550123\" into a country and national digits. Longest calling code wins. */\nexport function parsePhoneNumber(\n  input: string,\n  pool: PhoneCountry[] = PHONE_COUNTRIES,\n): { country: PhoneCountry; national: string } | null {\n  const trimmed = input.trim();\n  let digits = onlyDigits(trimmed);\n  if (!trimmed.startsWith(\"+\")) {\n    if (!trimmed.startsWith(\"00\")) return null;\n    digits = digits.slice(2);\n  }\n  for (const size of [3, 2, 1]) {\n    const dial = digits.slice(0, size);\n    const matches = pool.filter((entry) => entry.dial === dial);\n    if (!matches.length) continue;\n    let rest = digits.slice(size);\n    const entry =\n      matches.find((item) => item.areaCodes?.some((code) => rest.startsWith(code))) ??\n      matches.find((item) => !item.areaCodes) ??\n      matches[0]!;\n    // \"+44 (0)7911…\" carries a trunk zero it should not; drop it when the rest is still a full number without it.\n    if (\n      entry.trunk &&\n      rest.startsWith(entry.trunk) &&\n      rest.length - entry.trunk.length >= Math.min(...lengthsOf(entry))\n    )\n      rest = rest.slice(entry.trunk.length);\n    return { country: entry, national: capDigits(entry, rest) };\n  }\n  return null;\n}\n\n/** Formats an E.164 number for display, such as \"+44 7400 123456\". Returns the input when it cannot be read. */\nexport function formatPhoneNumber(e164: string) {\n  const parsed = parsePhoneNumber(e164);\n  return parsed ? `+${parsed.country.dial} ${formatNational(parsed.country, parsed.national)}` : e164;\n}\n\n/* ------------------------------------------------------------------------------------------------ */\n\nexport interface PhoneInputDetails {\n  country: PhoneCountry;\n  /** The number as shown in the field, without the calling code. */\n  formatted: string;\n  status: PhoneStatus;\n  valid: boolean;\n}\n\nexport interface PhoneInputProps {\n  label: string;\n  hideLabel?: boolean;\n  /** The number in E.164, such as \"+14155550132\". An empty string clears the field. */\n  value?: string;\n  defaultValue?: string;\n  /** Fires on every edit with the E.164 number (empty when there are no digits) and its parsed details. */\n  onValueChange?: (value: string, details: PhoneInputDetails) => void;\n  /** ISO code of the selected country. */\n  country?: string;\n  /** ISO code used until someone picks a country or enters an international number. */\n  defaultCountry?: string;\n  onCountryChange?: (iso: string) => void;\n  /** Limit the picker to these ISO codes. */\n  countries?: string[];\n  /** Pinned at the top of the picker under \"Suggested\". */\n  preferredCountries?: string[];\n  description?: string;\n  /** Replaces the built-in validation message. */\n  error?: string;\n  /** Show a message after blur when the number is incomplete. On by default. */\n  validate?: boolean;\n  disabled?: boolean;\n  required?: boolean;\n  /** Adds a hidden input carrying the E.164 value for native form submission. */\n  name?: string;\n  id?: string;\n  className?: string;\n  onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;\n}\n\ntype Bezier = [number, number, number, number];\nconst enterEase = [...motionTokens.ease.enter] as Bezier;\nconst standardEase = [...motionTokens.ease.standard] as Bezier;\nconst { blur } = motionTokens;\n/** Duration springs restated as stiffness and damping, so a retarget mid flight keeps the velocity it already has. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(0.4, 0),\n  SHRINK = physical(0.32, 0),\n  GLIDE = physical(0.28, 0),\n  WIDTH = physical(0.42, 0);\nconst PANEL_MAX = 340,\n  CLOSED_RADIUS = 17,\n  OPEN_RADIUS = 22,\n  PAGE = 8;\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\n/** The flag and calling code roll in the direction of the list: a country further down rises from below. */\nconst layerVariants: Variants = {\n  enter: (dir: number) => ({ opacity: 0, y: `${dir * 0.6}em`, filter: `blur(${blur.soft}px)` }),\n  rest: { opacity: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } },\n  exit: (dir: number) => ({\n    opacity: 0,\n    y: `${dir * -0.5}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: 0.12, ease: standardEase },\n  }),\n};\nconst layerFade: Variants = {\n  enter: { opacity: 0 },\n  rest: { opacity: 1, y: 0, filter: \"none\" },\n  exit: { opacity: 0, transition: { duration: 0.08 } },\n};\n\nfunction matchesQuery(entry: PhoneCountry, needle: string) {\n  if (!needle) return true;\n  const digits = onlyDigits(needle);\n  if (digits && /^[+\\d\\s()-]+$/.test(needle)) return entry.dial.startsWith(digits) || digits.startsWith(entry.dial);\n  if (needle.replace(\"+\", \"\") === \"\") return true;\n  return entry.name.toLowerCase().includes(needle) || entry.iso.toLowerCase() === needle;\n}\n\n/** Position in the formatted text just after the nth digit. */\nfunction caretAfterDigits(text: string, count: number) {\n  if (count <= 0) {\n    const first = text.search(/\\d/);\n    return first < 0 ? text.length : Math.min(first, text.length);\n  }\n  let seen = 0;\n  for (let index = 0; index < text.length; index++) if (/\\d/.test(text[index]!) && ++seen === count) return index + 1;\n  return text.length;\n}\n\nfunction MessageRow({ id, text, tone }: { id: string; text: string; tone: \"hint\" | \"error\" }) {\n  const reduced = useReducedFlag();\n  // The row's space is padding inside the clipped slot, so the height animates from a true zero.\n  return (\n    <motion.span\n      className=\"relative block overflow-hidden\"\n      initial={reduced ? false : { height: 0, opacity: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={text}\n          id={id}\n          className={cn(\"block pt-2 text-xs leading-body\", tone === \"error\" ? \"text-red\" : \"text-ink-3\")}\n          role={tone === \"error\" ? \"alert\" : undefined}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${blur.soft}px)` }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={{ opacity: 0, transition: { duration: 0.1 } }}\n          transition={{ duration: reduced ? 0.12 : motionTokens.duration.standard, ease: enterEase }}\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\n/** Flags draw from the colour emoji fonts before the body font. */\nconst flagClass =\n  \"inline-grid w-5 flex-none place-items-center [font-family:'Apple_Color_Emoji','Segoe_UI_Emoji','Noto_Color_Emoji',var(--font-body)] text-[17px] leading-none font-normal\";\nconst groupClass = \"grid gap-0.5 [&+&]:mt-1\";\n\ntype Row = { key: string; entry: PhoneCountry };\ntype Section = { key: string; label?: string; rows: Row[] };\n\n/**\n * A phone number field with a country picker. The country button grows into a searchable list, the number formats as it is\n * typed with a faint guide showing the rest of the expected shape, pasted or autofilled international numbers pick their own\n * country, and the value comes out in E.164. Typing \"+\" in the number jumps into the picker with a calling code search.\n */\nexport const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(\n  {\n    label,\n    hideLabel = false,\n    value,\n    defaultValue,\n    onValueChange,\n    country: countryProp,\n    defaultCountry = \"US\",\n    onCountryChange,\n    countries,\n    preferredCountries = [\"US\", \"CA\", \"GB\"],\n    description,\n    error,\n    validate = true,\n    disabled = false,\n    required,\n    name,\n    id,\n    className,\n    onBlur,\n  },\n  forwardedRef,\n) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const inputId = id ?? `${uid}-number`;\n  const listId = `${uid}-list`;\n  const hintId = `${inputId}-hint`,\n    errorId = `${inputId}-error`;\n  const optionId = (key: string) => `${uid}-opt-${key}`;\n\n  const pool = useMemo(\n    () => (countries?.length ? PHONE_COUNTRIES.filter((entry) => countries.includes(entry.iso)) : PHONE_COUNTRIES),\n    [countries],\n  );\n  const fallback = BY_ISO.get(defaultCountry) ?? PHONE_COUNTRIES[0]!;\n\n  const [state, setState] = useState(() => {\n    const parsed = parsePhoneNumber(value ?? defaultValue ?? \"\", pool);\n    return parsed ? { iso: parsed.country.iso, digits: parsed.national } : { iso: fallback.iso, digits: \"\" };\n  });\n  const iso = countryProp ?? state.iso;\n  const current = BY_ISO.get(iso) ?? fallback;\n  const digits = state.digits;\n  const e164 = toE164(current, digits);\n\n  // A controlled value that differs from what the field last produced is read back in.\n  const [seenValue, setSeenValue] = useState(value);\n  if (value !== seenValue) {\n    setSeenValue(value);\n    if (value !== undefined && value !== e164) {\n      const parsed = parsePhoneNumber(value, pool);\n      setState(parsed ? { iso: parsed.country.iso, digits: parsed.national } : { iso, digits: \"\" });\n    }\n  }\n\n  const formatted = formatNational(current, digits);\n  const status = statusOf(current, digits);\n  const [touched, setTouched] = useState(false);\n  const lengths = lengthsOf(current);\n  const lengthCopy =\n    lengths.length === 1 ? `${lengths[0]}` : `${lengths.slice(0, -1).join(\", \")} or ${lengths[lengths.length - 1]}`;\n  const builtIn =\n    validate && touched && (status === \"incomplete\" || status === \"too-long\")\n      ? `Numbers in ${current.name} have ${lengthCopy} digits`\n      : undefined;\n  const message = error ?? builtIn;\n\n  /* The guide: the rest of the example number, in the shape this country expects, drawn faintly after what was typed. */\n  const guide = useMemo(() => {\n    const [trunk, rest] = splitTrunk(current, digits);\n    if (rest.length >= current.example.length) return \"\";\n    const full = formatNational(current, trunk + rest + current.example.slice(rest.length));\n    return full.startsWith(formatted) ? full.slice(formatted.length) : \"\";\n  }, [current, digits, formatted]);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);\n  const pendingCaret = useRef<number | null>(null);\n  const [, rerender] = useReducer((count: number) => count + 1, 0);\n\n  const emit = useCallback(\n    (entry: PhoneCountry, nextDigits: string) => {\n      const nextStatus = statusOf(entry, nextDigits);\n      onValueChange?.(toE164(entry, nextDigits), {\n        country: entry,\n        formatted: formatNational(entry, nextDigits),\n        status: nextStatus,\n        valid: nextStatus === \"valid\",\n      });\n    },\n    [onValueChange],\n  );\n\n  const setNumber = (nextDigits: string, caretDigits: number | null, entry = current) => {\n    const capped = capDigits(entry, nextDigits);\n    pendingCaret.current = caretDigits === null ? null : Math.min(caretDigits, capped.length);\n    if (entry.iso !== current.iso) onCountryChange?.(entry.iso);\n    // Nothing changed (a refused digit): React restores the old text after this handler, so the caret is placed on the next render.\n    if (capped === digits && entry.iso === current.iso) {\n      rerender();\n      return;\n    }\n    setState({ iso: entry.iso, digits: capped });\n    emit(entry, capped);\n  };\n\n  function placeCaret() {\n    const input = inputRef.current,\n      count = pendingCaret.current;\n    pendingCaret.current = null;\n    if (!input || count === null || document.activeElement !== input) return;\n    const position = caretAfterDigits(input.value, count);\n    input.setSelectionRange(position, position);\n  }\n  useLayoutEffect(placeCaret);\n\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  /* ---------------------------------------------- Number entry ---------------------------------------------- */\n\n  function onNumberChange(input: HTMLInputElement) {\n    const raw = input.value;\n    // Autofill and dropped text arrive as a change: an international number chooses its own country.\n    if (raw.includes(\"+\") || (/^\\s*00/.test(raw) && onlyDigits(raw).length > 6)) {\n      const parsed = parsePhoneNumber(raw.slice(Math.max(0, raw.indexOf(\"+\"))), pool);\n      if (parsed && parsed.national) {\n        if (parsed.country.iso !== current.iso) setAnnouncement(`Country set to ${parsed.country.name}`);\n        setNumber(parsed.national, parsed.national.length, parsed.country);\n        return;\n      }\n    }\n    const caret = input.selectionStart ?? raw.length;\n    setNumber(onlyDigits(raw), onlyDigits(raw.slice(0, caret)).length);\n  }\n\n  function onNumberKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    const input = event.currentTarget;\n    if (event.key === \"+\" && !event.metaKey && !event.ctrlKey) {\n      event.preventDefault();\n      openList(\"+\");\n      return;\n    }\n    const start = input.selectionStart ?? 0,\n      end = input.selectionEnd ?? 0;\n    if (start !== end || event.metaKey || event.ctrlKey || event.altKey) return;\n    // Deleting a separator deletes the digit beside it instead of doing nothing.\n    if (event.key === \"Backspace\" && start > 0 && !/\\d/.test(input.value[start - 1]!)) {\n      event.preventDefault();\n      const index = onlyDigits(input.value.slice(0, start)).length;\n      if (index > 0) setNumber(digits.slice(0, index - 1) + digits.slice(index), index - 1);\n    }\n    if (event.key === \"Delete\" && start < input.value.length && !/\\d/.test(input.value[start]!)) {\n      event.preventDefault();\n      const index = onlyDigits(input.value.slice(0, start)).length;\n      setNumber(digits.slice(0, index) + digits.slice(index + 1), index);\n    }\n  }\n\n  function onPaste(event: ReactClipboardEvent<HTMLInputElement>) {\n    const text = event.clipboardData.getData(\"text\");\n    if (!text) return;\n    event.preventDefault();\n    const input = event.currentTarget;\n    const parsed = /^\\s*(\\+|00)/.test(text) ? parsePhoneNumber(text, pool) : null;\n    if (parsed) {\n      if (parsed.country.iso !== current.iso) setAnnouncement(`Country set to ${parsed.country.name}`);\n      setNumber(parsed.national, parsed.national.length, parsed.country);\n      return;\n    }\n    let pasted = onlyDigits(text);\n    // \"1 415 555 0132\" pasted into a US field: the leading calling code is dropped when the number would not fit otherwise.\n    if (pasted.startsWith(current.dial) && pasted.length > capDigits(current, pasted).length)\n      pasted = pasted.slice(current.dial.length);\n    // A whole number replaces what was there; a fragment goes in at the caret.\n    if (pasted.length >= Math.min(...lengthsOf(current))) {\n      setNumber(pasted, pasted.length);\n      return;\n    }\n    const a = onlyDigits(input.value.slice(0, input.selectionStart ?? 0)).length;\n    const b = onlyDigits(input.value.slice(0, input.selectionEnd ?? 0)).length;\n    setNumber(digits.slice(0, a) + pasted + digits.slice(b), a + pasted.length);\n  }\n\n  /* ---------------------------------------------- Country picker ---------------------------------------------- */\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState<string | null>(null);\n  const [roll, setRoll] = useState(1);\n  const needle = query.trim().toLowerCase();\n\n  const sections = useMemo<Section[]>(() => {\n    const sorted = [...pool].sort((a, b) => a.name.localeCompare(b.name));\n    if (needle && needle !== \"+\") {\n      const hits = sorted.filter((entry) => matchesQuery(entry, needle));\n      const digitsOnly = onlyDigits(needle);\n      if (digitsOnly)\n        hits.sort(\n          (a, b) => Number(b.dial === digitsOnly) - Number(a.dial === digitsOnly) || a.dial.length - b.dial.length,\n        );\n      return [{ key: \"results\", rows: hits.map((entry) => ({ key: `r-${entry.iso}`, entry })) }];\n    }\n    const preferred = preferredCountries\n      .map((code) => pool.find((entry) => entry.iso === code))\n      .filter((entry): entry is PhoneCountry => !!entry);\n    const all = {\n      key: \"all\",\n      label: preferred.length ? \"All countries\" : undefined,\n      rows: sorted.map((entry) => ({ key: `a-${entry.iso}`, entry })),\n    };\n    return preferred.length\n      ? [\n          { key: \"preferred\", label: \"Suggested\", rows: preferred.map((entry) => ({ key: `p-${entry.iso}`, entry })) },\n          all,\n        ]\n      : [all];\n  }, [needle, pool, preferredCountries]);\n  const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]);\n  const activeKey = active !== null && rows.some((row) => row.key === active) ? active : (rows[0]?.key ?? null);\n  const orderOf = useMemo(\n    () => new Map([...pool].sort((a, b) => a.name.localeCompare(b.name)).map((entry, index) => [entry.iso, index])),\n    [pool],\n  );\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const controlRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const listFaceRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n  const rowRefs = useRef(new Map<string, HTMLElement>());\n\n  /* One surface whose width, height, and corners spring between the country button and the open list. */\n  const anchorW = useMotionValue<number | string>(\"auto\");\n  const shapeW = useMotionValue<number | string>(\"100%\"),\n    shapeH = useMotionValue<number | string>(\"100%\"),\n    radius = useMotionValue(CLOSED_RADIUS);\n  const sizes = useRef({ trigger: 0, lid: 0, panel: 0, list: 0 });\n  const live = useRef({ open: false, reduced: false, measured: false });\n  useLayoutEffect(() => {\n    live.current.reduced = reduced;\n  }, [reduced]);\n\n  const place = useCallback(\n    (animated: boolean) => {\n      const { trigger, lid, panel, list } = sizes.current;\n      if (!trigger || !lid) return;\n      const isOpen = live.current.open;\n      const next = isOpen\n        ? { w: Math.max(trigger, panel), h: lid + 2 + list, r: OPEN_RADIUS }\n        : { w: trigger, h: lid, r: CLOSED_RADIUS };\n      if (!animated || live.current.reduced || !live.current.measured) {\n        anchorW.jump(trigger);\n        shapeW.jump(next.w);\n        shapeH.jump(next.h);\n        radius.jump(next.r);\n        live.current.measured = true;\n        return;\n      }\n      const growing = isOpen;\n      animate(anchorW, trigger, WIDTH);\n      animate(shapeW, next.w, growing ? GROW : SHRINK);\n      animate(shapeH, next.h, growing ? GROW : SHRINK);\n      animate(radius, next.r, growing ? GROW : SHRINK);\n    },\n    [anchorW, radius, shapeH, shapeW],\n  );\n\n  const read = useCallback(() => {\n    const measure = measureRef.current,\n      control = controlRef.current,\n      face = listFaceRef.current,\n      root = rootRef.current;\n    if (!measure || !control || !face || !root) return false;\n    const panel = Math.min(PANEL_MAX, control.offsetWidth);\n    root.style.setProperty(\"--pi-panel-w\", `${panel}px`);\n    const previous = sizes.current;\n    const next = { trigger: measure.offsetWidth, lid: control.clientHeight, panel, list: face.offsetHeight };\n    sizes.current = next;\n    return (\n      next.trigger !== previous.trigger ||\n      next.lid !== previous.lid ||\n      next.panel !== previous.panel ||\n      (live.current.open && next.list !== previous.list)\n    );\n  }, []);\n\n  useLayoutEffect(() => {\n    const measure = measureRef.current,\n      control = controlRef.current,\n      face = listFaceRef.current;\n    if (read()) place(live.current.measured);\n    if (!measure || !control || !face || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (read()) place(live.current.measured);\n    });\n    observer.observe(measure);\n    observer.observe(control);\n    observer.observe(face);\n    return () => observer.disconnect();\n  }, [place, read]);\n\n  const pendingFocus = useRef<\"trigger\" | \"search\" | \"number\" | null>(null);\n  useLayoutEffect(() => {\n    if (live.current.open !== open) {\n      live.current.open = open;\n      read();\n      place(true);\n    }\n    const target = pendingFocus.current;\n    pendingFocus.current = null;\n    if (target === \"search\") searchRef.current?.focus({ preventScroll: true });\n    if (target === \"trigger\") triggerRef.current?.focus({ preventScroll: true });\n    if (target === \"number\") {\n      const input = inputRef.current;\n      input?.focus({ preventScroll: true });\n      input?.setSelectionRange(input.value.length, input.value.length);\n    }\n  }, [open, place, read]);\n\n  /* The highlight glides between rows on its own spring. */\n  const hy = useMotionValue(0),\n    hh = useMotionValue(0),\n    ho = useMotionValue(0);\n  const scrollIntent = useRef(false);\n  useLayoutEffect(() => {\n    const node = open && activeKey ? rowRefs.current.get(activeKey) : undefined;\n    if (!node) {\n      animate(ho, 0, { duration: reduced || !open ? 0 : 0.12, ease: standardEase });\n      return;\n    }\n    const top = node.offsetTop,\n      height = node.offsetHeight;\n    if (ho.get() < 0.05 || reduced) {\n      hy.jump(top);\n      hh.jump(height);\n    } else {\n      animate(hy, top, GLIDE);\n      animate(hh, height, GLIDE);\n    }\n    animate(ho, 1, { duration: reduced ? 0 : 0.12, ease: enterEase });\n    const scroller = scrollRef.current;\n    if (scroller && scrollIntent.current) {\n      scrollIntent.current = false;\n      const pad = 6;\n      if (top < scroller.scrollTop + pad) scroller.scrollTop = top - pad;\n      else if (top + height > scroller.scrollTop + scroller.clientHeight - pad)\n        scroller.scrollTop = top + height - scroller.clientHeight + pad;\n    }\n  }, [activeKey, hh, ho, hy, open, reduced, sections]);\n\n  function openList(seed = \"\") {\n    if (disabled || open) return;\n    setQuery(seed);\n    const selectedRow = seed\n      ? null\n      : (sections\n          .flatMap((section) => section.rows)\n          .find((row) => row.entry.iso === current.iso && !row.key.startsWith(\"p-\")) ?? null);\n    setActive(selectedRow?.key ?? null);\n    scrollIntent.current = true;\n    pendingFocus.current = \"search\";\n    setAnnouncement(\"\");\n    setOpen(true);\n  }\n  const close = useCallback(\n    (focus: \"trigger\" | \"number\" | null) => {\n      pendingFocus.current = focus;\n      setOpen(false);\n    },\n    [setOpen],\n  );\n\n  function pick(entry: PhoneCountry | undefined) {\n    if (!entry) return;\n    if (entry.iso !== current.iso) {\n      setRoll(Math.sign((orderOf.get(entry.iso) ?? 0) - (orderOf.get(current.iso) ?? 0)) || 1);\n      const nextDigits = capDigits(entry, digits);\n      if (countryProp === undefined) setState({ iso: entry.iso, digits: nextDigits });\n      else setState((last) => ({ ...last, digits: nextDigits }));\n      onCountryChange?.(entry.iso);\n      emit(entry, nextDigits);\n      setAnnouncement(`${entry.name}, +${entry.dial}`);\n    }\n    close(\"number\");\n  }\n\n  function move(key: string | null | undefined) {\n    if (!key) return;\n    scrollIntent.current = true;\n    setActive(key);\n  }\n\n  function onSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    const at = rows.findIndex((row) => row.key === activeKey);\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        move(rows[Math.min(rows.length - 1, at + 1)]?.key);\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        move(rows[Math.max(0, at - 1)]?.key);\n        return;\n      case \"PageDown\":\n        event.preventDefault();\n        move(rows[Math.min(rows.length - 1, at + PAGE)]?.key);\n        return;\n      case \"PageUp\":\n        event.preventDefault();\n        move(rows[Math.max(0, at - PAGE)]?.key);\n        return;\n      case \"Home\":\n        if (query) return;\n        event.preventDefault();\n        move(rows[0]?.key);\n        return;\n      case \"End\":\n        if (query) return;\n        event.preventDefault();\n        move(rows[rows.length - 1]?.key);\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        pick(rows[at]?.entry);\n        return;\n      case \"Escape\":\n        event.preventDefault();\n        event.stopPropagation();\n        close(\"trigger\");\n        return;\n      case \"Tab\":\n        close(null);\n        return;\n    }\n  }\n\n  function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      openList();\n      return;\n    }\n    if (event.key.length === 1 && event.key !== \" \" && !event.metaKey && !event.ctrlKey && !event.altKey) {\n      event.preventDefault();\n      openList(event.key);\n    }\n  }\n\n  function onQuery(next: string) {\n    setQuery(next);\n    setActive(null);\n    if (scrollRef.current) scrollRef.current.scrollTop = 0;\n    const text = next.trim().toLowerCase();\n    const count = text && text !== \"+\" ? pool.filter((entry) => matchesQuery(entry, text)).length : pool.length;\n    setAnnouncement(text ? (count ? `${count} ${count === 1 ? \"country\" : \"countries\"}` : \"No matches\") : \"\");\n  }\n\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) close(null);\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [close, open]);\n\n  const onRootBlur = (event: ReactFocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget as Node | null;\n    if (open && next && !event.currentTarget.contains(next)) close(null);\n  };\n\n  const showCheck = status === \"valid\";\n  const invalid = !!message;\n  const describedBy = [description ? hintId : null, message ? errorId : null].filter(Boolean).join(\" \") || undefined;\n  const rootStyle = { \"--pi-closed-r\": `${CLOSED_RADIUS}px` } as CSSProperties;\n\n  const face = (entry: PhoneCountry) => (\n    <>\n      <span className={flagClass} aria-hidden=\"true\">\n        {flagOf(entry.iso)}\n      </span>\n      <span className=\"tabular-nums\">+{entry.dial}</span>\n    </>\n  );\n\n  // No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead.\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"group/field relative grid min-w-0 font-body tracking-body [--pi-h:var(--control-height-md)] [--pi-panel-w:320px] data-open:z-30\",\n        className,\n      )}\n      style={rootStyle}\n      data-open={open || undefined}\n      data-disabled={disabled || undefined}\n      onBlur={onRootBlur}\n    >\n      <label\n        htmlFor={inputId}\n        className={hideLabel ? \"sr-only\" : \"mb-2 justify-self-start text-sm leading-body font-medium text-ink\"}\n      >\n        {label}\n      </label>\n\n      {/* The shell matches the labeled input: one border, one radius, and a quiet border change on hover and focus. */}\n      <div\n        ref={controlRef}\n        className=\"relative box-border flex h-(--pi-h) min-w-0 rounded-control border border-line-strong bg-surface transition-[border-color] duration-fast ease-standard hover:not-data-invalid:border-ink has-[input[type=tel]:focus]:border-ink data-invalid:not-has-[input[type=tel]:focus]:border-red group-data-disabled/field:bg-hover group-data-disabled/field:opacity-50 motion-reduce:duration-0\"\n        data-invalid={invalid || undefined}\n      >\n        {/* The anchor holds the closed button's place and springs to a new width when the calling code changes length. */}\n        <motion.div\n          className=\"relative h-full flex-none after:absolute after:top-2.5 after:right-0 after:bottom-2.5 after:w-px after:bg-line after:transition-opacity after:duration-fast after:ease-standard after:content-[''] group-data-open/field:after:opacity-0\"\n          style={{ width: anchorW }}\n        >\n          {/* Sizes the closed button: same padding and content, so the width spring has a target before anything moves. */}\n          <span\n            ref={measureRef}\n            className=\"box-border flex items-center text-sm/[inherit] font-medium whitespace-nowrap pointer-events-none invisible h-[calc(var(--pi-h)_-_2px)] w-max pr-9 pl-3\"\n            aria-hidden=\"true\"\n          >\n            {face(current)}\n          </span>\n\n          {/* One material for both states. Open, it steps out over the shell's own border so the two edges never double up. */}\n          <motion.div\n            className=\"absolute top-0 left-0 z-2 overflow-clip bg-surface [transition:box-shadow_var(--duration-considered)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] after:pointer-events-none after:absolute after:inset-0 after:z-3 after:rounded-[inherit] after:border after:border-line after:opacity-0 after:transition-opacity after:duration-fast after:ease-standard after:content-[''] group-data-open/field:[translate:-1px_-1px] group-data-open/field:bg-surface group-data-open/field:shadow-floating group-data-open/field:after:opacity-100 group-data-disabled/field:bg-transparent motion-reduce:duration-0 motion-reduce:after:duration-0 contrast-more:after:border-line-strong\"\n            style={{ width: shapeW, height: shapeH, borderRadius: radius }}\n          >\n            <div className=\"absolute top-0 right-0 left-0 z-1 h-[calc(var(--pi-h)_-_2px)]\">\n              <button\n                ref={triggerRef}\n                type=\"button\"\n                className=\"box-border flex items-center text-sm/[inherit] font-medium whitespace-nowrap absolute inset-y-[3px] right-auto left-[3px] m-0 h-auto w-[calc(100%_-_6px)] cursor-pointer touch-manipulation rounded-button border-0 bg-transparent pr-[33px] pl-[9px] text-left text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard hover:not-group-data-open/field:not-group-data-disabled/field:bg-hover focus-visible:bg-hover active:enabled:bg-hover disabled:cursor-not-allowed\"\n                disabled={disabled}\n                inert={open || undefined}\n                tabIndex={open ? -1 : 0}\n                aria-haspopup=\"listbox\"\n                aria-expanded={open}\n                aria-controls={listId}\n                aria-label={`Country, ${current.name} +${current.dial}`}\n                onClick={() => (open ? close(\"trigger\") : openList())}\n                onKeyDown={onTriggerKeyDown}\n              >\n                <span\n                  className=\"relative block h-full min-w-0 flex-1 data-hidden:opacity-0 data-hidden:transition-opacity data-hidden:duration-instant data-hidden:ease-standard motion-reduce:duration-0\"\n                  data-hidden={open || undefined}\n                >\n                  <AnimatePresence initial={false} custom={roll}>\n                    <motion.span\n                      key={current.iso}\n                      className=\"absolute inset-0 flex items-center gap-1.5\"\n                      custom={roll}\n                      variants={reduced ? layerFade : layerVariants}\n                      initial=\"enter\"\n                      animate=\"rest\"\n                      exit=\"exit\"\n                      transition={\n                        reduced\n                          ? { duration: 0.12 }\n                          : {\n                              y: GLIDE,\n                              opacity: { duration: 0.2, ease: enterEase },\n                              filter: { duration: 0.22, ease: enterEase },\n                            }\n                      }\n                    >\n                      {face(current)}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n              </button>\n\n              {/* Search takes the lid's place while the list is open. */}\n              <motion.div\n                className=\"absolute inset-0 flex items-center gap-2 pr-0.5 pl-[13px] [&[inert]]:pointer-events-none\"\n                inert={!open || undefined}\n                initial={false}\n                animate={\n                  open\n                    ? { opacity: 1, filter: \"blur(0px)\" }\n                    : { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }\n                }\n                transition={\n                  open\n                    ? { duration: 0.18, ease: enterEase, delay: reduced ? 0 : 0.05 }\n                    : { duration: 0.1, ease: standardEase }\n                }\n              >\n                <Search className=\"flex-none text-ink-3\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                <input\n                  ref={searchRef}\n                  className=\"h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-sm/[inherit] tracking-body text-ink outline-none placeholder:text-ink-3\"\n                  type=\"text\"\n                  role=\"combobox\"\n                  aria-label=\"Search countries or calling codes\"\n                  aria-expanded={open}\n                  aria-controls={listId}\n                  aria-autocomplete=\"list\"\n                  aria-activedescendant={open && activeKey ? optionId(activeKey) : undefined}\n                  placeholder=\"Country or code\"\n                  value={query}\n                  autoComplete=\"off\"\n                  spellCheck={false}\n                  onChange={(event) => onQuery(event.target.value)}\n                  onKeyDown={onSearchKeyDown}\n                />\n                <AnimatePresence initial={false}>\n                  {query && (\n                    <motion.button\n                      key=\"clear\"\n                      type=\"button\"\n                      className=\"grid size-6 flex-none cursor-pointer place-items-center rounded-button border-0 bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] hover:text-ink focus-visible:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)]\"\n                      aria-label=\"Clear search\"\n                      onPointerDown={(event) => event.preventDefault()}\n                      onClick={() => {\n                        onQuery(\"\");\n                        searchRef.current?.focus();\n                      }}\n                      initial={{ opacity: 0, scale: 0.6 }}\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                      transition={reduced ? { duration: 0 } : GLIDE}\n                    >\n                      <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                    </motion.button>\n                  )}\n                </AnimatePresence>\n                {/* Sits over the shared chevron, so the chevron closes the list. */}\n                <button\n                  type=\"button\"\n                  className=\"size-9 flex-none cursor-pointer rounded-button border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard hover:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] focus-visible:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)]\"\n                  aria-label=\"Close country list\"\n                  onClick={() => close(\"trigger\")}\n                />\n              </motion.div>\n\n              <motion.span\n                className=\"pointer-events-none absolute top-1/2 right-3 -mt-2 grid size-4 place-items-center text-ink-3\"\n                aria-hidden=\"true\"\n                initial={false}\n                animate={{ rotate: open ? 180 : 0 }}\n                transition={reduced ? { duration: 0 } : GLIDE}\n              >\n                <ChevronDown size={16} strokeWidth={1.75} />\n              </motion.span>\n            </div>\n\n            {/* The list hangs under the lid at its final width, so rows never reflow while the surface springs open. */}\n            <motion.div\n              ref={listFaceRef}\n              className=\"absolute top-[calc(var(--pi-h)_-_2px)] left-0 box-border w-(--pi-panel-w) px-1.5 pb-1.5 before:absolute before:top-0 before:right-3.5 before:left-3.5 before:h-px before:bg-line-soft before:content-[''] [&[inert]]:pointer-events-none\"\n              inert={!open || undefined}\n              aria-hidden={!open || undefined}\n              initial={false}\n              animate={\n                open\n                  ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                  : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }\n              }\n              transition={\n                open\n                  ? {\n                      y: GROW,\n                      opacity: { duration: 0.2, ease: enterEase, delay: reduced ? 0 : 0.04 },\n                      filter: { duration: 0.22, ease: enterEase, delay: 0.04 },\n                    }\n                  : { duration: 0.1, ease: standardEase }\n              }\n            >\n              <div\n                ref={scrollRef}\n                className=\"max-h-[280px] overflow-y-auto overscroll-contain pt-1.5 [scrollbar-width:thin]\"\n              >\n                <div id={listId} className=\"relative grid gap-0.5\" role=\"listbox\" aria-label=\"Countries\">\n                  <motion.span\n                    className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[16px] bg-[color-mix(in_oklab,var(--ink)_6.5%,transparent)]\"\n                    style={{ y: hy, height: hh, opacity: ho }}\n                    aria-hidden=\"true\"\n                  />\n                  {sections.map((section) => {\n                    const body = section.rows.map((row) => {\n                      const selected = row.entry.iso === current.iso;\n                      return (\n                        // oxlint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -- options are reached through aria-activedescendant; the search field handles the keys, and <option> only works inside <select>\n                        <div\n                          key={row.key}\n                          id={optionId(row.key)}\n                          role=\"option\"\n                          aria-selected={selected}\n                          className=\"relative box-border flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[16px] px-2.5 text-sm/[inherit] text-ink-2 transition-[color] duration-fast ease-standard select-none aria-selected:text-ink data-active:text-ink\"\n                          data-active={row.key === activeKey || undefined}\n                          ref={(node) => {\n                            if (node) rowRefs.current.set(row.key, node);\n                            else rowRefs.current.delete(row.key);\n                          }}\n                          onPointerMove={(event) => {\n                            if (event.pointerType === \"mouse\" && row.key !== activeKey) setActive(row.key);\n                          }}\n                          onPointerDown={(event) => event.preventDefault()}\n                          onClick={() => pick(row.entry)}\n                        >\n                          <span className={flagClass} aria-hidden=\"true\">\n                            {flagOf(row.entry.iso)}\n                          </span>\n                          <span className=\"min-w-0 flex-1 truncate font-medium\">{row.entry.name}</span>\n                          <span className=\"flex-none text-xs/[inherit] text-ink-3 tabular-nums\">+{row.entry.dial}</span>\n                          <span\n                            className=\"grid size-4 flex-none place-items-center text-ink opacity-0 [transform:scale(.6)] [transition:opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] data-on:opacity-100 data-on:[transform:none] motion-reduce:duration-0\"\n                            data-on={selected || undefined}\n                            aria-hidden=\"true\"\n                          >\n                            <Check size={16} strokeWidth={1.75} />\n                          </span>\n                        </div>\n                      );\n                    });\n                    return section.label ? (\n                      <div\n                        key={section.key}\n                        role=\"group\"\n                        aria-labelledby={`${uid}-${section.key}`}\n                        className={groupClass}\n                      >\n                        <div\n                          id={`${uid}-${section.key}`}\n                          className=\"px-2.5 pt-2 pb-1 text-xs/[inherit] text-ink-3\"\n                          role=\"presentation\"\n                        >\n                          {section.label}\n                        </div>\n                        {body}\n                      </div>\n                    ) : (\n                      <div key={section.key} role=\"presentation\" className={groupClass}>\n                        {body}\n                      </div>\n                    );\n                  })}\n                  {rows.length === 0 && (\n                    <p className=\"m-0 px-2.5 pt-3 pb-3.5 text-sm/[inherit] text-ink-3\">\n                      No countries match “{query.trim()}”\n                    </p>\n                  )}\n                </div>\n              </div>\n            </motion.div>\n          </motion.div>\n        </motion.div>\n\n        {/* The number and its guide share one box. The guide paints the typed part invisibly, then the rest of the example faintly after it. */}\n        <div className=\"relative flex min-w-0 flex-1 items-center\">\n          <span\n            className=\"pointer-events-none absolute inset-0 box-border flex items-center overflow-hidden pr-9 pl-3 text-sm leading-body tracking-body whitespace-pre text-[color-mix(in_oklab,var(--ink-3)_72%,transparent)] tabular-nums\"\n            aria-hidden=\"true\"\n          >\n            <span className=\"invisible\">{formatted}</span>\n            {guide}\n          </span>\n          <input\n            ref={inputRef}\n            id={inputId}\n            className=\"relative z-1 m-0 box-border size-full min-w-0 rounded-[0_var(--radius-control)_var(--radius-control)_0] border-0 bg-transparent pr-9 pl-3 text-sm leading-body tracking-body text-ink tabular-nums outline-none disabled:cursor-not-allowed\"\n            type=\"tel\"\n            inputMode=\"tel\"\n            autoComplete=\"tel\"\n            value={formatted}\n            disabled={disabled}\n            required={required}\n            aria-invalid={invalid || undefined}\n            aria-describedby={describedBy}\n            aria-label={hideLabel ? label : undefined}\n            onChange={(event) => onNumberChange(event.currentTarget)}\n            onKeyDown={onNumberKeyDown}\n            onPaste={onPaste}\n            onBlur={(event) => {\n              setTouched(digits.length > 0);\n              onBlur?.(event);\n            }}\n          />\n          <AnimatePresence initial={false}>\n            {showCheck && (\n              <motion.span\n                key=\"ok\"\n                className=\"pointer-events-none absolute top-1/2 right-3 z-1 -mt-[9px] grid size-[18px] place-items-center text-green\"\n                aria-hidden=\"true\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${blur.subtle}px)` }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, scale: reduced ? 1 : 0.8, transition: { duration: 0.1 } }}\n                transition={reduced ? { duration: 0.12 } : motionTokens.spring.snappy}\n              >\n                <Check size={16} strokeWidth={2} />\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {description && !message && <MessageRow key=\"hint\" id={hintId} text={description} tone=\"hint\" />}\n      </AnimatePresence>\n      <AnimatePresence initial={false}>\n        {message && <MessageRow key=\"error\" id={errorId} text={message} tone=\"error\" />}\n      </AnimatePresence>\n      {name && <input type=\"hidden\" name={name} value={e164} />}\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {announcement || (showCheck ? `Valid ${current.name} number` : \"\")}\n      </output>\n    </div>\n  );\n});\n\nPhoneInput.displayName = \"PhoneInput\";\n\nexport default PhoneInput;\n",
      "type": "registry:component",
      "target": "@components/iris/phone-input/phone-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/phone-input",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "PhoneInput",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/phone-input"
  }
}
