Перейти к содержимому
v3.0.0
English
Русский
Документация

PhoneField

PhoneField — составной компонент, который объединяет поле телефона с маской и вложенный Select для кода страны.
phone-field.tsx · 8 строк
import { Label, PhoneField, PhoneFieldInput } from '@vega-ui/react';

<div>
  <Label htmlFor='preview-phone'>Phone</Label>
  <PhoneField code='US'>
    <PhoneFieldInput id='preview-phone' placeholder='+1 (___) ___-____' />
  </PhoneField>
</div>
Поддержка браузеров
Недавно доступно, 2025
  • Chrome 116
  • Edge 116
  • Firefox 125
  • Safari 17
  • Safari на iOS 18.3
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

PhoneField — составной компонент, который объединяет поле телефона с маской и вложенный Select для кода страны. Это готовый слой ввода телефона для потребителя, а не просто свободная пара Select и TextField.

Импорт

import {
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectValue,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  type PhoneFieldProps,
} from '@vega-ui/react';

Экспортируемые типы

  • PhoneFieldProps
  • PhoneFieldInputProps
  • PhoneFieldSelectProps

Минимальная композиция

minimal-composition.tsx · 38 строк
import {
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

export default function MinimalComposition() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Phone number</Label>
      <PhoneField defaultCode='US'>
        <PhoneFieldSelect>
          <PhoneFieldSelectHiddenSelect name='country' />
          <PhoneFieldSelectCombobox aria-label='Country'>
            <PhoneFieldSelectValue />
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            <PhoneFieldSelectOption value='US'>US</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='GB'>GB</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='DE'>DE</PhoneFieldSelectOption>
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} name='phone' autoComplete='tel' />
      </PhoneField>
    </div>
  );
}

Обязательные части

  • PhoneField: состояние поля на уровне корня и контекст форматирования
  • PhoneFieldInput: собственно поле ввода номера телефона

Необязательные части

  • PhoneFieldSelect*: вложенное семейство выбора кода страны на том же контракте подсистемы select
  • PhoneFieldSelectHiddenSelect: нативное участие в форме для кода страны

Порядок композиции

Типичная композиция:

  1. PhoneField
  2. PhoneFieldSelect
  3. PhoneFieldSelectHiddenSelect
  4. PhoneFieldSelectCombobox
  5. PhoneFieldSelectListbox
  6. PhoneFieldSelectOption
  7. PhoneFieldInput

Варианты

  • Только поле ввода или полная композиция с выбором страны
  • strictMask или нестрогий ввод
  • Контролируемое значение поля или неконтролируемое использование в форме

Пограничные случаи

  • Смена страны может потребовать переформатирования и повторной валидации.
  • Тестируйте и строгую маску, и поведение при вставке.
  • Сохраняйте каноническое значение на границе функции, а не только отображаемую отформатированную строку.
  • Автозаполнение, вставленные значения и частично введённые международные префиксы тестируйте вместе, а не по отдельности.

Частые ошибки

  • Видимую отформатированную строку считают единственным источником истины.
  • Смену страны не учитывают в логике валидации.
  • Тестируют только набор с клавиатуры и пропускают вставку и автозаполнение.

Примеры

Базовый

Базовый: простая маска телефона по ISO-коду

Одного code достаточно для формата: маска держит код страны США и группирует цифры как +1 201 555-0123. Видимая Label называет поле, а autoComplete='tel' позволяет браузеру заполнить его.

simple-iso-based-phone-mask.tsx · 15 строк
import { Label, PhoneField, PhoneFieldInput } from '@vega-ui/react';
import { useId } from 'react';

export default function SimpleIsoBasedPhoneMask() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 280 }}>
      <Label htmlFor={id}>Mobile number</Label>
      <PhoneField code='US'>
        <PhoneFieldInput id={id} name='phone' autoComplete='tel' placeholder='+1 201 555-0123' />
      </PhoneField>
    </div>
  );
}

Базовый: выбор страны с иконками флагов

Флаги из @vega-ui/icons стоят в каждом варианте рядом с названием и кодом страны. Страна контролируемая, поэтому триггер показывает только выбранный флаг, а его aria-label содержит название страны.

