reui

Phone Input

Phone Input — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).

Загрузка превью…

src/reui/phone-input/PhoneInput.vue

<script setup lang="ts">
/**
 * Порт ReUI PhoneInput (registry-reui/bases/radix/reui/phone-input.tsx, MIT).
 *
 * Замена библиотеки: у `react-phone-number-input` нет Vue-порта. Вместо
 * неё — `libphonenumber-js` (форматирование через `AsYouType`, разбор
 * через `parsePhoneNumberFromString`) и собственная логика ввода/выбора
 * страны. Это меняет форму API: оригинал берёт `onChange`/`value` и всю
 * логику форматирования, разбора E.164 и списка стран из
 * `react-phone-number-input`; здесь то же самое собрано вручную поверх
 * `libphonenumber-js`, поведение по духу совпадает (значение наружу —
 * E.164 или пустая строка), но кобрёнок реализации не идентичен байт в
 * байт апстриму (там она вообще не видна — это внутренности библиотеки).
 *
 * `smartCaret={false}` в оригинале явно отключает работу с положением
 * курсора при переформатировании — здесь курсор тоже не сохраняется
 * между ре-форматированиями (одинаковый компромисс, не хуже оригинала).
 */
import type { HTMLAttributes } from "vue"
import { computed, provide, reactive, ref, watch } from "vue"
import { AsYouType, parsePhoneNumberFromString } from "libphonenumber-js"
import { cn } from "@/lib/utils"
import { PhoneInputContextKey, type PhoneInputSize } from "./context"
import PhoneInputCountrySelect from "./PhoneInputCountrySelect.vue"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    variant?: PhoneInputSize
    popupClassName?: string
    scrollAreaClassName?: string
    defaultCountry?: string
    placeholder?: string
    disabled?: boolean
    id?: string
    name?: string
    ariaInvalid?: boolean
  }>(),
  {
    variant: "default",
  }
)

const value = defineModel<string>()

const selectedCountry = ref<string | undefined>(props.defaultCountry)
// Хранит то, что реально набрано: либо международный номер с ведущим "+"
// (тогда форматируем международно, без привязки к выбранной стране — так
// же ведёт себя `react-phone-number-input`, когда значение приходит
// контролируемым `value`), либо цифры национального формата для текущей
// выбранной страны.
const rawDigits = ref("")

function formatDigits(digits: string, country?: string): string {
  if (!digits) return ""
  // Строка с ведущим "+" форматируется международно (AsYouType сам
  // определяет страну по калling-коду); без "+" — национально, по
  // выбранной в комбобоксе стране.
  return digits.startsWith("+")
    ? new AsYouType().input(digits)
    : new AsYouType(country as never).input(digits)
}

function syncFromValue(next: string | undefined) {
  if (!next) {
    rawDigits.value = ""
    return
  }
  const parsed = parsePhoneNumberFromString(next)
  if (parsed) {
    selectedCountry.value = parsed.country ?? selectedCountry.value
  }
  // Как и `react-phone-number-input` при контролируемом `value`: значение
  // отображается в международном формате (с "+" и кодом страны).
  rawDigits.value = next
}

watch(() => value.value, syncFromValue, { immediate: true })

function emitFromDigits() {
  if (!rawDigits.value) {
    value.value = ""
    return
  }
  const formatted = formatDigits(rawDigits.value, selectedCountry.value)
  const parsed = parsePhoneNumberFromString(formatted, selectedCountry.value as never)
  value.value = parsed?.number ?? ""
}

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  rawDigits.value = target.value.replace(/[^\d+]/g, "")
  emitFromDigits()
}

watch(selectedCountry, () => emitFromDigits())

provide(
  PhoneInputContextKey,
  reactive({
    variant: computed(() => props.variant ?? "default"),
    popupClassName: computed(() => props.popupClassName),
    scrollAreaClassName: computed(() => props.scrollAreaClassName),
  })
)
</script>

<template>
  <div
    :class="
      cn(
        'flex',
        props.ariaInvalid &&
          '[&_*[data-slot=combobox-trigger]]:border-destructive [&_*[data-slot=combobox-trigger]]:ring-destructive/50',
        props.class
      )
    "
  >
    <PhoneInputCountrySelect
      v-model="selectedCountry"
      :disabled="props.disabled"
    />
    <input
      data-slot="input"
      :id="props.id"
      :name="props.name"
      type="tel"
      :disabled="props.disabled"
      :aria-invalid="props.ariaInvalid"
      :placeholder="props.placeholder"
      :value="formatDigits(rawDigits, selectedCountry)"
      :class="
        cn(
          'cn-input w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
          'rounded-s-none focus:z-1',
          props.variant === 'sm' && 'h-7',
          props.variant === 'lg' && 'h-9'
        )
      "
      @input="handleInput"
    />
  </div>
</template>

src/reui/phone-input/PhoneInputCountrySelect.vue

