{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-input",
  "type": "registry:ui",
  "title": "Phone input",
  "description": "International phone field with country search, E.164 values, and Zod validation helpers.",
  "dependencies": [
    "libphonenumber-js@^1.13.9",
    "react-phone-number-input@^3.4.17",
    "zod@^4.4.3"
  ],
  "registryDependencies": [
    "http://127.0.0.1:3000/r/command.json",
    "http://127.0.0.1:3000/r/core.json",
    "http://127.0.0.1:3000/r/input-group.json",
    "http://127.0.0.1:3000/r/popover.json"
  ],
  "files": [
    {
      "path": "src/registry/lib/phone-input-format.ts",
      "target": "@components/control-ui/lib/phone-input-format.ts",
      "type": "registry:lib",
      "content": "import { AsYouType } from \"libphonenumber-js/max\";\nimport { type Country, getCountryCallingCode, type Value } from \"react-phone-number-input/max\";\n\nexport function normalizePhoneInputText(value: string, country: Country | undefined) {\n  if (!country) return value;\n\n  const callingCode = `+${getCountryCallingCode(country)}`;\n  if (!value.startsWith(callingCode)) return value;\n\n  const nationalDigits = value.slice(callingCode.length).replaceAll(/\\D/g, \"\");\n  if (!nationalDigits) return value;\n\n  const nationalFormatter = new AsYouType(country);\n  nationalFormatter.input(nationalDigits);\n  const normalizedValue = nationalFormatter.getNumberValue();\n  return normalizedValue ? new AsYouType().input(normalizedValue) : value;\n}\n\nexport function normalizePhoneInputValue(value: Value | undefined, country: Country | undefined) {\n  if (!value || !country) return value;\n\n  const callingCode = `+${getCountryCallingCode(country)}`;\n  if (!value.startsWith(callingCode)) return value;\n\n  const nationalDigits = value.slice(callingCode.length);\n  if (!nationalDigits) return value;\n\n  const formatter = new AsYouType(country);\n  formatter.input(nationalDigits);\n  return formatter.getNumberValue();\n}\n"
    },
    {
      "path": "src/registry/lib/phone-number.ts",
      "target": "@components/control-ui/lib/phone-number.ts",
      "type": "registry:lib",
      "content": "import { isPossiblePhoneNumber, isValidPhoneNumber, type Value as PhoneInputValue, parsePhoneNumber } from \"react-phone-number-input/max\";\nimport { e164 } from \"zod\";\n\nexport type PhoneNumberValidationMode = \"possible\" | \"valid\";\n\nexport type PhoneNumberSchemaMessages = {\n  format?: string;\n  invalid?: string;\n  mobile?: string;\n};\n\nexport type PhoneNumberSchemaOptions = {\n  mode?: PhoneNumberValidationMode;\n  mobileOnly?: boolean;\n  messages?: PhoneNumberSchemaMessages;\n};\n\nconst DEFAULT_MESSAGES = {\n  format: \"Enter a phone number in international format.\",\n  invalid: \"Enter a valid phone number.\",\n  mobile: \"Enter a mobile phone number.\",\n} as const;\n\nconst MOBILE_TYPES = new Set([\"MOBILE\", \"FIXED_LINE_OR_MOBILE\"]);\n\nfunction isMobilePhoneNumber(value: string) {\n  try {\n    const type = parsePhoneNumber(value)?.getType();\n    return type ? MOBILE_TYPES.has(type) : false;\n  } catch {\n    return false;\n  }\n}\n\nexport function createPhoneNumberSchema({ mode = \"valid\", mobileOnly = false, messages = {} }: PhoneNumberSchemaOptions = {}) {\n  const isAccepted = mode === \"possible\" ? isPossiblePhoneNumber : isValidPhoneNumber;\n\n  let schema = e164(messages.format ?? DEFAULT_MESSAGES.format).refine(isAccepted, messages.invalid ?? DEFAULT_MESSAGES.invalid);\n\n  if (mobileOnly) {\n    schema = schema.refine(isMobilePhoneNumber, messages.mobile ?? DEFAULT_MESSAGES.mobile);\n  }\n\n  return schema.transform((value): PhoneInputValue => value);\n}\n\nexport const phoneNumberSchema = createPhoneNumberSchema();\nexport const possiblePhoneNumberSchema = createPhoneNumberSchema({ mode: \"possible\" });\nexport const mobilePhoneNumberSchema = createPhoneNumberSchema({ mobileOnly: true });\n"
    },
    {
      "path": "src/registry/sources/control-ui/ui/phone-input.tsx",
      "target": "@components/control-ui/ui/phone-input.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport type { ChangeEventHandler, ComponentProps, ComponentType, Ref, SVGProps } from \"react\";\nimport { useState } from \"react\";\nimport type { EmbeddedFlagProps, Labels } from \"react-phone-number-input\";\nimport flags from \"react-phone-number-input/flags\";\nimport PhoneNumberInput, { type Country, getCountryCallingCode, parsePhoneNumber, type Value } from \"react-phone-number-input/max\";\n\nimport type { ControlSize } from \"@/components/control-ui/contracts\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { normalizePhoneInputText, normalizePhoneInputValue } from \"@/components/control-ui/lib/phone-input-format\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"@/components/control-ui/ui/command\";\nimport { InputGroup, InputGroupAddon, InputGroupInput } from \"@/components/control-ui/ui/input-group\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/control-ui/ui/popover\";\n\nexport type PhoneInputValue = Value;\nexport type PhoneInputCountry = Country;\n\ntype CountryOptionOrder = PhoneInputCountry | \"XX\" | \"🌐\" | \"|\" | \"...\" | \"…\";\n\nexport type PhoneInputProps = Omit<ComponentProps<\"input\">, \"defaultValue\" | \"size\" | \"type\" | \"value\"> & {\n  size?: ControlSize;\n  value?: PhoneInputValue | string;\n  defaultValue?: PhoneInputValue | string;\n  onValueChange?: (value?: PhoneInputValue) => void;\n  defaultCountry?: PhoneInputCountry;\n  countries?: PhoneInputCountry[];\n  labels?: Labels;\n  locales?: string | string[];\n  countryOptionsOrder?: CountryOptionOrder[];\n  onCountryChange?: (country?: PhoneInputCountry) => void;\n  countryCallingCodeEditable?: boolean;\n  addInternationalOption?: boolean;\n  countrySearchPlaceholder?: string;\n  countryEmptyMessage?: string;\n  \"data-invalid\"?: boolean | string;\n};\n\ntype CountrySelectOption = {\n  value?: PhoneInputCountry;\n  label: string;\n};\n\ntype PhoneCountrySelectProps = {\n  value?: PhoneInputCountry;\n  options: CountrySelectOption[];\n  onChange: (country?: PhoneInputCountry) => void;\n  onFocus?: () => void;\n  onBlur?: () => void;\n  disabled?: boolean;\n  readOnly?: boolean;\n  \"aria-label\"?: string;\n  searchPlaceholder?: string;\n  emptyMessage?: string;\n};\n\ntype PhoneInputControlProps = ComponentProps<\"input\"> & {\n  normalizationCountry?: PhoneInputCountry;\n  onNativeChange?: ChangeEventHandler<HTMLInputElement>;\n};\n\n// country-flag-icons spreads full SVG props while react-phone-number-input declares ({ title }) => Element;\n// intersecting both keeps `title` required, so no widening assertion is needed.\ntype FlagComponent = ComponentType<SVGProps<SVGSVGElement> & EmbeddedFlagProps>;\nconst FLAG_COMPONENTS: Partial<Record<PhoneInputCountry, FlagComponent>> = flags;\nconst PhoneNumberInputWithRef: ComponentType<ComponentProps<typeof PhoneNumberInput> & { inputRef?: Ref<HTMLInputElement> }> =\n  PhoneNumberInput;\n\nfunction PhoneInputContainer({ className, ...props }: ComponentProps<typeof InputGroup>) {\n  return <InputGroup data-phone-input=\"\" className={cn(\"gap-0 p-0\", className)} {...props} />;\n}\n\nfunction PhoneInputControl({ className, normalizationCountry, onChange, onNativeChange, ...props }: PhoneInputControlProps) {\n  return (\n    <InputGroupInput\n      data-phone-input-control=\"\"\n      dir=\"ltr\"\n      className={cn(\"px-3 tabular-nums\", className)}\n      onChange={(event) => {\n        const normalizedText = normalizePhoneInputText(event.currentTarget.value, normalizationCountry);\n        if (normalizedText !== event.currentTarget.value) event.currentTarget.value = normalizedText;\n        onChange?.(event);\n        onNativeChange?.(event);\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction PhoneCountrySelect({\n  value,\n  options,\n  onChange,\n  onFocus,\n  onBlur,\n  disabled,\n  readOnly,\n  \"aria-label\": ariaLabel = \"Country\",\n  searchPlaceholder = \"Search country...\",\n  emptyMessage = \"No country found.\",\n}: PhoneCountrySelectProps) {\n  const [open, setOpen] = useState(false);\n  const selectedOption = options.find((option) => option.value === value);\n  const selectedCallingCode = value ? `+${getCountryCallingCode(value)}` : undefined;\n  const accessibleLabel = selectedOption\n    ? `${ariaLabel}: ${selectedOption.label}${selectedCallingCode ? ` (${selectedCallingCode})` : \"\"}`\n    : ariaLabel;\n\n  return (\n    <InputGroupAddon data-phone-input-country=\"\" className=\"h-full self-stretch border-e border-border p-0\">\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger\n          render={<button type=\"button\" />}\n          disabled={disabled || readOnly}\n          aria-label={accessibleLabel}\n          onFocus={onFocus}\n          onBlur={onBlur}\n          className=\"inline-flex h-full min-w-14 items-center justify-center gap-2 px-3 outline-none transition-colors hover:bg-foreground/6 focus-visible:bg-foreground/6 disabled:cursor-not-allowed disabled:opacity-50\"\n        >\n          <CountryFlag country={value} />\n          <ChevronIcon open={open} />\n        </PopoverTrigger>\n        <PopoverContent align=\"start\" padding=\"none\" className=\"w-[min(20rem,calc(100vw-2rem))]\">\n          <Command>\n            <CommandInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} />\n            <CommandList>\n              <CommandEmpty>{emptyMessage}</CommandEmpty>\n              <CommandGroup>\n                {options.map((option) => {\n                  const callingCode = option.value ? `+${getCountryCallingCode(option.value)}` : \"\";\n                  const selected = option.value === value;\n\n                  return (\n                    <CommandItem\n                      key={option.value ?? \"international\"}\n                      value={`${option.label} ${option.value ?? \"international\"} ${callingCode}`}\n                      data-current={selected ? \"\" : undefined}\n                      onSelect={() => {\n                        onChange(option.value);\n                        setOpen(false);\n                      }}\n                    >\n                      <CountryFlag country={option.value} />\n                      <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n                      {callingCode ? <span className=\"text-caption tabular-nums text-muted-foreground\">{callingCode}</span> : null}\n                      {selected ? <span className=\"sr-only\">Selected</span> : null}\n                      <CheckIcon visible={selected} />\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    </InputGroupAddon>\n  );\n}\n\nfunction CountryFlag({ country }: { country?: PhoneInputCountry }) {\n  if (!country) return <GlobeIcon />;\n  const Flag = FLAG_COMPONENTS[country];\n  // empty title renders no <title>, keeping flag decorative beside country name\n  return Flag ? (\n    <Flag title=\"\" aria-hidden=\"true\" className=\"h-3.5 w-5 shrink-0 rounded-[2px]\" />\n  ) : (\n    <span className=\"text-caption\">{country}</span>\n  );\n}\n\nfunction PhoneFlag({ country }: { country: PhoneInputCountry; countryName: string }) {\n  return <CountryFlag country={country} />;\n}\n\nexport function PhoneInput(props: PhoneInputProps) {\n  const controlled = Object.hasOwn(props, \"value\");\n  const {\n    ref,\n    size = \"md\",\n    value,\n    defaultValue,\n    onValueChange,\n    onChange: onNativeChange,\n    name,\n    disabled,\n    readOnly,\n    className,\n    defaultCountry,\n    countries,\n    labels,\n    locales,\n    countryOptionsOrder,\n    onCountryChange,\n    countryCallingCodeEditable = true,\n    addInternationalOption = true,\n    countrySearchPlaceholder,\n    countryEmptyMessage,\n    \"aria-invalid\": ariaInvalid,\n    \"data-invalid\": dataInvalid,\n    ...inputProps\n  } = props;\n  const [internalValue, setInternalValue] = useState<PhoneInputValue | string | undefined>(defaultValue);\n  const [selectedCountry, setSelectedCountry] = useState(defaultCountry);\n  const currentValue = controlled ? value : internalValue;\n\n  function handleValueChange(nextValue?: PhoneInputValue) {\n    const normalizationCountry = selectedCountry ?? (nextValue ? parsePhoneNumber(nextValue)?.country : undefined);\n    const normalizedValue = normalizePhoneInputValue(nextValue, normalizationCountry);\n    if (!controlled) setInternalValue(normalizedValue);\n    onValueChange?.(normalizedValue);\n  }\n\n  function handleCountryChange(country?: PhoneInputCountry) {\n    setSelectedCountry(country);\n    onCountryChange?.(country);\n  }\n\n  return (\n    <>\n      <PhoneNumberInputWithRef\n        {...inputProps}\n        inputRef={ref}\n        value={currentValue}\n        onChange={handleValueChange}\n        name={undefined}\n        className={className}\n        disabled={disabled}\n        readOnly={readOnly}\n        defaultCountry={defaultCountry}\n        countries={countries}\n        labels={labels}\n        locales={locales}\n        countryOptionsOrder={countryOptionsOrder}\n        onCountryChange={handleCountryChange}\n        international\n        countryCallingCodeEditable={countryCallingCodeEditable}\n        addInternationalOption={addInternationalOption}\n        autoComplete={inputProps.autoComplete ?? \"tel\"}\n        containerComponent={PhoneInputContainer}\n        containerComponentProps={{\n          size,\n          \"aria-invalid\": ariaInvalid,\n          \"data-invalid\": dataInvalid,\n          \"data-disabled\": disabled ? \"true\" : undefined,\n        }}\n        inputComponent={PhoneInputControl}\n        numberInputProps={{\n          normalizationCountry: selectedCountry,\n          onNativeChange,\n          \"aria-invalid\": ariaInvalid,\n          \"data-invalid\": dataInvalid,\n        }}\n        countrySelectComponent={PhoneCountrySelect}\n        countrySelectProps={{\n          searchPlaceholder: countrySearchPlaceholder,\n          emptyMessage: countryEmptyMessage,\n        }}\n        flagComponent={PhoneFlag}\n      />\n      {name && currentValue ? <input type=\"hidden\" name={name} value={currentValue} disabled={disabled} /> : null}\n    </>\n  );\n}\n\nfunction ChevronIcon({ open }: { open: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 12 12\"\n      className={cn(\n        \"size-3 text-muted-foreground transition-transform duration-[var(--duration-base)] ease-[var(--ease-emphasized)]\",\n        open && \"rotate-180\",\n      )}\n      aria-hidden=\"true\"\n      fill=\"none\"\n    >\n      <path d=\"M3 4.5 6 7.5 9 4.5\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon({ visible }: { visible: boolean }) {\n  return (\n    <span className={cn(\"flex size-3.5 shrink-0 items-center justify-center\", !visible && \"opacity-0\")} aria-hidden=\"true\">\n      <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n        <path d=\"M2.5 6.5 5 9l4.5-5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </svg>\n    </span>\n  );\n}\n\nfunction GlobeIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 shrink-0 text-muted-foreground\" fill=\"none\" aria-hidden=\"true\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.5\" stroke=\"currentColor\" strokeWidth=\"1.2\" />\n      <path\n        d=\"M2.75 8h10.5M8 2.5c1.45 1.5 2.15 3.34 2.15 5.5S9.45 12 8 13.5C6.55 12 5.85 10.16 5.85 8S6.55 4 8 2.5Z\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.2\"\n      />\n    </svg>\n  );\n}\n"
    }
  ],
  "meta": {}
}