select-with-country-options-and-icons.tsx · 62 строки
import {
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
} from '@vega-ui/react';
import { FlagDE, FlagFR, FlagGBUKM, FlagIN, FlagJP, FlagUS } from '@vega-ui/icons';
import { useId, useState } from 'react';

const countries = [
  { code: 'US', name: 'United States', callingCode: '+1', Flag: FlagUS },
  { code: 'GB', name: 'United Kingdom', callingCode: '+44', Flag: FlagGBUKM },
  { code: 'DE', name: 'Germany', callingCode: '+49', Flag: FlagDE },
  { code: 'FR', name: 'France', callingCode: '+33', Flag: FlagFR },
  { code: 'IN', name: 'India', callingCode: '+91', Flag: FlagIN },
  { code: 'JP', name: 'Japan', callingCode: '+81', Flag: FlagJP },
] as const;

export default function SelectWithCountryOptionsAndIcons() {
  const id = useId();
  const [country, setCountry] = useState<(typeof countries)[number]>(countries[1]);

  const onChangeCode = (code: string) => {
    const found = countries.find((item) => item.code === code);
    if (found) setCountry(found);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>
      <Label htmlFor={id}>Phone number</Label>
      <PhoneField code={country.code} onChangeCode={onChangeCode}>
        <PhoneFieldSelect>
          <PhoneFieldSelectHiddenSelect name='country' />
          <PhoneFieldSelectCombobox aria-label={`Country: ${country.name}`}>
            <PhoneFieldSelectValue>
              <country.Flag width={20} height={15} aria-hidden='true' />
            </PhoneFieldSelectValue>
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            {countries.map(({ code, name, callingCode, Flag }) => (
              <PhoneFieldSelectOption key={code} value={code} label={name}>
                <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
                  <Flag width={20} height={15} aria-hidden='true' />
                  {name}
                </span>
                <span style={{ opacity: 0.7 }}>{callingCode}</span>
              </PhoneFieldSelectOption>
            ))}
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} name='phone' autoComplete='tel' />
      </PhoneField>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: контролируемое значение с действием очистки

Отформатированным текстом владеет родитель, выводит из него сохраняемые цифры и очищает его пустой строкой. Подсказка сообщает, что номер ещё не полный.

controlled-value-with-clear-action.tsx · 25 строк
import { Button, Label, PhoneField, PhoneFieldInput, Text } from '@vega-ui/react';
import { useId, useState } from 'react';

export default function ControlledValueWithClearAction() {
  const id = useId();
  const [phone, setPhone] = useState('+1 415 555-0132');
  const digits = phone.replace(/\D/g, '');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Phone for delivery updates</Label>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        <PhoneField code='US' value={phone} onChangeValue={setPhone} style={{ flex: 1 }}>
          <PhoneFieldInput id={id} autoComplete='tel' />
        </PhoneField>
        <Button variant='secondary' appearance='ghost' disabled={phone === ''} onClick={() => setPhone('')}>
          Clear
        </Button>
      </div>
      <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums', marginBlockStart: 'var(--spacing-2)' }}>
        {digits.length === 11 ? `Stored as +${digits}` : 'Enter all 10 digits of the number.'}
      </Text>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: отправка неконтролируемой нативной формы

Поле отправляет отформатированный номер под name='phone', а PhoneFieldSelectHiddenSelect — страну под name='country'. required — нативная валидация настоящего поля ввода.

uncontrolled-native-form-submit.tsx · 53 строки
import {
  Button,
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  Text,
} from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function UncontrolledNativeFormSubmit() {
  const id = useId();
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(`country=${data.get('country')}, phone=${data.get('phone')}`);
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>
      <Label htmlFor={id}>Phone number</Label>
      <PhoneField defaultCode='DE'>
        <PhoneFieldSelect>
          <PhoneFieldSelectHiddenSelect name='country' />
          <PhoneFieldSelectCombobox aria-label='Country'>
            <PhoneFieldSelectValue />
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            <PhoneFieldSelectOption value='DE'>DE</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='AT'>AT</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='CH'>CH</PhoneFieldSelectOption>
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} name='phone' autoComplete='tel' required />
      </PhoneField>
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Request a callback</Button>
      {submitted && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          Submitted: {submitted}
        </Text>
      )}
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: полное поле внутри формы в диалоге

Список стран открывается в верхнем слое поверх модального диалога. Форма читает оба значения через FormData и закрывает диалог при отправке.

full-field-inside-a-dialog-form.tsx · 85 строк
import type { CSSProperties, FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';
import { useId, useState } from 'react';

export default function FullFieldInsideADialogForm() {
  const id = useId();
  const [open, setOpen] = useState(false);
  const [contact, setContact] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setContact(`${data.get('phone')} (${data.get('country')})`);
    setOpen(false);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-8)', width: '100%' }}>
      <Dialog open={open} onChangeOpen={setOpen}>
        <DialogTrigger asChild>
          <Button>Add emergency contact</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={`${id}-title`}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={`${id}-title`} as='h2' size={4} fontWeight={600}>Emergency contact</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <form
            onSubmit={onSubmit}
            style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-12)' }}
          >
            <Label htmlFor={id}>Phone number</Label>
            <PhoneField defaultCode='FR'>
              <PhoneFieldSelect>
                <PhoneFieldSelectHiddenSelect name='country' />
                <PhoneFieldSelectCombobox aria-label='Country'>
                  <PhoneFieldSelectValue />
                  <PhoneFieldSelectIcon />
                </PhoneFieldSelectCombobox>
                <PhoneFieldSelectListbox>
                  <PhoneFieldSelectOption value='FR'>FR</PhoneFieldSelectOption>
                  <PhoneFieldSelectOption value='BE'>BE</PhoneFieldSelectOption>
                  <PhoneFieldSelectOption value='CH'>CH</PhoneFieldSelectOption>
                </PhoneFieldSelectListbox>
              </PhoneFieldSelect>
              <PhoneFieldInput id={id} name='phone' autoComplete='tel' required />
            </PhoneField>
            <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Save contact</Button>
          </form>
        </DialogContent>
      </Dialog>
      {contact && <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>Emergency contact: {contact}</Text>}
    </div>
  );
}