<script setup lang="ts">
/**
 * Порт `CountrySelect` из ReUI phone-input. В оригинале это Base UI
 * `Combobox` (registry/bases/radix/ui/combobox.tsx) с триггером,
 * подменённым на `Button` через `render`-проп (Base UI-паттерн: при
 * `render` собственная разметка `ComboboxTrigger` — chevron, свой
 * `data-slot` — не рендерится, вся разметка приходит из `render`-элемента,
 * поверх неё накладывается только поведение примитива). Итоговый DOM
 * триггера — это именно `Button`, без `cn-combobox-trigger`/шеврона.
 *
 * Примитив: у reka-ui нет `Group`-подобной обвязки Base UI Combobox
 * (Portal/Positioner/Popup разделены иначе), но набор частей
 * (Root/Trigger/Anchor/Content/Input/Item/Empty/Separator) соответствует
 * по смыслу. `ComboboxList` и `ScrollArea` из shadcn-обвязки здесь не
 * заведены отдельными компонентами (вне области порта: это общие ui-
 * примитивы, а не reui/phone-input), поэтому их разметка и классы
 * (`cn-combobox-list`, `cn-scroll-area*`, уже есть в вендоренных
 * per-style CSS) воспроизведены инлайново.
 */
import { computed, inject } from "vue"
import {
  ComboboxAnchor,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxRoot,
  ComboboxSeparator,
  ComboboxTrigger,
} from "reka-ui"
import { getCountryCallingCode } from "libphonenumber-js"
import { cn } from "@/lib/utils"
import { PhoneInputContextKey } from "./context"
import { countries } from "./countries-data"
import PhoneInputFlag from "./PhoneInputFlag.vue"

const props = defineProps<{
  disabled?: boolean
}>()

const country = defineModel<string>()

const context = inject(PhoneInputContextKey)!

const sizeClass = computed(() => {
  switch (context.variant) {
    case "sm":
      return "cn-button-size-sm"
    case "lg":
      return "cn-button-size-lg"
    default:
      return "cn-button-size-default"
  }
})
</script>

<template>
  <ComboboxRoot
    :model-value="country"
    @update:model-value="(v) => (country = (v as string) || undefined)"
  >
    <ComboboxAnchor as-child>
      <ComboboxTrigger
        data-slot="button"
        data-variant="outline"
        :data-size="context.variant"
        :disabled="props.disabled"
        :class="
          cn(
            'cn-button group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
            'cn-button-variant-outline',
            sizeClass,
            'style-vega:rounded-s-md style-nova:rounded-s-lg style-maia:rounded-s-4xl style-lyra:rounded-s-none style-mira:rounded-s-md style-luma:rounded-s-3xl style-rhea:rounded-s-2xl style-sera:rounded-s-none flex gap-1 rounded-e-none border-e-0 px-2.5 py-0 leading-none hover:bg-transparent focus:z-10 data-pressed:bg-transparent',
            props.disabled && 'opacity-50'
          )
        "
      >
        <span class="sr-only">{{ country }}</span>
        <PhoneInputFlag :country="country" :country-name="country" />
      </ComboboxTrigger>
    </ComboboxAnchor>

    <ComboboxContent
      position="popper"
      side="bottom"
      :side-offset="6"
      align="start"
      :align-offset="0"
      data-slot="combobox-content"
      :class="
        cn(
          'cn-combobox-content cn-combobox-content-logical cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin)',
          'w-xs *:data-[slot=input-group]:bg-transparent',
          context.popupClassName
        )
      "
    >
      <ComboboxInput
        data-slot="combobox-chip-input"
        placeholder="e.g. United States"
        class="cn-combobox-chip-input min-w-16 flex-1 outline-none border-input focus-visible:border-border rounded-none border-0 px-0 py-2.5 shadow-none ring-0! outline-none! focus-visible:ring-0 focus-visible:ring-offset-0"
      />
      <ComboboxSeparator
        data-slot="combobox-separator"
        class="cn-combobox-separator"
      />
      <ComboboxEmpty
        data-slot="combobox-empty"
        class="cn-combobox-empty px-4 py-2.5 text-sm"
      >
        No country found.
      </ComboboxEmpty>
      <div data-slot="combobox-list" class="cn-combobox-list overflow-y-auto overscroll-contain">
        <div class="relative flex max-h-full">
          <div class="flex max-h-[min(var(--available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain">
            <div
              data-slot="scroll-area"
              :class="
                cn(
                  'cn-scroll-area relative size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 [&_[data-slot=scroll-area-viewport]]:h-full [&_[data-slot=scroll-area-viewport]]:overscroll-contain',
                  context.scrollAreaClassName
                )
              "
            >
              <div
                data-slot="scroll-area-viewport"
                class="cn-scroll-area-viewport size-full rounded-[inherit] transition-[color,box-shadow] outline-none overflow-auto"
              >
                <ComboboxItem
                  v-for="item in countries"
                  :key="item.value"
                  :value="item.value"
                  :text-value="item.label"
                  data-slot="combobox-item"
                  class="cn-combobox-item relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 flex items-center gap-2"
                >
                  <PhoneInputFlag :country="item.value" :country-name="item.label" />
                  <span class="flex-1 text-sm">{{ item.label }}</span>
                  <span class="text-foreground/50 text-sm">{{ `+${getCountryCallingCode(item.value as never)}` }}</span>
                </ComboboxItem>
              </div>
            </div>
          </div>
        </div>
      </div>
    </ComboboxContent>
  </ComboboxRoot>
</template>

src/reui/phone-input/PhoneInputFlag.vue

<script setup lang="ts">
/**
 * Порт `FlagComponent` из ReUI phone-input. В оригинале флаг берётся из
 * `react-phone-number-input/flags` (реэкспорт `country-flag-icons/react/3x2`).
 * Здесь — те же самые SVG (см. flags-data.ts), извлечённые из
 * `country-flag-icons/3x2/*.svg` (тот же пакет, MIT), но только для
 * ограниченного набора стран — см. комментарий в flags-data.ts. Для
 * остального (и для неизвестных кодов, как в оригинале) — запасная
 * иконка-глобус вместо `IconPlaceholder` (dev-инструмент сайта ReUI, не
 * компилируется вне апстрима, см. общий комментарий в rating/Rating.vue).
 */
