{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-field",
  "type": "registry:ui",
  "title": "Phone Field",
  "description": "Formats as you type for the chosen country, and reads it from pasted numbers.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/phone-field.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronDown, Search } from \"@/lib/icons\";\nimport { ease, spring, swap } from \"@/lib/motion\";\n\n/* ───────────────────────── countries ───────────────────────── */\n\nexport type PhoneCountry = {\n  /** ISO 3166-1 alpha-2. */\n  iso: string;\n  name: string;\n  /** Calling code without the plus. */\n  dial: string;\n  /** National format, # for a digit. The count of # is the maximum length. */\n  pattern: string;\n  /** Fewest national digits that make a complete number. Defaults to the pattern's length. */\n  min?: number;\n  /** Drops a leading 0 typed out of national habit (07400… becomes 7400…). */\n  trunk?: boolean;\n  /** A believable number for the placeholder. */\n  example: string;\n  /** Other formats chosen by leading digits, like London's 20 7946 0958 beside mobile 7400 123456. */\n  formats?: { prefix: string; pattern: string }[];\n};\n\n// One line per country: iso|name|dial|pattern|example|min|trunk|prefix=pattern;…\nconst raw = `\nUS|United States|1|(###) ###-####|2015550123\nCA|Canada|1|(###) ###-####|6135550123\nGB|United Kingdom|44|#### ######|7400123456||t|20=## #### ####;23=## #### ####;24=## #### ####;28=## #### ####;29=## #### ####\nIE|Ireland|353|## ### ####|851234567||t\nDE|Germany|49|### ########|15123456789|9|t|30=## ########;40=## ########;69=## ########;89=## ########\nFR|France|33|# ## ## ## ##|612345678||t\nES|Spain|34|### ## ## ##|612345678\nIT|Italy|39|### ### ####|3123456789|9\nPT|Portugal|351|### ### ###|912345678\nNL|Netherlands|31|# ########|612345678||t\nBE|Belgium|32|### ## ## ##|470123456|8|t\nCH|Switzerland|41|## ### ## ##|781234567||t\nAT|Austria|43|### #######|6641234567|9|t\nSE|Sweden|46|##-### ## ##|701234567|7|t\nNO|Norway|47|### ## ###|40612345\nDK|Denmark|45|## ## ## ##|20123456\nFI|Finland|358|## ### ####|412345678|6|t\nPL|Poland|48|### ### ###|512345678\nCZ|Czechia|420|### ### ###|601123456\nGR|Greece|30|### ### ####|6912345678\nTR|Türkiye|90|### ### ## ##|5012345678||t\nUA|Ukraine|380|## ### ## ##|501234567||t\nIL|Israel|972|##-###-####|501234567|8|t\nAE|United Arab Emirates|971|## ### ####|501234567|8|t\nSA|Saudi Arabia|966|## ### ####|512345678||t\nEG|Egypt|20|### ### ####|1001234567||t\nZA|South Africa|27|## ### ####|711234567||t\nNG|Nigeria|234|### ### ####|8021234567||t\nKE|Kenya|254|### ######|712123456||t\nIN|India|91|##### #####|8123456789||t\nPK|Pakistan|92|### #######|3012345678||t\nBD|Bangladesh|880|####-######|1812345678||t\nCN|China|86|### #### ####|13123456789||t\nJP|Japan|81|##-####-####|9012345678|9|t\nKR|South Korea|82|##-####-####|1020000000|9|t\nTW|Taiwan|886|### ### ###|912345678||t\nHK|Hong Kong|852|#### ####|51234567\nSG|Singapore|65|#### ####|81234567\nMY|Malaysia|60|##-#### ####|123456789|9|t\nTH|Thailand|66|## ### ####|812345678|8|t\nVN|Vietnam|84|## ### ## ##|912345678||t\nPH|Philippines|63|### ### ####|9051234567||t\nID|Indonesia|62|###-####-####|81234567890|9|t\nAU|Australia|61|### ### ###|412345678||t|2=# #### ####;3=# #### ####;7=# #### ####;8=# #### ####\nNZ|New Zealand|64|## ### ####|211234567|8|t\nMX|Mexico|52|## #### ####|5512345678\nBR|Brazil|55|(##) #####-####|11961234567|10|t\nAR|Argentina|54|## ####-####|1123456789||t\nCL|Chile|56|# #### ####|912345678\nCO|Colombia|57|### ### ####|3211234567\nPE|Peru|51|### ### ###|912345678`;\n\nexport const phoneCountries: PhoneCountry[] = raw\n  .trim()\n  .split(\"\\n\")\n  .map((l) => {\n    const [iso, name, dial, pattern, example, min, trunk, alt] = l.split(\"|\");\n    const formats = alt?.split(\";\").map((f) => {\n      const [prefix, p] = f.split(\"=\");\n      return { prefix, pattern: p };\n    });\n    return { iso, name, dial, pattern, example, min: min ? Number(min) : undefined, trunk: trunk === \"t\", formats };\n  });\n\nconst byIso = new Map(phoneCountries.map((c) => [c.iso, c]));\nconst maxDigits = (c: PhoneCountry) => [...c.pattern].filter((ch) => ch === \"#\").length;\nconst minDigits = (c: PhoneCountry) => c.min ?? maxDigits(c);\n\n// North American numbers share +1; these area codes are Canadian.\nconst CA_AREAS = new Set(\"204 226 236 249 250 289 306 343 365 403 416 418 431 437 438 450 506 514 519 548 579 581 587 604 613 639 647 672 705 709 742 778 780 782 807 819 825 867 873 902 905\".split(\" \"));\n\n/** Regional indicator letters make the flag emoji for an ISO code. */\nexport const flagOf = (iso: string) => String.fromCodePoint(...[...iso.toUpperCase()].map((ch) => 0x1f1a5 + ch.charCodeAt(0)));\n\n/** The pattern for these digits: the longest matching prefix format, or the country's default. */\nexport const patternFor = (c: PhoneCountry, digits: string) =>\n  c.formats?.filter((f) => digits.startsWith(f.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0]?.pattern ?? c.pattern;\n\n/** Fills a pattern with digits, adding separators only between digits that exist. */\nexport function formatPhone(digits: string, pattern: string) {\n  let out = \"\";\n  let i = 0;\n  for (const ch of pattern) {\n    if (i >= digits.length) break;\n    if (ch === \"#\") out += digits[i++];\n    else out += ch;\n  }\n  return out + digits.slice(i);\n}\n\n/**\n * Reads an international number (\"+44 (0)20 7946 0958\", \"0044…\") into a country and\n * national digits. Uses the longest calling code that matches, and area codes for +1.\n */\nexport function parsePhone(input: string, countries = phoneCountries): { country: PhoneCountry; national: string } | null {\n  const cleaned = input.replace(/\\(0\\)/g, \"\").trim();\n  const intl = cleaned.startsWith(\"+\") || cleaned.startsWith(\"00\");\n  if (!intl) return null;\n  const digits = cleaned.replace(/\\D/g, \"\").replace(/^00/, \"\");\n  const hits = countries.filter((c) => digits.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length);\n  if (!hits.length) return null;\n  const dial = hits[0].dial;\n  const national = digits.slice(dial.length);\n  const group = hits.filter((c) => c.dial === dial);\n  const country = dial === \"1\" && CA_AREAS.has(national.slice(0, 3)) ? group.find((c) => c.iso === \"CA\") ?? group[0] : group[0];\n  return { country, national };\n}\n\n// A code that is typed so far is only safe to act on when no longer code starts with it.\nconst unambiguous = (digits: string, countries: PhoneCountry[]) =>\n  countries.some((c) => c.dial === digits) && !countries.some((c) => c.dial.length > digits.length && c.dial.startsWith(digits));\n\n/* ───────────────────────── the field ───────────────────────── */\n\nexport type PhoneFieldDetails = { country: PhoneCountry; national: string; complete: boolean };\n\nexport type PhoneFieldProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"size\" | \"type\"> & {\n  /** E.164, like +14155550132. An empty string is no number. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (e164: string, details: PhoneFieldDetails) => void;\n  /** ISO code of the country when there's no number yet. */\n  defaultCountry?: string;\n  /** Pinned to the top of the list, in this order. */\n  preferredCountries?: string[];\n  countries?: PhoneCountry[];\n  size?: \"sm\" | \"md\";\n  invalid?: boolean;\n  /** Submits the E.164 value in a hidden input. */\n  name?: string;\n};\n\nexport function PhoneField({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  defaultCountry = \"US\",\n  preferredCountries = [],\n  countries = phoneCountries,\n  size = \"md\",\n  invalid = false,\n  name,\n  disabled,\n  readOnly,\n  className,\n  id: idProp,\n  onBlur,\n  onKeyDown,\n  onPaste,\n  ...rest\n}: PhoneFieldProps) {\n  const uid = useId();\n  const id = idProp ?? `${uid}-input`;\n  const reduce = useReducedMotion();\n  const initial = () => parsePhone(valueProp ?? defaultValue, countries) ?? { country: byIso.get(defaultCountry) ?? countries[0], national: \"\" };\n  const [country, setCountry] = useState<PhoneCountry>(() => initial().country);\n  const [national, setNational] = useState(() => initial().national);\n  // Set by a + code; lets +1 settle on Canada once the area code arrives.\n  const [auto, setAuto] = useState(false);\n  // Raw \"+4…\" text while a code is still ambiguous.\n  const [pendingIntl, setPendingIntl] = useState<string | null>(null);\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [pulse, setPulse] = useState(0);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const caret = useRef<number | null>(null);\n\n  const e164 = national ? `+${country.dial}${national}` : \"\";\n\n  // Follow a controlled value that changed from outside (a paste handler, a reset, saved data).\n  const [seen, setSeen] = useState(valueProp);\n  if (valueProp !== seen) {\n    setSeen(valueProp);\n    if (valueProp !== undefined && valueProp !== e164) {\n      const parsed = parsePhone(valueProp, countries);\n      if (parsed) {\n        if (parsed.country.iso !== country.iso) setPulse((p) => p + 1);\n        setCountry(parsed.country);\n        setNational(parsed.national.slice(0, maxDigits(parsed.country)));\n      } else setNational(\"\");\n      setPendingIntl(null);\n    }\n  }\n\n  const emit = (c: PhoneCountry, n: string) => {\n    const next = n ? `+${c.dial}${n}` : \"\";\n    setSeen(next);\n    onValueChange?.(next, { country: c, national: n, complete: n.length >= minDigits(c) && n.length <= maxDigits(c) });\n  };\n\n  const commit = (c: PhoneCountry, n: string, caretDigits: number | null, from: \"type\" | \"code\" | \"pick\") => {\n    const clipped = n.slice(0, maxDigits(c));\n    if (c.iso !== country.iso && from === \"code\") setPulse((p) => p + 1);\n    setCountry(c);\n    setNational(clipped);\n    setPendingIntl(null);\n    if (caretDigits !== null) caret.current = caretIndex(formatPhone(clipped, patternFor(c, clipped)), Math.min(caretDigits, clipped.length));\n    emit(c, clipped);\n  };\n\n  useLayoutEffect(() => {\n    if (caret.current === null || document.activeElement !== inputRef.current) return;\n    inputRef.current?.setSelectionRange(caret.current, caret.current);\n    caret.current = null;\n  });\n\n  const handleText = (text: string, caretPos: number | null) => {\n    if (text.trim().startsWith(\"+\") || text.trim().startsWith(\"00\")) {\n      const digits = text.replace(/\\(0\\)/g, \"\").replace(/\\D/g, \"\").replace(/^00/, \"\");\n      const parsed = parsePhone(text, countries);\n      // Act when the code is certain: a whole number was pasted, or the code typed so far has no longer rival.\n      if (parsed && (digits.length > parsed.country.dial.length || unambiguous(digits, countries))) {\n        setAuto(true);\n        return commit(parsed.country, parsed.national, parsed.national.length, \"code\");\n      }\n      setPendingIntl(`+${digits}`);\n      return;\n    }\n    let digits = text.replace(/\\D/g, \"\");\n    const before = caretPos === null ? digits.length : text.slice(0, caretPos).replace(/\\D/g, \"\").length;\n    let shift = 0;\n    if (country.trunk && digits.startsWith(\"0\")) {\n      digits = digits.slice(1);\n      shift = 1;\n    }\n    let target = country;\n    if (auto && country.dial === \"1\" && digits.length >= 3) {\n      target = (CA_AREAS.has(digits.slice(0, 3)) ? countries.find((c) => c.iso === \"CA\") : countries.find((c) => c.iso === \"US\")) ?? country;\n      if (target.iso !== country.iso) setPulse((p) => p + 1);\n    }\n    commit(target, digits, Math.max(0, before - shift), \"type\");\n  };\n\n  const shown = pendingIntl ?? formatPhone(national, patternFor(country, national));\n  const complete = national.length >= minDigits(country);\n\n  const filtered = useMemo(() => {\n    const pinned = preferredCountries.map((iso) => countries.find((c) => c.iso === iso)).filter((c): c is PhoneCountry => !!c);\n    const ordered = [...pinned, ...countries.filter((c) => !preferredCountries.includes(c.iso))];\n    const q = query.trim().toLowerCase().replace(/^\\+/, \"\");\n    if (!q) return ordered;\n    return countries.filter((c) => c.name.toLowerCase().includes(q) || c.iso.toLowerCase() === q || c.dial.startsWith(q.replace(/\\D/g, \"\") || \"_\"));\n  }, [countries, preferredCountries, query]);\n  const lastPinned = query.trim() ? -1 : preferredCountries.filter((iso) => countries.some((c) => c.iso === iso)).length - 1;\n\n  return (\n    <div\n      ref={fieldRef}\n      data-size={size}\n      data-invalid={invalid || undefined}\n      data-disabled={disabled || undefined}\n      data-complete={complete || undefined}\n      className={cn(\n        \"group/phone relative flex w-full min-w-0 items-stretch rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)]\",\n        \"transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n        \"data-[invalid]:border-danger data-[invalid]:focus-within:ring-danger/15 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        size === \"sm\" ? \"h-7\" : \"h-8\",\n        className,\n      )}\n    >\n      <Combobox.Root<PhoneCountry>\n        items={filtered}\n        filteredItems={filtered}\n        value={country}\n        onValueChange={(c) => {\n          if (!c) return;\n          setAuto(false);\n          commit(c, national, null, \"pick\");\n        }}\n        open={open}\n        onOpenChange={(next) => {\n          setOpen(next);\n          if (!next) setQuery(\"\");\n        }}\n        inputValue={query}\n        onInputValueChange={(q) => setQuery(q)}\n        itemToStringLabel={(c) => c.name}\n        itemToStringValue={(c) => c.iso}\n        isItemEqualToValue={(a, b) => a.iso === b.iso}\n        disabled={disabled}\n        readOnly={readOnly}\n        autoHighlight\n      >\n        <Combobox.Trigger\n          aria-label={`Country: ${country.name}, +${country.dial}`}\n          className={cn(\n            \"group/cc relative flex shrink-0 select-none items-center gap-1.5 rounded-l-[7px] border-r border-line pl-2.5 pr-2 outline-none\",\n            \"transition-[background-color] duration-150 hover:bg-fg/[0.04] active:bg-fg/[0.07] data-[popup-open]:bg-fg/[0.05]\",\n            \"focus-visible:bg-fg/[0.05] focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-fg-4\",\n          )}\n        >\n          <span className=\"relative grid size-4 place-items-center text-[15px] leading-none\" aria-hidden>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={`${country.iso}-${pulse}`}\n                initial={reduce ? { opacity: 0 } : swap.initial}\n                animate={swap.animate}\n                exit={reduce ? { opacity: 0 } : swap.exit}\n                transition={reduce ? { duration: 0.12 } : spring.pop}\n              >\n                {flagOf(country.iso)}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          {/* Wide enough for +880, so the number never shifts when the code changes. */}\n          <span className=\"relative grid min-w-[4ch] overflow-hidden font-mono text-[12px] tabular text-fg-2\" aria-hidden>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={country.dial}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}\n                transition={{ duration: 0.2, ease: ease.out }}\n              >\n                +{country.dial}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <ChevronDown size={12} className=\"text-fg-4 transition-transform duration-200 ease-out group-data-[popup-open]/cc:rotate-180 motion-reduce:transition-none\" />\n        </Combobox.Trigger>\n\n        <Combobox.Portal>\n          <Combobox.Positioner anchor={fieldRef} align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n            <Combobox.Popup\n              aria-label=\"Choose a country\"\n              finalFocus={inputRef}\n              className={cn(\n                \"flex w-(--anchor-width) min-w-[260px] max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised shadow-pop outline-none\",\n                \"transition-[opacity,scale] duration-180 ease-out-expo data-starting-style:scale-[0.97] data-starting-style:opacity-0\",\n                \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-120\",\n                \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n              )}\n            >\n              <div className=\"flex h-10 shrink-0 items-center gap-2 border-b border-line px-3\">\n                <Search size={14} className=\"shrink-0 text-fg-4\" />\n                <Combobox.Input\n                  placeholder=\"Search countries or codes\"\n                  aria-label=\"Search countries or codes\"\n                  className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n                />\n              </div>\n              <Combobox.Empty className=\"empty:hidden\">\n                <p className=\"px-3 py-6 text-center text-[12.5px] text-fg-3\">\n                  No country matches <span className=\"text-fg\">“{query.trim()}”</span>\n                </p>\n              </Combobox.Empty>\n              <Combobox.List className=\"max-h-[min(280px,calc(var(--available-height)-48px))] scroll-py-1 overflow-y-auto overscroll-contain p-1 empty:p-0\">\n                {(c: PhoneCountry, index: number) => (\n                  <Combobox.Item\n                    key={c.iso}\n                    value={c}\n                    index={index}\n                    data-last-pinned={index === lastPinned || undefined}\n                    className={cn(\n                      \"relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-md pl-2 pr-2 text-[13px] text-fg-2 outline-none\",\n                      \"data-[highlighted]:bg-fg/[0.07] data-[highlighted]:text-fg data-[selected]:text-fg\",\n                      \"data-[last-pinned]:mb-[9px] data-[last-pinned]:after:absolute data-[last-pinned]:after:inset-x-1 data-[last-pinned]:after:-bottom-[5px] data-[last-pinned]:after:h-px data-[last-pinned]:after:bg-line\",\n                    )}\n                  >\n                    <span className=\"text-[15px] leading-none\" aria-hidden>{flagOf(c.iso)}</span>\n                    <span className=\"min-w-0 flex-1 truncate\">{c.name}</span>\n                    <span className=\"shrink-0 font-mono text-[11.5px] tabular text-fg-3\">+{c.dial}</span>\n                    <span className=\"grid size-4 shrink-0 place-items-center\">\n                      <Combobox.ItemIndicator>\n                        <Check size={14} className=\"text-fg\" />\n                      </Combobox.ItemIndicator>\n                    </span>\n                  </Combobox.Item>\n                )}\n              </Combobox.List>\n            </Combobox.Popup>\n          </Combobox.Positioner>\n        </Combobox.Portal>\n      </Combobox.Root>\n\n      <input\n        ref={inputRef}\n        id={id}\n        type=\"tel\"\n        inputMode=\"tel\"\n        autoComplete=\"tel\"\n        value={shown}\n        disabled={disabled}\n        readOnly={readOnly}\n        placeholder={formatPhone(country.example, country.pattern)}\n        aria-invalid={invalid || undefined}\n        onChange={(e) => handleText(e.target.value, e.target.selectionStart)}\n        onPaste={(e) => {\n          onPaste?.(e);\n          const text = e.clipboardData.getData(\"text\");\n          // A pasted number replaces the field outright, so its country can be read from it.\n          if (/^\\s*(\\+|00)/.test(text) && !e.defaultPrevented) {\n            e.preventDefault();\n            handleText(text, null);\n          }\n        }}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          if (e.defaultPrevented) return;\n          const el = e.currentTarget;\n          const at = el.selectionStart ?? 0;\n          // Backspace just after a separator deletes the digit before it, instead of doing nothing.\n          if (e.key === \"Backspace\" && at === el.selectionEnd && at > 0 && !pendingIntl && /\\D/.test(shown[at - 1] ?? \"\")) {\n            e.preventDefault();\n            const digitsBefore = shown.slice(0, at).replace(/\\D/g, \"\").length;\n            if (digitsBefore === 0) return;\n            commit(country, national.slice(0, digitsBefore - 1) + national.slice(digitsBefore), digitsBefore - 1, \"type\");\n          }\n        }}\n        onBlur={(e) => {\n          onBlur?.(e);\n          setPendingIntl(null);\n        }}\n        className={cn(\n          \"min-w-0 flex-1 bg-transparent px-2.5 font-normal tabular text-fg outline-none placeholder:text-fg-4\",\n          size === \"sm\" ? \"text-base sm:text-[12.5px]\" : \"text-base sm:text-[13px]\",\n        )}\n        {...rest}\n      />\n      {name && <input type=\"hidden\" name={name} value={e164} />}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {pulse ? `Country set to ${country.name}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction caretIndex(formatted: string, digits: number) {\n  if (digits <= 0) return formatted.search(/\\d/) === -1 ? formatted.length : 0;\n  let seen = 0;\n  for (let i = 0; i < formatted.length; i++) {\n    if (/\\d/.test(formatted[i]) && ++seen === digits) return i + 1;\n  }\n  return formatted.length;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/phone-field"
}