Ошибка

Ошибка: состояние невалидного номера

Номер проверяется при потере фокуса и при отправке. Неполный номер ставит error на поле и aria-invalid на поле ввода и связывает сообщение через aria-describedby; ввод последней цифры снимает ошибку.

invalid-number-state.tsx · 52 строки
import { Button, HelperText, Label, PhoneField, PhoneFieldInput, Text } from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

const countDigits = (phone: string) => phone.replace(/\D/g, '').length;

export default function InvalidNumberState() {
  const id = useId();
  const errorId = `${id}-error`;
  const [phone, setPhone] = useState('');
  const [invalid, setInvalid] = useState(false);
  const [saved, setSaved] = useState(false);

  const onChangeValue = (next: string) => {
    setPhone(next);
    if (invalid && countDigits(next) === 11) setInvalid(false);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (countDigits(phone) !== 11) {
      setInvalid(true);
      setSaved(false);
      return;
    }

    setSaved(true);
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Phone for two-factor codes</Label>
      <PhoneField code='US' value={phone} onChangeValue={onChangeValue} error={invalid}>
        <PhoneFieldInput
          id={id}
          autoComplete='tel'
          placeholder='+1 201 555-0123'
          aria-invalid={invalid}
          aria-describedby={invalid ? errorId : undefined}
          onBlur={() => setInvalid(countDigits(phone) > 1 && countDigits(phone) !== 11)}
        />
      </PhoneField>
      {invalid && <HelperText id={errorId} error>Enter all 10 digits of the US number.</HelperText>}
      <Button type='submit' style={{ alignSelf: 'flex-end', marginBlockStart: 'var(--spacing-9)' }}>Send a code</Button>
      {saved && (
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-4)' }}>
          A code was sent to {phone}.
        </Text>
      )}
    </form>
  );
}

Отключённый

Отключённый: унаследовано от fieldset