import { computed } from "vue"
import { flags } from "./flags-data"

const props = defineProps<{
  country?: string
  countryName?: string
}>()

const flag = computed(() => (props.country ? flags[props.country] : undefined))
</script>

<template>
  <span
    class="flex h-4 w-4 items-center justify-center [&_svg:not([class*='size-'])]:size-full! [&_svg:not([class*='size-'])]:rounded-[5px]"
  >
    <svg
      v-if="flag"
      :viewBox="flag.viewBox"
      :aria-label="countryName"
      role="img"
      xmlns="http://www.w3.org/2000/svg"
      v-html="flag.inner"
    />
    <svg
      v-else
      class="size-4 opacity-60"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      xmlns="http://www.w3.org/2000/svg"
    >
      <circle cx="12" cy="12" r="10" />
      <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
      <path d="M2 12h20" />
    </svg>
  </span>
</template>

src/reui/phone-input/context.ts

import type { InjectionKey } from "vue"

export type PhoneInputSize = "sm" | "default" | "lg"

export interface PhoneInputContextValue {
  variant: PhoneInputSize
  popupClassName?: string
  scrollAreaClassName?: string
}

export const PhoneInputContextKey: InjectionKey<PhoneInputContextValue> =
  Symbol("PhoneInputContext")

src/reui/phone-input/countries-data.ts

/**
 * Список стран для CountrySelect: ISO-коды из `libphonenumber-js` (`getCountries()`,
 * те же коды, что использует `react-phone-number-input`), названия — из
 * английской локали `react-phone-number-input/locale/en.json` (MIT), взятой
 * как источник данных на этапе портирования (рантайм-зависимости на
 * react-phone-number-input здесь нет). Сортировка — по названию, как и в
 * оригинальном компоненте (countryList приходит от библиотеки уже
 * отсортированным по локализованному названию).
 */
export interface CountryEntry {
  value: string
  label: string
}

