{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-input",
  "title": "Phone Input",
  "description": "Phone number field with a searchable country picker, formatting as you type and E.164 output.",
  "dependencies": [
    "libphonenumber-js",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "popover",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/phone-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type { CountryCode } from \"libphonenumber-js\"\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  caretAfterDigits,\n  changeCountry,\n  countDigits,\n  countryForValue,\n  deleteDigitPastFormatting,\n  filterCountries,\n  formatInsertedPhone,\n  formatPhoneInput,\n  formatPhoneValue,\n  isAllowedNumber,\n  resolveCountries,\n  type PhoneCountry,\n  type PhoneCountryOption,\n} from \"@/registry/default/lib/phone-input\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/default/ui/popover\"\n\nfunction scrollOptionIntoList(option: HTMLElement | null) {\n  const list = option?.parentElement\n  if (!option || !list) return\n  const bottom = option.offsetTop + option.offsetHeight\n  if (option.offsetTop < list.scrollTop) {\n    list.scrollTop = option.offsetTop\n  } else if (bottom > list.scrollTop + list.clientHeight) {\n    list.scrollTop = bottom - list.clientHeight\n  }\n}\n\nfunction CountryPicker({\n  countries,\n  country,\n  disabled,\n  onSelect,\n}: {\n  countries: PhoneCountry[]\n  country: CountryCode\n  disabled?: boolean\n  onSelect: (country: CountryCode) => void\n}) {\n  const listId = React.useId()\n  const [isOpen, setIsOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  const [highlightedIndex, setHighlightedIndex] = React.useState(0)\n\n  const current = countries.find((option) => option.code === country)\n  const matches = filterCountries(countries, query)\n\n  function handleOpenChange(nextOpen: boolean) {\n    setIsOpen(nextOpen)\n    if (!nextOpen) return\n    setQuery(\"\")\n    setHighlightedIndex(\n      Math.max(\n        0,\n        countries.findIndex((option) => option.code === country)\n      )\n    )\n  }\n\n  function select(code: CountryCode) {\n    onSelect(code)\n    setIsOpen(false)\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault()\n        if (matches.length === 0) return\n        const delta = event.key === \"ArrowDown\" ? 1 : -1\n        setHighlightedIndex(\n          (highlightedIndex + delta + matches.length) % matches.length\n        )\n        return\n      }\n      case \"Enter\": {\n        event.preventDefault()\n        const match = matches[highlightedIndex]\n        if (match) select(match.code)\n        return\n      }\n    }\n  }\n\n  return (\n    <Popover open={isOpen} onOpenChange={handleOpenChange}>\n      <PopoverTrigger\n        type=\"button\"\n        disabled={disabled}\n        data-slot=\"phone-input-country\"\n        aria-label={`Country: ${current?.name}, ${current?.dialCode}`}\n        className=\"flex shrink-0 items-center gap-1 rounded-l-md border-r border-input px-2 text-muted-foreground tabular-nums transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground disabled:cursor-not-allowed\"\n      >\n        <span aria-hidden=\"true\">{current?.flag}</span>\n        <span>{current?.dialCode}</span>\n        <ChevronDownIcon className=\"size-3.5 opacity-60\" />\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        aria-label=\"Choose a country\"\n        className=\"flex w-72 flex-col gap-1 p-1\"\n      >\n        <input\n          type=\"text\"\n          role=\"combobox\"\n          data-slot=\"phone-input-search\"\n          aria-label=\"Search countries\"\n          aria-autocomplete=\"list\"\n          aria-expanded=\"true\"\n          aria-controls={listId}\n          aria-activedescendant={\n            matches.length > 0 ? `${listId}-${highlightedIndex}` : undefined\n          }\n          value={query}\n          placeholder=\"Search countries\"\n          onChange={(event) => {\n            setQuery(event.target.value)\n            setHighlightedIndex(0)\n          }}\n          onKeyDown={handleKeyDown}\n          className=\"h-8 w-full rounded-sm bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\"\n        />\n        <div\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Countries\"\n          data-slot=\"phone-input-list\"\n          className=\"relative max-h-64 overflow-y-auto\"\n        >\n          {matches.length === 0 ? (\n            <p className=\"px-2 py-1.5 text-sm text-muted-foreground\">\n              No results\n            </p>\n          ) : (\n            matches.map((option, index) => (\n              <div\n                key={option.code}\n                ref={\n                  index === highlightedIndex ? scrollOptionIntoList : undefined\n                }\n                id={`${listId}-${index}`}\n                role=\"option\"\n                aria-selected={option.code === country}\n                data-highlighted={index === highlightedIndex ? \"\" : undefined}\n                onPointerMove={() => setHighlightedIndex(index)}\n                onClick={() => select(option.code)}\n                className=\"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n              >\n                <span aria-hidden=\"true\">{option.flag}</span>\n                <span className=\"min-w-0 flex-1 truncate\">{option.name}</span>\n                <span className=\"text-muted-foreground tabular-nums\">\n                  {option.dialCode}\n                </span>\n                {option.code === country && (\n                  <CheckIcon className=\"size-4 shrink-0\" />\n                )}\n              </div>\n            ))\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\ntype PhoneInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\"\n> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  defaultCountry?: CountryCode\n  countries?: PhoneCountryOption[]\n  excludedCountries?: CountryCode[]\n}\n\nfunction PhoneInput({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  defaultCountry = \"US\",\n  countries,\n  excludedCountries,\n  name,\n  disabled,\n  autoComplete = \"tel\",\n  className,\n  ref,\n  ...props\n}: PhoneInputProps) {\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)\n\n  const { value: phone, setValue: setPhone } = useControllableState({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const [selectedCountry, setSelectedCountry] = React.useState(defaultCountry)\n  const [edit, setEdit] = React.useState({ text: \"\", caret: 0 })\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  const allowedCountries = resolveCountries(countries, excludedCountries)\n  const country = countryForValue(phone, selectedCountry, allowedCountries)\n  const display =\n    formatPhoneInput(edit.text, country).value === phone\n      ? edit.text\n      : formatPhoneValue(phone, country)\n\n  React.useLayoutEffect(() => {\n    const input = inputRef.current\n    if (input && document.activeElement === input) {\n      input.setSelectionRange(edit.caret, edit.caret)\n    }\n  }, [edit])\n\n  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {\n    const { value: raw, selectionStart } = event.target\n    const { inputType } = event.nativeEvent as InputEvent\n    const { text, caret } = deleteDigitPastFormatting(\n      display,\n      raw,\n      selectionStart ?? raw.length,\n      inputType === \"deleteContentForward\"\n    )\n    const isWholeInsert =\n      !inputType || /^insert(From|Replacement)/.test(inputType)\n    const next = isWholeInsert\n      ? formatInsertedPhone(text, country, allowedCountries)\n      : { ...formatPhoneInput(text, country), country }\n    const isDeletion = inputType?.startsWith(\"delete\")\n    if (!isDeletion && !isAllowedNumber(next.value, allowedCountries)) {\n      setEdit({ text: display, caret: display.length })\n      setAnnouncement(\"Numbers from this country are not accepted\")\n      return\n    }\n    setAnnouncement(\"\")\n    setSelectedCountry(next.country)\n    setEdit({\n      text: next.display,\n      caret: caretAfterDigits(next.display, countDigits(text.slice(0, caret))),\n    })\n    setPhone(next.value)\n  }\n\n  function handleCountrySelect(next: CountryCode) {\n    const { display: nextDisplay, value: nextValue } = changeCountry(\n      phone,\n      country,\n      next\n    )\n    setSelectedCountry(next)\n    setEdit({ text: nextDisplay, caret: nextDisplay.length })\n    setPhone(nextValue)\n  }\n\n  return (\n    <div\n      data-slot=\"phone-input\"\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        \"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] md:text-sm dark:bg-input/30\",\n        \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n        \"has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20\",\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )}\n    >\n      <CountryPicker\n        countries={allowedCountries}\n        country={country}\n        disabled={disabled}\n        onSelect={handleCountrySelect}\n      />\n      <input\n        {...props}\n        ref={inputRef}\n        type=\"tel\"\n        data-slot=\"phone-input-field\"\n        autoComplete={autoComplete}\n        disabled={disabled}\n        value={display}\n        onChange={handleChange}\n        className=\"min-w-0 flex-1 rounded-r-md bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n      />\n      {name && (\n        <input type=\"hidden\" name={name} disabled={disabled} value={phone} />\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n\nexport {\n  PhoneInput,\n  type PhoneCountry,\n  type PhoneCountryOption,\n  type PhoneInputProps,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/phone-input.ts",
      "content": "import {\n  AsYouType,\n  getCountries,\n  getCountryCallingCode,\n  isValidPhoneNumber,\n  type CountryCode,\n} from \"libphonenumber-js\"\nimport metadata from \"libphonenumber-js/min/metadata\"\nimport type { ReactNode } from \"react\"\n\nexport type PhoneCountry = {\n  code: CountryCode\n  name: string\n  dialCode: string\n  flag: ReactNode\n}\n\nexport type PhoneCountryOption =\n  | CountryCode\n  | (Pick<PhoneCountry, \"code\"> & Partial<Pick<PhoneCountry, \"name\" | \"flag\">>)\n\nconst REGIONAL_INDICATOR_OFFSET = 0x1f1a5\n\nconst regionNames = new Intl.DisplayNames([\"en\"], { type: \"region\" })\n\nfunction dialCodeOf(country: CountryCode) {\n  return `+${getCountryCallingCode(country)}`\n}\n\nfunction digitsOf(text: string) {\n  return text.replace(/\\D/g, \"\")\n}\n\nfunction foldText(text: string) {\n  return text.normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLowerCase()\n}\n\nexport function countryFlag(country: CountryCode) {\n  return String.fromCodePoint(\n    ...Array.from(\n      country,\n      (letter) => REGIONAL_INDICATOR_OFFSET + letter.charCodeAt(0)\n    )\n  )\n}\n\nexport const PHONE_COUNTRIES: PhoneCountry[] = getCountries()\n  .map((code) => ({\n    code,\n    name: regionNames.of(code) ?? code,\n    dialCode: dialCodeOf(code),\n    flag: countryFlag(code),\n  }))\n  .sort((a, b) => a.name.localeCompare(b.name))\n\nexport function resolveCountries(\n  countries?: PhoneCountryOption[],\n  excludedCountries: CountryCode[] = []\n) {\n  const chosen =\n    countries?.flatMap((option) => {\n      const { code, ...overrides } =\n        typeof option === \"string\" ? { code: option } : option\n      const country = PHONE_COUNTRIES.find((entry) => entry.code === code)\n      return country ? [{ ...country, ...overrides }] : []\n    }) ?? PHONE_COUNTRIES\n  return chosen.filter(({ code }) => !excludedCountries.includes(code))\n}\n\nexport function filterCountries(countries: PhoneCountry[], query: string) {\n  const needle = foldText(query.trim())\n  if (!needle) return countries\n  const dialPrefix = needle.startsWith(\"+\") ? needle : `+${needle}`\n  return countries.filter(\n    (country) =>\n      foldText(country.name).includes(needle) ||\n      country.code.toLowerCase() === needle ||\n      country.dialCode.startsWith(dialPrefix)\n  )\n}\n\nfunction isInternationalText(text: string) {\n  return text.trimStart().startsWith(\"+\")\n}\n\nfunction groupWithoutTrunkPrefix(\n  digits: string,\n  value: string,\n  country: CountryCode\n) {\n  const grouped = new AsYouType()\n    .input(value)\n    .slice(dialCodeOf(country).length)\n    .trim()\n  return digitsOf(grouped) === digits ? grouped : digits\n}\n\nexport function formatPhoneInput(text: string, country: CountryCode) {\n  const digits = digitsOf(text)\n  if (isInternationalText(text)) {\n    const typer = new AsYouType()\n    const display = typer.input(`+${digits}`)\n    return { display, value: typer.getNumberValue() ?? \"\" }\n  }\n  const typer = new AsYouType(country)\n  const display = typer.input(digits)\n  const value = typer.getNumberValue() ?? \"\"\n  const withLeadingDigits = `${dialCodeOf(country)}${digits}`\n  if (!isValidPhoneNumber(value) && isValidPhoneNumber(withLeadingDigits)) {\n    return {\n      display: groupWithoutTrunkPrefix(digits, withLeadingDigits, country),\n      value: withLeadingDigits,\n    }\n  }\n  return {\n    display:\n      display === digits && value\n        ? groupWithoutTrunkPrefix(digits, value, country)\n        : display,\n    value,\n  }\n}\n\nexport function formatPhoneValue(value: string, country: CountryCode) {\n  const dialCode = dialCodeOf(country)\n  const national = value.slice(dialCode.length)\n  if (!value.startsWith(dialCode) || !national) {\n    return new AsYouType().input(value)\n  }\n  return formatPhoneInput(national, country).display\n}\n\nfunction includesCountry(countries: PhoneCountry[], country: string) {\n  return countries.some(({ code }) => code === country)\n}\n\nexport function countryForValue(\n  value: string,\n  selected: CountryCode,\n  countries = PHONE_COUNTRIES\n) {\n  const fallback = includesCountry(countries, selected)\n    ? selected\n    : (countries[0]?.code ?? selected)\n  if (!value) return fallback\n  const typer = new AsYouType()\n  typer.input(value)\n  const detected = typer.getCountry()\n  if (detected && includesCountry(countries, detected)) return detected\n  const callingCode = typer.getCallingCode()\n  if (!callingCode || callingCode === getCountryCallingCode(fallback)) {\n    return fallback\n  }\n  const candidates = metadata.country_calling_codes[callingCode] ?? []\n  return (\n    candidates.find((candidate) => includesCountry(countries, candidate)) ??\n    fallback\n  )\n}\n\nexport function isAllowedNumber(value: string, countries: PhoneCountry[]) {\n  const typer = new AsYouType()\n  typer.input(value)\n  const detected = typer.getCountry()\n  if (detected) return includesCountry(countries, detected)\n  const callingCode = typer.getCallingCode()\n  return (\n    !callingCode ||\n    countries.some(({ dialCode }) => dialCode === `+${callingCode}`)\n  )\n}\n\nexport function formatInsertedPhone(\n  text: string,\n  country: CountryCode,\n  countries = PHONE_COUNTRIES\n) {\n  const { display, value } = formatPhoneInput(text, country)\n  const insertedCountry = countryForValue(value, country, countries)\n  return {\n    value,\n    country: insertedCountry,\n    display: isInternationalText(text)\n      ? formatPhoneValue(value, insertedCountry)\n      : display,\n  }\n}\n\nexport function changeCountry(\n  value: string,\n  from: CountryCode,\n  to: CountryCode\n) {\n  const dialCode = dialCodeOf(from)\n  const national = value.startsWith(dialCode)\n    ? value.slice(dialCode.length)\n    : \"\"\n  return formatPhoneInput(national, to)\n}\n\nexport function deleteDigitPastFormatting(\n  previous: string,\n  text: string,\n  caret: number,\n  isForward: boolean\n) {\n  const removedFormatting =\n    previous.length - text.length === 1 && digitsOf(previous) === digitsOf(text)\n  if (!removedFormatting) return { text, caret }\n\n  const digitIndex = isForward\n    ? text.slice(caret).search(/\\d/)\n    : text.slice(0, caret).search(/\\d\\D*$/)\n  if (digitIndex === -1) return { text, caret }\n\n  const index = isForward ? caret + digitIndex : digitIndex\n  return {\n    text: text.slice(0, index) + text.slice(index + 1),\n    caret: isForward ? caret : index,\n  }\n}\n\nexport function countDigits(text: string) {\n  return digitsOf(text).length\n}\n\nexport function caretAfterDigits(text: string, digitCount: number) {\n  if (digitCount >= countDigits(text)) return text.length\n  let seen = 0\n  let index = 0\n  while (seen < digitCount) {\n    if (/\\d/.test(text[index])) seen += 1\n    index += 1\n  }\n  return index\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}