Поле ввода и триггер страны — нативные элементы, поэтому отключённый Fieldset отключает оба и исключает их из формы. Переключатель меняет это состояние.

disabled-input.tsx · 59 строк
import {
  Fieldset,
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  Switch,
  SwitchIndicator,
  SwitchInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

export default function DisabledInput() {
  const switchId = useId();
  const phoneId = useId();
  const [sameAsProfile, setSameAsProfile] = useState(true);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)', width: '100%', maxWidth: 340 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Switch>
          <SwitchInput
            id={switchId}
            checked={sameAsProfile}
            onChange={(event) => setSameAsProfile(event.currentTarget.checked)}
          />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor={switchId}>Use the number from my profile</Label>
      </div>
      <Fieldset
        disabled={sameAsProfile}
        style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)' }}
      >
        <Label htmlFor={phoneId}>Billing phone</Label>
        <PhoneField defaultValue='+44 7400 123456'>
          <PhoneFieldSelect>
            <PhoneFieldSelectHiddenSelect name='country' />
            <PhoneFieldSelectCombobox aria-label='Country'>
              <PhoneFieldSelectValue />
              <PhoneFieldSelectIcon />
            </PhoneFieldSelectCombobox>
            <PhoneFieldSelectListbox>
              <PhoneFieldSelectOption value='GB'>GB</PhoneFieldSelectOption>
              <PhoneFieldSelectOption value='IE'>IE</PhoneFieldSelectOption>
            </PhoneFieldSelectListbox>
          </PhoneFieldSelect>
          <PhoneFieldInput id={phoneId} name='billingPhone' autoComplete='tel' />
        </PhoneField>
      </Fieldset>
    </div>
  );
}

Пограничный случай

Пограничный случай: нестрогая маска для терпимой вставки

С strictMask={false} вставленный номер сохраняет свой код страны, а поле переключает страну под него; onChangeCode сообщает определённую страну. Со строгой маской по умолчанию та же вставка была бы приведена к коду текущей страны.

non-strict-mask-for-lenient-paste-behavior.tsx · 43 строки
import {
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  Text,
} from '@vega-ui/react';
import { useId, useState } from 'react';

export default function NonStrictMaskForLenientPasteBehavior() {
  const id = useId();
  const [country, setCountry] = useState('US');

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>
      <Label htmlFor={id}>Customer phone</Label>
      <PhoneField defaultCode='US' strictMask={false} onChangeCode={setCountry}>
        <PhoneFieldSelect>
          <PhoneFieldSelectCombobox aria-label='Country'>
            <PhoneFieldSelectValue />
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            <PhoneFieldSelectOption value='US'>US</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='GB'>GB</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='DE'>DE</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='FR'>FR</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='JP'>JP</PhoneFieldSelectOption>
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} autoComplete='tel' />
      </PhoneField>
      <Text size={2} style={{ color: 'var(--label-secondary)', marginBlockStart: 'var(--spacing-2)' }}>
        Paste +33 6 12 34 56 78 or +81 90-1234-5678: the country switches to match. Detected: {country}
      </Text>
    </div>
  );
}

Пограничный случай: эмодзи-флаги

Варианты и значение принимают любое содержимое, поэтому эмодзи-флаги работают без файлов изображений. Триггер читает выбранный код из useSelectContext и показывает только флаг — как изображение с названием страны.

emoji-flags.tsx · 67 строк
import {
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
  useSelectContext,
} from '@vega-ui/react';
import { useId } from 'react';

const countries = [
  { code: 'BR', flag: '🇧🇷', name: 'Brazil', callingCode: '+55' },
  { code: 'MX', flag: '🇲🇽', name: 'Mexico', callingCode: '+52' },
  { code: 'US', flag: '🇺🇸', name: 'United States', callingCode: '+1' },
  { code: 'FR', flag: '🇫🇷', name: 'France', callingCode: '+33' },
] as const;

function SelectedFlag() {
  const { selected } = useSelectContext();
  const country = countries.find((item) => item.code === selected);

  if (!country) return null;

  return (
    <span role='img' aria-label={country.name}>
      {country.flag}
    </span>
  );
}