export const countries: CountryEntry[] = [
  { value: "AF", label: "Afghanistan" },
  { value: "AX", label: "Åland Islands" },
  { value: "AL", label: "Albania" },
  { value: "DZ", label: "Algeria" },
  { value: "AS", label: "American Samoa" },
  { value: "AD", label: "Andorra" },
  { value: "AO", label: "Angola" },
  { value: "AI", label: "Anguilla" },
  { value: "AG", label: "Antigua and Barbuda" },
  { value: "AR", label: "Argentina" },
  { value: "AM", label: "Armenia" },
  { value: "AW", label: "Aruba" },
  { value: "AC", label: "Ascension Island" },
  { value: "AU", label: "Australia" },
  { value: "AT", label: "Austria" },
  { value: "AZ", label: "Azerbaijan" },
  { value: "BS", label: "Bahamas" },
  { value: "BH", label: "Bahrain" },
  { value: "BD", label: "Bangladesh" },
  { value: "BB", label: "Barbados" },
  { value: "BY", label: "Belarus" },
  { value: "BE", label: "Belgium" },
  { value: "BZ", label: "Belize" },
  { value: "BJ", label: "Benin" },
  { value: "BM", label: "Bermuda" },
  { value: "BT", label: "Bhutan" },
  { value: "BO", label: "Bolivia" },
  { value: "BQ", label: "Bonaire, Sint Eustatius and Saba" },
  { value: "BA", label: "Bosnia and Herzegovina" },
  { value: "BW", label: "Botswana" },
  { value: "BR", label: "Brazil" },
  { value: "IO", label: "British Indian Ocean Territory" },
  { value: "BN", label: "Brunei Darussalam" },
  { value: "BG", label: "Bulgaria" },
  { value: "BF", label: "Burkina Faso" },
  { value: "BI", label: "Burundi" },
  { value: "KH", label: "Cambodia" },
  { value: "CM", label: "Cameroon" },
  { value: "CA", label: "Canada" },
  { value: "CV", label: "Cape Verde" },
  { value: "KY", label: "Cayman Islands" },
  { value: "CF", label: "Central African Republic" },
  { value: "TD", label: "Chad" },
  { value: "CL", label: "Chile" },
  { value: "CN", label: "China" },
  { value: "CX", label: "Christmas Island" },
  { value: "CC", label: "Cocos (Keeling) Islands" },
  { value: "CO", label: "Colombia" },
  { value: "KM", label: "Comoros" },
  { value: "CG", label: "Congo" },
  { value: "CD", label: "Congo, Democratic Republic of the" },
  { value: "CK", label: "Cook Islands" },
  { value: "CR", label: "Costa Rica" },
  { value: "CI", label: "Cote d'Ivoire" },
  { value: "HR", label: "Croatia" },
  { value: "CU", label: "Cuba" },
  { value: "CW", label: "Curaçao" },
  { value: "CY", label: "Cyprus" },
  { value: "CZ", label: "Czech Republic" },
  { value: "DK", label: "Denmark" },
  { value: "DJ", label: "Djibouti" },
  { value: "DM", label: "Dominica" },
  { value: "DO", label: "Dominican Republic" },
  { value: "EC", label: "Ecuador" },
  { value: "EG", label: "Egypt" },
  { value: "SV", label: "El Salvador" },
  { value: "GQ", label: "Equatorial Guinea" },
  { value: "ER", label: "Eritrea" },
  { value: "EE", label: "Estonia" },
  { value: "ET", label: "Ethiopia" },
  { value: "FK", label: "Falkland Islands" },
  { value: "FO", label: "Faroe Islands" },
  { value: "FM", label: "Federated States of Micronesia" },
  { value: "FJ", label: "Fiji" },
  { value: "FI", label: "Finland" },
  { value: "FR", label: "France" },
  { value: "GF", label: "French Guiana" },
  { value: "PF", label: "French Polynesia" },
  { value: "GA", label: "Gabon" },
  { value: "GM", label: "Gambia" },
  { value: "GE", label: "Georgia" },
  { value: "DE", label: "Germany" },
  { value: "GH", label: "Ghana" },
  { value: "GI", label: "Gibraltar" },
  { value: "GR", label: "Greece" },
  { value: "GL", label: "Greenland" },
  { value: "GD", label: "Grenada" },
  { value: "GP", label: "Guadeloupe" },
  { value: "GU", label: "Guam" },
  { value: "GT", label: "Guatemala" },
  { value: "GG", label: "Guernsey" },
  { value: "GN", label: "Guinea" },
  { value: "GW", label: "Guinea-Bissau" },
  { value: "GY", label: "Guyana" },
  { value: "HT", label: "Haiti" },
  { value: "VA", label: "Holy See (Vatican City State)" },
  { value: "HN", label: "Honduras" },
  { value: "HK", label: "Hong Kong" },
  { value: "HU", label: "Hungary" },
  { value: "IS", label: "Iceland" },
  { value: "IN", label: "India" },
  { value: "ID", label: "Indonesia" },
  { value: "IR", label: "Iran" },
  { value: "IQ", label: "Iraq" },
  { value: "IE", label: "Ireland" },
  { value: "IM", label: "Isle of Man" },
  { value: "IL", label: "Israel" },
  { value: "IT", label: "Italy" },
  { value: "JM", label: "Jamaica" },
  { value: "JP", label: "Japan" },
  { value: "JE", label: "Jersey" },
  { value: "JO", label: "Jordan" },
  { value: "KZ", label: "Kazakhstan" },
  { value: "KE", label: "Kenya" },
  { value: "KI", label: "Kiribati" },
  { value: "XK", label: "Kosovo" },
  { value: "KW", label: "Kuwait" },
  { value: "KG", label: "Kyrgyzstan" },
  { value: "LA", label: "Laos" },
  { value: "LV", label: "Latvia" },
  { value: "LB", label: "Lebanon" },
  { value: "LS", label: "Lesotho" },
  { value: "LR", label: "Liberia" },
  { value: "LY", label: "Libya" },
  { value: "LI", label: "Liechtenstein" },
  { value: "LT", label: "Lithuania" },
  { value: "LU", label: "Luxembourg" },
  { value: "MO", label: "Macao" },
  { value: "MG", label: "Madagascar" },
  { value: "MW", label: "Malawi" },
  { value: "MY", label: "Malaysia" },
  { value: "MV", label: "Maldives" },
  { value: "ML", label: "Mali" },
  { value: "MT", label: "Malta" },
  { value: "MH", label: "Marshall Islands" },
  { value: "MQ", label: "Martinique" },
  { value: "MR", label: "Mauritania" },
  { value: "MU", label: "Mauritius" },
  { value: "YT", label: "Mayotte" },
  { value: "MX", label: "Mexico" },
  { value: "MD", label: "Moldova" },
  { value: "MC", label: "Monaco" },
  { value: "MN", label: "Mongolia" },
  { value: "ME", label: "Montenegro" },
  { value: "MS", label: "Montserrat" },
  { value: "MA", label: "Morocco" },
  { value: "MZ", label: "Mozambique" },
  { value: "MM", label: "Myanmar" },
  { value: "NA", label: "Namibia" },
  { value: "NR", label: "Nauru" },
  { value: "NP", label: "Nepal" },
  { value: "NL", label: "Netherlands" },
  { value: "NC", label: "New Caledonia" },
  { value: "NZ", label: "New Zealand" },
  { value: "NI", label: "Nicaragua" },
  { value: "NE", label: "Niger" },
  { value: "NG", label: "Nigeria" },
  { value: "NU", label: "Niue" },
  { value: "NF", label: "Norfolk Island" },
  { value: "KP", label: "North Korea" },
  { value: "MK", label: "North Macedonia" },
  { value: "MP", label: "Northern Mariana Islands" },
  { value: "NO", label: "Norway" },
  { value: "OM", label: "Oman" },
  { value: "PK", label: "Pakistan" },
  { value: "PW", label: "Palau" },
  { value: "PS", label: "Palestine" },
  { value: "PA", label: "Panama" },
  { value: "PG", label: "Papua New Guinea" },
  { value: "PY", label: "Paraguay" },
  { value: "PE", label: "Peru" },
  { value: "PH", label: "Philippines" },
  { value: "PL", label: "Poland" },
  { value: "PT", label: "Portugal" },
  { value: "PR", label: "Puerto Rico" },
  { value: "QA", label: "Qatar" },
  { value: "RE", label: "Reunion" },
  { value: "RO", label: "Romania" },
  { value: "RU", label: "Russia" },
  { value: "RW", label: "Rwanda" },
  { value: "BL", label: "Saint Barthélemy" },
  { value: "SH", label: "Saint Helena" },
  { value: "KN", label: "Saint Kitts and Nevis" },
  { value: "LC", label: "Saint Lucia" },
  { value: "MF", label: "Saint Martin (French Part)" },
  { value: "PM", label: "Saint Pierre and Miquelon" },
  { value: "VC", label: "Saint Vincent and the Grenadines" },
  { value: "WS", label: "Samoa" },
  { value: "SM", label: "San Marino" },
  { value: "ST", label: "Sao Tome and Principe" },
  { value: "SA", label: "Saudi Arabia" },
  { value: "SN", label: "Senegal" },
  { value: "RS", label: "Serbia" },
  { value: "SC", label: "Seychelles" },
  { value: "SL", label: "Sierra Leone" },
  { value: "SG", label: "Singapore" },
  { value: "SX", label: "Sint Maarten" },
  { value: "SK", label: "Slovakia" },
  { value: "SI", label: "Slovenia" },
  { value: "SB", label: "Solomon Islands" },
  { value: "SO", label: "Somalia" },
  { value: "ZA", label: "South Africa" },
  { value: "KR", label: "South Korea" },
  { value: "SS", label: "South Sudan" },
  { value: "ES", label: "Spain" },
  { value: "LK", label: "Sri Lanka" },
  { value: "SD", label: "Sudan" },
  { value: "SR", label: "Suriname" },
  { value: "SJ", label: "Svalbard and Jan Mayen" },
  { value: "SZ", label: "Swaziland" },
  { value: "SE", label: "Sweden" },
  { value: "CH", label: "Switzerland" },
  { value: "SY", label: "Syria" },
  { value: "TW", label: "Taiwan" },
  { value: "TJ", label: "Tajikistan" },
  { value: "TZ", label: "Tanzania" },
  { value: "TH", label: "Thailand" },
  { value: "TL", label: "Timor-Leste" },
  { value: "TG", label: "Togo" },
  { value: "TK", label: "Tokelau" },
  { value: "TO", label: "Tonga" },
  { value: "TT", label: "Trinidad and Tobago" },
  { value: "TA", label: "Tristan da Cunha" },
  { value: "TN", label: "Tunisia" },
  { value: "TR", label: "Turkey" },
  { value: "TM", label: "Turkmenistan" },
  { value: "TC", label: "Turks and Caicos Islands" },
  { value: "TV", label: "Tuvalu" },
  { value: "UG", label: "Uganda" },
  { value: "UA", label: "Ukraine" },
  { value: "AE", label: "United Arab Emirates" },
  { value: "GB", label: "United Kingdom" },
  { value: "US", label: "United States" },
  { value: "UY", label: "Uruguay" },
  { value: "UZ", label: "Uzbekistan" },
  { value: "VU", label: "Vanuatu" },
  { value: "VE", label: "Venezuela" },
  { value: "VN", label: "Vietnam" },
  { value: "VG", label: "Virgin Islands, British" },
  { value: "VI", label: "Virgin Islands, U.S." },
  { value: "WF", label: "Wallis and Futuna" },
  { value: "EH", label: "Western Sahara" },
  { value: "YE", label: "Yemen" },
  { value: "ZM", label: "Zambia" },
  { value: "ZW", label: "Zimbabwe" },
]

