{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"phone-input","type":"registry:ui","title":"Phone Input","description":"A phone number with its country, grouped as it's typed and given in international form.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/command.json","https://dashral.com/r/field.json","https://dashral.com/r/input.json","https://dashral.com/r/popover.json"],"files":[{"path":"registry/dashral/phone-input.tsx","type":"registry:file","target":"components/dashral/phone-input.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandRoot } from \"./command\"\nimport { type FieldClassNames, type FieldOwnProps } from \"./field\"\nimport { Input } from \"./input\"\nimport { PopoverContent, PopoverRoot, PopoverTrigger } from \"./popover\"\n\n// Each region's calling code (ITU-T E.164), as ISO 3166 code + digits. The North American Numbering Plan shares +1,\n// and Russia and Kazakhstan +7; the country picked tells them apart.\nconst callingCodes = Object.fromEntries(\n  (\n    \"AF93 AX358 AL355 DZ213 AS1 AD376 AO244 AI1 AG1 AR54 AM374 AW297 AU61 AT43 AZ994 BS1 BH973 BD880 BB1 BY375 BE32 BZ501 \" +\n    \"BJ229 BM1 BT975 BO591 BA387 BW267 BR55 IO246 VG1 BN673 BG359 BF226 BI257 KH855 CM237 CA1 CV238 BQ599 KY1 CF236 TD235 \" +\n    \"CL56 CN86 CX61 CC61 CO57 KM269 CG242 CD243 CK682 CR506 CI225 HR385 CU53 CW599 CY357 CZ420 DK45 DJ253 DM1 DO1 EC593 \" +\n    \"EG20 SV503 GQ240 ER291 EE372 SZ268 ET251 FK500 FO298 FJ679 FI358 FR33 GF594 PF689 GA241 GM220 GE995 DE49 GH233 GI350 \" +\n    \"GR30 GL299 GD1 GP590 GU1 GT502 GG44 GN224 GW245 GY592 HT509 HN504 HK852 HU36 IS354 IN91 ID62 IR98 IQ964 IE353 IM44 \" +\n    \"IL972 IT39 JM1 JP81 JE44 JO962 KZ7 KE254 KI686 XK383 KW965 KG996 LA856 LV371 LB961 LS266 LR231 LY218 LI423 LT370 \" +\n    \"LU352 MO853 MG261 MW265 MY60 MV960 ML223 MT356 MH692 MQ596 MR222 MU230 YT262 MX52 FM691 MD373 MC377 MN976 ME382 MS1 \" +\n    \"MA212 MZ258 MM95 NA264 NR674 NP977 NL31 NC687 NZ64 NI505 NE227 NG234 NU683 NF672 KP850 MK389 MP1 NO47 OM968 PK92 \" +\n    \"PW680 PS970 PA507 PG675 PY595 PE51 PH63 PL48 PT351 PR1 QA974 RE262 RO40 RU7 RW250 BL590 SH290 KN1 LC1 MF590 PM508 \" +\n    \"VC1 WS685 SM378 ST239 SA966 SN221 RS381 SC248 SL232 SG65 SX1 SK421 SI386 SB677 SO252 ZA27 KR82 SS211 ES34 LK94 SD249 \" +\n    \"SR597 SJ47 SE46 CH41 SY963 TW886 TJ992 TZ255 TH66 TL670 TG228 TK690 TO676 TT1 TN216 TR90 TM993 TC1 TV688 UG256 UA380 \" +\n    \"AE971 GB44 US1 UY598 UZ998 VU678 VA39 VE58 VN84 VI1 WF681 EH212 YE967 ZM260 ZW263\"\n  )\n    .split(\" \")\n    .map((entry) => [entry.slice(0, 2), entry.slice(2)] as const)\n) as Record<string, string>\n\nconst allCountries = Object.keys(callingCodes)\n\n/** Where a leading 0 is part of the number, not a trunk prefix dropped after the country code. */\nconst keepsLeadingZero = new Set([\"IT\", \"SM\", \"VA\"])\n\n/** The flag emoji, from the two regional indicator letters (Windows shows the letters instead). */\nconst flag = (country: string) => String.fromCodePoint(...[...country].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))\n\n/** The country a full number starts with: the longest calling code that fits, the one already picked winning a tie (+1). */\nfunction detectCountry(digits: string, current: string, countries: string[]) {\n  const currentCode = callingCodes[current]\n  if (currentCode && digits.startsWith(currentCode)) return current\n  let best: string | undefined\n  for (const country of countries) {\n    const code = callingCodes[country]!\n    if (digits.startsWith(code) && (!best || code.length > callingCodes[best]!.length)) best = country\n  }\n  return best\n}\n\n/** \"+6281234567890\" for the country and what was typed: digits only, the trunk 0 dropped; \"\" when nothing's typed. */\nfunction toE164(country: string, national: string) {\n  const digits = national.replace(/\\D/g, \"\")\n  if (!digits) return \"\"\n  return `+${callingCodes[country]}${keepsLeadingZero.has(country) ? digits : digits.replace(/^0/, \"\")}`\n}\n\ntype PhoneInputDetails = {\n  /** The country picked, ISO 3166: \"ID\". */\n  country: string\n  /** The digits typed, without the country code: \"081234567890\". */\n  national: string\n}\n\ntype PhoneInputProps = FieldOwnProps & {\n  /** The number in international form (E.164), controlled: \"+6281234567890\". \"\" for none. */\n  value?: string\n  defaultValue?: string\n  /** Called with the international number (\"\" when empty), and the country and digits typed. */\n  onValueChange?: (value: string, details: PhoneInputDetails) => void\n  /** The country picked, controlled (ISO 3166: \"ID\"). */\n  country?: string\n  /** The country picked first. Without one, it follows the number given, or the United States. */\n  defaultCountry?: string\n  onCountryChange?: (country: string) => void\n  /** Only these countries in the list. */\n  countries?: string[]\n  /** Listed first, above the rest: the countries most of your users are in. */\n  preferredCountries?: string[]\n  /** The form field's name: the number is sent in international form. */\n  name?: string\n  id?: string\n  disabled?: boolean\n  required?: boolean\n  placeholder?: string\n  \"aria-label\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  /** The field's classes. */\n  className?: string\n  classNames?: FieldClassNames & { trigger?: string; content?: string }\n}\n\n/**\n * A phone number with its country, in one tag: `<PhoneInput label=\"Phone\" defaultCountry=\"ID\" onValueChange={setPhone} />`.\n * The flag and calling code open a searchable list of countries, named in the reader's language; the number is\n * grouped as it's typed, and comes out in international form (E.164), ready to store or send. Pasting a number\n * with its country code (+62 …) picks the country.\n */\nfunction PhoneInput({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  country: countryProp,\n  defaultCountry,\n  onCountryChange,\n  countries: countriesProp,\n  preferredCountries,\n  name,\n  disabled,\n  placeholder,\n  className,\n  classNames,\n  ...props\n}: PhoneInputProps) {\n  const { t, locale } = useLocale()\n  const countries = React.useMemo(\n    () => (countriesProp ?? allCountries).map((c) => c.toUpperCase()).filter((c) => c in callingCodes),\n    [countriesProp]\n  )\n  const fallback = countries.includes(defaultCountry?.toUpperCase() ?? \"\") ? defaultCountry!.toUpperCase() : countries.includes(\"US\") ? \"US\" : countries[0]!\n\n  // What's typed (digits, the way they were typed, a leading 0 kept) and the country, both read from the starting\n  // number when there is one.\n  const [state, setState] = React.useState(() => {\n    const start = (value ?? defaultValue).replace(/\\D/g, \"\")\n    const found = start ? detectCountry(start, countryProp?.toUpperCase() ?? fallback, countries) : undefined\n    return { country: found ?? fallback, national: found ? start.slice(callingCodes[found]!.length) : \"\" }\n  })\n  const country = countryProp?.toUpperCase() ?? state.country\n  const e164 = toE164(country, state.national)\n\n  // A number set from outside (the parent's state, a form reset): read it again, unless it's what's shown already.\n  // Only when value itself changed, so a number written another way (\"+620812…\") can't keep it re-reading.\n  const [seen, setSeen] = React.useState(value)\n  if (value !== seen) setSeen(value)\n  if (value !== undefined && value !== seen && value !== e164) {\n    const digits = value.replace(/\\D/g, \"\")\n    const found = digits ? detectCountry(digits, country, countries) : undefined\n    setState({ country: found ?? country, national: found ? digits.slice(callingCodes[found]!.length) : \"\" })\n  }\n\n  const [open, setOpen] = React.useState(false)\n  const names = React.useMemo(() => {\n    let display: Intl.DisplayNames | undefined\n    try {\n      display = new Intl.DisplayNames([locale], { type: \"region\" })\n    } catch {\n      display = undefined\n    }\n    return Object.fromEntries(countries.map((c) => [c, display?.of(c) ?? c])) as Record<string, string>\n  }, [countries, locale])\n\n  const sorted = React.useMemo(() => [...countries].sort((a, b) => names[a]!.localeCompare(names[b]!, locale)), [countries, names, locale])\n  const preferred = (preferredCountries ?? []).map((c) => c.toUpperCase()).filter((c) => countries.includes(c))\n\n  function update(nextCountry: string, nextNational: string) {\n    if (nextCountry !== country) onCountryChange?.(nextCountry)\n    setState({ country: nextCountry, national: nextNational })\n    const next = toE164(nextCountry, nextNational)\n    if (next !== e164 || nextCountry !== country) onValueChange?.(next, { country: nextCountry, national: nextNational })\n  }\n\n  function pickCountry(next: string) {\n    setOpen(false)\n    update(next, state.national)\n  }\n\n  const item = (c: string) => (\n    <CommandItem key={c} value={`${names[c]} ${c} +${callingCodes[c]}`} onSelect={() => pickCountry(c)}>\n      <CheckIcon aria-hidden className={cn(c === country ? \"opacity-100\" : \"opacity-0\")} />\n      <span aria-hidden className=\"text-base leading-none\">\n        {flag(c)}\n      </span>\n      <span className=\"min-w-0 flex-1 truncate\">{names[c]}</span>\n      <span dir=\"ltr\" className=\"text-xs text-muted-foreground tabular-nums\">\n        +{callingCodes[c]}\n      </span>\n    </CommandItem>\n  )\n\n  const picker = (\n    <PopoverRoot open={open} onOpenChange={setOpen}>\n      <PopoverTrigger\n        type=\"button\"\n        disabled={disabled}\n        aria-label={`${t(\"country\")}: ${names[country]}`}\n        className={cn(\n          \"-ms-1.5 flex h-7 shrink-0 items-center gap-1 rounded-sm px-1.5 text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none data-[state=open]:bg-accent\",\n          classNames?.trigger\n        )}\n      >\n        <span aria-hidden className=\"text-base leading-none\">\n          {flag(country)}\n        </span>\n        <span dir=\"ltr\" className=\"text-sm tabular-nums\">\n          +{callingCodes[country]}\n        </span>\n        <ChevronDownIcon aria-hidden className=\"size-3.5! opacity-60\" />\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" sideOffset={8} className={cn(\"w-72 p-0\", classNames?.content)}>\n        <CommandRoot>\n          <CommandInput placeholder={t(\"searchCountries\")} />\n          <CommandList>\n            <CommandEmpty>{t(\"noResults\")}</CommandEmpty>\n            {preferred.length > 0 && <CommandGroup>{preferred.map(item)}</CommandGroup>}\n            <CommandGroup>{sorted.filter((c) => !preferred.includes(c)).map(item)}</CommandGroup>\n          </CommandList>\n        </CommandRoot>\n      </PopoverContent>\n    </PopoverRoot>\n  )\n\n  return (\n    <>\n      <Input\n        {...props}\n        type=\"tel\"\n        format=\"phone\"\n        dir=\"ltr\"\n        disabled={disabled}\n        placeholder={placeholder}\n        prefix={picker}\n        value={state.national}\n        onValueChange={(raw) => update(country, raw)}\n        // A whole number pasted with its country code (\"+62 812…\", \"0062 812…\"): pick the country and keep the rest.\n        onPaste={(event) => {\n          const text = event.clipboardData.getData(\"text\").trim()\n          if (!/^(\\+|00)/.test(text)) return\n          const digits = text.replace(/^00/, \"\").replace(/\\D/g, \"\")\n          const found = detectCountry(digits, country, countries)\n          if (!found) return\n          event.preventDefault()\n          update(found, digits.slice(callingCodes[found]!.length))\n        }}\n        className={className}\n        classNames={classNames}\n      />\n      {name && <input type=\"hidden\" name={name} value={e164} disabled={disabled} />}\n    </>\n  )\n}\n\nexport { PhoneInput, type PhoneInputProps, type PhoneInputDetails }\n"}]}