export default function EmojiFlags() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 340 }}>
      <Label htmlFor={id}>WhatsApp number</Label>
      <PhoneField defaultCode='BR'>
        <PhoneFieldSelect>
          <PhoneFieldSelectHiddenSelect name='country' />
          <PhoneFieldSelectCombobox aria-label='Country'>
            <PhoneFieldSelectValue>
              <SelectedFlag />
            </PhoneFieldSelectValue>
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            {countries.map((country) => (
              <PhoneFieldSelectOption key={country.code} value={country.code} label={country.name}>
                <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
                  <span aria-hidden='true'>{country.flag}</span>
                  {country.name}
                </span>
                <span style={{ opacity: 0.7 }}>{country.callingCode}</span>
              </PhoneFieldSelectOption>
            ))}
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} name='whatsapp' autoComplete='tel' />
      </PhoneField>
    </div>
  );
}

Анатомия

Обзор

PhoneField согласует маску ввода, состояние страны и поведение вложенного select между корнем поля и семейством PhoneFieldSelect*.

Обязательные части

PhoneField

Корневой провайдер форматирования телефона и состояния страны.

PhoneFieldInput

Видимое текстовое поле для номера.

Необязательные части

PhoneFieldSelect

Корень вложенного выбора кода страны.

PhoneFieldSelectHiddenSelect

Нативное участие в форме для значения страны.

PhoneFieldSelectCombobox

Видимый триггер выбора страны.

PhoneFieldSelectValue

Текущая выбранная страна или собственное содержимое для её отображения.

PhoneFieldSelectIcon

Визуальная подсказка для триггера страны.

PhoneFieldSelectListbox

Контейнер вариантов стран. Рендерится в верхнем слое через popover="manual", поэтому отдельная часть-портал не нужна.

PhoneFieldSelectOption

Выбираемый вариант страны.

Порядок композиции

Типичная композиция:

  1. PhoneField
  2. PhoneFieldSelect
  3. PhoneFieldSelectCombobox
  4. PhoneFieldSelectListbox
  5. PhoneFieldInput

Корректные варианты композиции

minimal-composition.tsx · 38 строк
import {
  Label,
  PhoneField,
  PhoneFieldInput,
  PhoneFieldSelect,
  PhoneFieldSelectCombobox,
  PhoneFieldSelectHiddenSelect,
  PhoneFieldSelectIcon,
  PhoneFieldSelectListbox,
  PhoneFieldSelectOption,
  PhoneFieldSelectValue,
} from '@vega-ui/react';
import { useId } from 'react';

export default function MinimalComposition() {
  const id = useId();

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-3)', width: '100%', maxWidth: 320 }}>
      <Label htmlFor={id}>Phone number</Label>
      <PhoneField defaultCode='US'>
        <PhoneFieldSelect>
          <PhoneFieldSelectHiddenSelect name='country' />
          <PhoneFieldSelectCombobox aria-label='Country'>
            <PhoneFieldSelectValue />
            <PhoneFieldSelectIcon />
          </PhoneFieldSelectCombobox>
          <PhoneFieldSelectListbox>
            <PhoneFieldSelectOption value='US'>US</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='GB'>GB</PhoneFieldSelectOption>
            <PhoneFieldSelectOption value='DE'>DE</PhoneFieldSelectOption>
          </PhoneFieldSelectListbox>
        </PhoneFieldSelect>
        <PhoneFieldInput id={id} name='phone' autoComplete='tel' />
      </PhoneField>
    </div>
  );
}

Label называет поле ввода, у триггера страны свой aria-label, а скрытый select отправляет страну вместе с номером. Без PhoneFieldSelect поле всё равно форматирует номер по одному code.

Некорректные варианты композиции

Поле ввода вне PhoneField

Это ломает общее форматирование и согласование со страной.

Вложенный select рассматривается как не связанный с полем ввода

Смена страны всё равно должна согласовываться с маской телефона и моделью валидации.

Богатый рендер вариантов без сохранения одной выбираемой строки

Это ослабляет поведение клавиатуры и списка (listbox).