src/reui/phone-input/flags-data.ts

/**
 * Флаги стран (3x2), извлечены дословно из `country-flag-icons/3x2/*.svg`
 * (MIT, тот же пакет, из которого `react-phone-number-input/flags`
 * реэкспортирует свои React-компоненты флагов: `export { default as default }
 * from "country-flag-icons/react/3x2"`). Значит, при использовании
 * реальной библиотеки на React-стороне кейса и этих же самых SVG на
 * Vue-стороне флаги должны совпадать попиксельно.
 *
 * ВАЖНО — сознательное сокращение объёма: в оригинале доступны флаги для
 * всех ~250 кодов стран ITU. Здесь заведено 28 самых
 * распространённых (использованы в кейсе демонстрации). Для остальных
 * кодов `PhoneInputFlag` отрисовывает запасной globe-icon — как и
 * оригинал делает для НЕизвестных кодов, только здесь список "известных"
 * короче. Расширить до полного набора — механическая работа (см. скрипт
 * генерации в истории портирования), просто не уместилась в объём
 * текущего порта.
 */
export interface FlagSvgData {
  viewBox: string
  inner: string
}

export const flags: Record<string, FlagSvgData> = {
  US: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#D80027"><path d="M0 0h513v26.3H0zM0 52.6h513v26.3H0zM0 105.2h513v26.3H0zM0 157.8h513v26.3H0zM0 210.5h513v26.3H0zM0 263.1h513v26.3H0zM0 315.7h513V342H0z"/></g><path fill="#2E52B2" d="M0 0h256.5v184.1H0z"/><g fill="#FFF"><path d="m47.8 138.9-4-12.8-4.4 12.8H26.2l10.7 7.7-4 12.8 10.9-7.9 10.6 7.9-4.1-12.8 10.9-7.7zM104.1 138.9l-4.1-12.8-4.2 12.8H82.6l10.7 7.7-4 12.8 10.7-7.9 10.8 7.9-4-12.8 10.7-7.7zM160.6 138.9l-4.3-12.8-4 12.8h-13.5l11 7.7-4.2 12.8 10.7-7.9 11 7.9-4.2-12.8 10.7-7.7zM216.8 138.9l-4-12.8-4.2 12.8h-13.3l10.8 7.7-4 12.8 10.7-7.9 10.8 7.9-4.3-12.8 11-7.7zM100 75.3l-4.2 12.8H82.6L93.3 96l-4 12.6 10.7-7.8 10.8 7.8-4-12.6 10.7-7.9h-13.4zM43.8 75.3l-4.4 12.8H26.2L36.9 96l-4 12.6 10.9-7.8 10.6 7.8L50.3 96l10.9-7.9H47.8zM156.3 75.3l-4 12.8h-13.5l11 7.9-4.2 12.6 10.7-7.8 11 7.8-4.2-12.6 10.7-7.9h-13.2zM212.8 75.3l-4.2 12.8h-13.3l10.8 7.9-4 12.6 10.7-7.8 10.8 7.8-4.3-12.6 11-7.9h-13.5zM43.8 24.7l-4.4 12.6H26.2l10.7 7.9-4 12.7L43.8 50l10.6 7.9-4.1-12.7 10.9-7.9H47.8zM100 24.7l-4.2 12.6H82.6l10.7 7.9-4 12.7L100 50l10.8 7.9-4-12.7 10.7-7.9h-13.4zM156.3 24.7l-4 12.6h-13.5l11 7.9-4.2 12.7 10.7-7.9 11 7.9-4.2-12.7 10.7-7.9h-13.2zM212.8 24.7l-4.2 12.6h-13.3l10.8 7.9-4 12.7 10.7-7.9 10.8 7.9-4.3-12.7 11-7.9h-13.5z"/></g>` },
  GB: { viewBox: "0 0 513 342", inner: `<g fill="#FFF"><path d="M0 0h513v341.3H0V0z"/><path d="M311.7 230 513 341.3v-31.5L369.3 230h-57.6zM200.3 111.3 0 0v31.5l143.7 79.8h56.6z"/></g><g fill="#012169"><path d="M393.8 230 513 295.7V230H393.8zm-82.1 0L513 341.3v-31.5L369.3 230h-57.6zm146.9 111.3-147-81.7v81.7h147zM90.3 230 0 280.2V230h90.3zm110 14.2v97.2H25.5l174.8-97.2zM118.2 111.3 0 45.6v65.7h118.2zm82.1 0L0 0v31.5l143.7 79.8h56.6zM53.4 0l147 81.7V0h-147zM421.7 111.3 513 61.1v50.2h-91.3zm-110-14.2V0h174.9L311.7 97.1z"/></g><g fill="#c8102e"><path d="M288 0h-64v138.7H0v64h224v138.7h64V202.7h224v-64H288V0z"/><path d="M311.7 230 513 341.3v-31.5L369.3 230h-57.6zM143.7 230 0 309.9v31.5L200.3 230h-56.6zM200.3 111.3 0 0v31.5l143.7 79.8h56.6zM368.3 111.3 513 31.5V0L311.7 111.3h56.6z"/></g>` },
  DE: { viewBox: "0 0 513 342", inner: `<path fill="#D80027" d="M0 0h513v342H0z"/><path d="M0 0h513v114H0z"/><path fill="#FFDA44" d="M0 228h513v114H0z"/>` },
  FR: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#00318A" d="M0 0h171v342H0z"/><path fill="#D80027" d="M342 0h171v342H342z"/>` },
  ES: { viewBox: "0 0 22.5 15", inner: `<path fill="#FFF" d="M0 0h22.5v15H0V0z"/><path fill="#D03433" d="M0 0h22.5v4H0V0zm0 11h22.5v4H0v-4z"/><path fill="#FBCA46" d="M0 4h22.5v7H0V4z"/><path fill="#FFF" d="M7.8 7h1v.5h-1V7z"/><path fill="#A41517" d="M7.2 8.5c0 .3.3.5.6.5s.6-.2.6-.5L8.5 7H7.1l.1 1.5zM6.6 7c0-.3.2-.5.4-.5h1.5c.3 0 .5.2.5.4V7l-.1 1.5c-.1.6-.5 1-1.1 1-.6 0-1-.4-1.1-1L6.6 7z"/><path fill="#A41517" d="M6.8 7.5h2V8h-.5l-.5 1-.5-1h-.5v-.5zM5.3 6h1v3.5h-1V6zm4 0h1v3.5h-1V6zm-2.5-.5c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v.2c0 .2-.1.3-.3.3H7c-.1 0-.2-.1-.2-.2v-.3z"/>` },
  IT: { viewBox: "0 0 513 342", inner: `<path fill="#F4F5F0" d="M342 0H0v341.3h512V0z"/><path fill="#008C45" d="M0 0h171v342H0z"/><path fill="#CD212A" d="M342 0h171v342H342z"/>` },
  LV: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#A2001D"><path class="st1" d="M0 0h513v127.6H0zM0 214.4h513V342H0z"/></g>` },
  LT: { viewBox: "0 0 513 342", inner: `<path fill="#6DA544" d="M0 0h513v342H0z"/><path fill="#FFDA44" d="M0 0h513v114H0z"/><path fill="#D80027" d="M0 228h513v114H0z"/>` },
  EE: { viewBox: "0 0 513 342", inner: `<path fill="#111" d="M0 0h513v342H0z"/><path fill="#368FD8" d="M0 0h513v114H0z"/><path fill="#FFF" d="M0 228h513v114H0z"/>` },
  RU: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v114H0z"/><path fill="#0039a6" d="M0 114h513v114H0z"/><path fill="#d52b1e" d="M0 228h513v114H0z"/>` },
  UA: { viewBox: "0 85.333 512 341.333", inner: `<path fill="#FFDA44" d="M0 85.337h512v341.326H0z"/><path fill="#338AF3" d="M0 85.337h512V256H0z"/>` },
  PL: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#dc143c" d="M0 171h513v171H0z"/>` },
  NL: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 114h513v114H0z"/><path fill="#cd1f2a" d="M0 0h513v114H0z"/><path fill="#1d4185" d="M0 228h513v114H0z"/>` },
  BE: { viewBox: "0 0 513 342", inner: `<path fill="#fdda25" d="M0 0h513v342H0z"/><path d="M0 0h171v342H0z"/><path fill="#ef3340" d="M342 0h171v342H342z"/>` },
  CH: { viewBox: "0 0 768 512", inner: `<path fill="red" d="M0 0h768v512H0V0z"/><path fill="#FFF" d="M336 96h96v112h112v96H432v112h-96V304H224v-96h112V96z"/>` },
  AT: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 114h513v114H0z"/><path fill="#D80027" d="M0 0h513v114H0zM0 228h513v114H0z"/>` },
  SE: { viewBox: "0 0 513 342", inner: `<path fill="#004F8E" d="M0 0h513v342H0z"/><path fill="#F6C500" d="M192.4.3h-64.2v138.8H0v64.1h128.2V342h64.2V203.2H513v-64.1H192.4z"/>` },
  NO: { viewBox: "0 0 513 342", inner: `<path fill="#ba0c2f" d="M0 0h513v342H0z"/><path fill="#FFF" d="M513 211H202.6v131h-79.9V211H0v-80h122.7V0h79.9v131H513v61.4z"/><path fill="#00205b" d="M513 149.6v42.8H184V342h-42.7V192.4H0v-42.8h141.3V0H184v149.6z"/>` },
  DK: { viewBox: "0 0 513 342", inner: `<path fill="#c60c30" d="M0 0h513v342H0z"/><path fill="#FFF" d="M190 0h-60v140H0v60h130v142h60V200h323v-60H190z"/>` },
  FI: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#2E52B2" d="M513 129.3V212H203.7v130H121V212H0v-82.7h121V0h82.7v129.3z"/>` },
  JP: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h512v342H0z"/><circle fill="#D80027" cx="256.5" cy="171" r="96"/>` },
  CN: { viewBox: "0 0 513 342", inner: `<path fill="#D80027" d="M0 0h513v342H0z"/><g fill="#FFDA44"><path d="m226.8 239.2-9.7-15.6-17.9 4.4 11.9-14.1-9.7-15.6 17.1 6.9 11.8-14.1-1.3 18.4 17.1 6.9-17.9 4.4zM290.6 82l-10.1 15.4 11.6 14.3-17.7-4.8-10.1 15.5-1-18.4-17.7-4.8 17.2-6.6-1-18.4 11.6 14.3zM236.2 25.4l-2 18.3 16.8 7.6-18 3.8-2 18.3-9.2-16-17.9 3.8 12.3-13.7-9.2-15.9 16.8 7.5zM292.8 161.8l-14.9 10.9 5.8 17.5-14.9-10.8-14.9 11 5.6-17.6-14.9-10.7 18.4-.1 5.6-17.6 5.8 17.5zM115 46.3l17.3 53.5h56.2l-45.4 32.9 17.3 53.5-45.4-33-45.5 33 17.4-53.5-45.5-32.9h56.3z"/></g>` },
  IN: { viewBox: "0 0 513 342", inner: `<path fill="#ff6820" d="M0 0h513v114H0V0z"/><path fill="#FFF" d="M0 114h513v114H0V114z"/><path fill="#046a38" d="M0 228h513v114H0V228z"/><path fill="none" stroke="#07038d" stroke-width="4" d="M256.5 136.8c18.9 0 34.2 15.3 34.2 34.2s-15.3 34.2-34.2 34.2-34.2-15.3-34.2-34.2 15.3-34.2 34.2-34.2z"/><g stroke="#07038d" stroke-width="2"><path d="m265.3 138.2-17.6 65.7m17.6-65.7-17.6 65.7M273.5 141.6l-34 58.9M280.5 147l-48 48M285.9 154 227 188M289.3 162.2l-65.7 17.6M290.5 171h-68M289.3 179.8l-65.7-17.6M285.9 188 227 154M280.5 195l-48-48M273.5 200.4l-34-58.9M265.3 203.8l-17.6-65.7M256.5 205v-68M247.7 203.8l17.6-65.7M239.5 200.4l34-58.9M232.5 195l48.1-48.1M227.1 188l58.9-34M223.7 179.8l65.7-17.6M222.5 171h68M223.7 162.2l65.7 17.6M227.1 154l58.9 34M232.5 147l48.1 48.1M239.5 141.6l34 58.9M247.7 138.2l17.6 65.7"/></g>` },
  BR: { viewBox: "0 0 513 342", inner: `<path fill="#009b3a" d="M0 0h513v342H0z"/><path fill="#fedf00" d="m256.5 19.3 204.9 151.4L256.5 322 50.6 170.7z"/><circle fill="#FFF" cx="256.5" cy="171" r="80.4"/><path fill="#002776" d="M215.9 165.7c-13.9 0-27.4 2.1-40.1 6 .6 43.9 36.3 79.3 80.3 79.3 27.2 0 51.3-13.6 65.8-34.3-24.9-31-63.2-51-106-51zM334.9 186c.9-5 1.5-10.1 1.5-15.4 0-44.4-36-80.4-80.4-80.4-33.1 0-61.5 20.1-73.9 48.6 10.9-2.2 22.1-3.4 33.6-3.4 46.8.1 89 19.5 119.2 50.6z"/>` },
  CA: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><g fill="red"><path d="M0 0h142v342H0zM371 0h142v342H371zM306.5 206l50.4-25.2-25.2-12.6V143l-50.4 25.2 25.2-50.4h-25.2L256.1 80l-25.2 37.8h-25.2l25.2 50.4-50.4-25.2v25.2l-25.2 12.6 50.4 25.2-12.6 25.2h50.4V269h25.2v-37.8h50.4z"/></g>` },
  AU: { viewBox: "0 0 513 342", inner: `<path fill="#00008b" d="M0 0h513v342H0z"/><g fill="#FFF"><path d="m188 212.6 11 22.9 24.7-5.7-11 22.8 19.9 15.8-24.8 5.6.1 25.4-19.9-15.9-19.8 15.9.1-25.4-24.8-5.6 19.9-15.8-11.1-22.8 24.8 5.7zM385.9 241.1l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.6v12.2l-9.4-7.6-9.5 7.6.1-12.2-11.8-2.6 9.5-7.5-5.3-10.9 11.8 2.7zM337.3 125.1l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.7v12.1l-9.4-7.6-9.5 7.6.1-12.1-11.9-2.7 9.5-7.5-5.3-10.9L332 136zM385.9 58.9l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.7v12.1l-9.4-7.6-9.5 7.6.1-12.1-11.8-2.7 9.5-7.5-5.3-10.9 11.8 2.7zM428.4 108.6l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.6V150l-9.4-7.6-9.5 7.6v-12.2l-11.8-2.6 9.5-7.5-5.3-10.9 11.8 2.7zM398 166.5l4.1 12.7h13.3l-10.8 7.8 4.2 12.7-10.8-7.9-10.8 7.9 4.1-12.7-10.7-7.8h13.3z"/></g><path fill="#00008b" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="red"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g>` },
  MX: { viewBox: "0 0 513 342", inner: `<path fill="#dc2339" d="M342 0H0v341.3h513V0z"/><path fill="#11865d" d="M0 0h171v342H0z"/><path fill="#FFF" d="M171 0h171v342H171z"/><path fill="#8C9157" d="M195.8 171.2c0 21.6 11.5 41.7 30.3 52.5 5.8 3.4 13.2 1.4 16.6-4.4 3.4-5.8 1.4-13.2-4.4-16.6-11.3-6.5-18.2-18.5-18.2-31.5 0-6.7-5.4-12.1-12.1-12.1-6.7 0-12.2 5.4-12.2 12.1zm93.4 51.1c17.5-11.1 28-30.4 28-51.1 0-6.7-5.4-12.1-12.1-12.1s-12.1 5.4-12.1 12.1c0 12.4-6.3 24-16.8 30.7-5.7 3.5-7.5 10.9-4.1 16.7s10.9 7.5 16.7 4.1c0-.2.2-.3.4-.4z"/><ellipse fill="#C59262" cx="256.5" cy="159.1" rx="24.3" ry="36.4"/>` },
  KR: { viewBox: "0 0 513 342", inner: `<path fill="#FFF" d="M0 0h513v342H0z"/><g stroke="#000" stroke-width="14.25"><path d="M84.6 107.8 132 36.6m-29.7 83 47.4-71.1m-29.6 83 47.4-71.1M345.4 281.7l47.4-71.1m-29.6 82.9 47.4-71.1m-29.6 83 47.4-71.1M132 305.4l-47.4-71.1m65.2 59.2-47.4-71.1m65.2 59.3-47.4-71.1M392.8 131.5l-47.4-71.1m65.2 59.2-47.4-71.1m65.2 59.3L381 36.6"/></g><g stroke="#FFF" stroke-width="12.5"><path d="m357.3 238.2 59.3 39.5M117.2 263.9 135 252m222.3-148.2L378 90m17.8-11.9 17.8-11.9"/></g><circle fill="#CA163A" cx="256.5" cy="171" r="85.5"/><path fill="#0E4896" d="M185.3 123.6c-13.1 19.6-7.8 46.2 11.9 59.3s46.2 7.8 59.3-11.9 39.6-24.9 59.3-11.9c19.6 13.1 24.9 39.6 11.9 59.3-26.2 39.3-79.3 49.9-118.6 23.7s-49.9-79.3-23.8-118.5z"/>` },
}

src/reui/phone-input/index.ts

export { default as PhoneInput } from "./PhoneInput.vue"
export { default as PhoneInputCountrySelect } from "./PhoneInputCountrySelect.vue"
export { default as PhoneInputFlag } from "./PhoneInputFlag.vue"
export { countries, type CountryEntry } from "./countries-data"
export { flags, type FlagSvgData } from "./flags-data"
export {
  PhoneInputContextKey,
  type PhoneInputContextValue,
  type PhoneInputSize,
} from "./context"

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/phone-input.json

Зависимости реестра

npm-зависимости

  • libphonenumber-js
  • reka-ui

Источник: порт из ReUI (Keenthemes, MIT)