Zum Inhalt springen
UI Kit
Accent

Telefonfeld

Eine Telefonnummer mit Land, ohne Flaggen. Beim Tippen gruppiert wie im Land üblich, zurückgegeben in E.164, mit durchsuchbarer Länderliste.

Eine von 6 Components in Spezielle Eingabefelder

4 Variants

Variants

Land und Nummer

Land und Nummer in einem Feld. Die Liste sucht Namen, Kürzel und Vorwahlen, eine führende 0 fällt weg, ein eingefügtes +41 wählt die Schweiz, eine vollständige Nummer zeichnet einen Haken.

Im Studio öffnen

Erkennt das Land

Ein Feld für alle, die die Nummer vollständig tippen. Das + und die Vorwahl setzen das Land, sein Kürzel federt am Anfang herein, der Rest gruppiert sich selbst.

Im Studio öffnen

Zwei Felder

Vorwahl und Nummer nebeneinander als zwei Bedienelemente, für lange Formulare, in denen jedes Feld für sich steht. Die Favoriten führen die Liste an.

Im Studio öffnen

Code senden

Ein Sendeknopf, der erst bei vollständiger Nummer erwacht, kurzes Warten, dann eine ablaufende Linie bis zum erneuten Senden. Hier folgt das Codefeld und bestätigt das Telefon.

Im Studio öffnen

Details

Was Telefonfeld mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/phone-input.tsx
Bringt mit
utilskiticoninputselectpopover
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 11 eigene Texte

Props

Die Schnittstelle PhoneInputProps aus components/ui/phone-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • variantoptional
    PhoneInputVariant

    selectdetectsplitverify

  • valueoptional
    string

    E.164, e.g. "+4915112345678"

  • defaultValueoptional
    string
  • onValueChangeoptional
    (e164: string, info: { country: string; national: string; complete: boolean }) => void
  • defaultCountryoptional
    string

    The country at the start, e.g. "DE"

  • countriesoptional
    PhoneCountry[]

    The countries on offer, default a list of 19

  • preferredoptional
    string[]

    Countries shown first in the list

  • placeholderoptional
    string
  • onSendCodeoptional
    (e164: string) => Promise<void> | void

    verify: called with the number, resolve when the code is on its way

  • resendAfteroptional
    number

    verify: seconds before the code can be sent again. Default 30.

  • sizeoptional
    ControlSize
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean

Eingebaute Texte

Diese Texte schreibt Telefonfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 11 Texte anzeigen
countryEnglisch CountryDeutsch Land
searchEnglisch Search a country or codeDeutsch Land oder Vorwahl suchen
emptyEnglisch No country found. Try the dialling code.Deutsch Kein Land gefunden. Versuch die Vorwahl.
validEnglisch Number completeDeutsch Nummer vollständig
sendEnglisch Send codeDeutsch Code senden
sendingEnglisch Sending…Deutsch Wird gesendet…
resendEnglisch Send againDeutsch Erneut senden
resendInEnglisch Send again inDeutsch Erneut senden in
sentEnglisch Code sent toDeutsch Code gesendet an
changeEnglisch Change numberDeutsch Nummer ändern
unknownEnglisch Start with + and the country codeDeutsch Mit + und Ländervorwahl beginnen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Telefonfeld reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter und Leertaste.
  • Setzt aria-activedescendant, aria-controls, aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, listbox, option und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 438 Zeilen
components/ui/phone-input.tsx
'use client';

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CONTROL_HEIGHT, SuccessMark, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, revealOption, unfold } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';

/*
 * A phone number with its country. No flags: a flag stands for a state, not
 * a language or a dialling code, and it is a picture someone has to draw.
 * The country shows as its two letter code and dialling code. The number is
 * grouped as you type, in the habit of the country, and the value is given
 * back in E.164 (+4915112345678). Four looks: country and number in one box,
 * one field that finds the country from the + prefix, two separate fields for
 * long forms, and a field with a send code button and a resend countdown.
 */

const TEXTS = {
  en: {
    country: 'Country', search: 'Search a country or code', empty: 'No country found. Try the dialling code.', valid: 'Number complete',
    send: 'Send code', sending: 'Sending…', resend: 'Send again', resendIn: 'Send again in', sent: 'Code sent to', change: 'Change number', unknown: 'Start with + and the country code',
  },
  de: {
    country: 'Land', search: 'Land oder Vorwahl suchen', empty: 'Kein Land gefunden. Versuch die Vorwahl.', valid: 'Nummer vollständig',
    send: 'Code senden', sending: 'Wird gesendet…', resend: 'Erneut senden', resendIn: 'Erneut senden in', sent: 'Code gesendet an', change: 'Nummer ändern', unknown: 'Mit + und Ländervorwahl beginnen',
  },
};
type Texts = (typeof TEXTS)['en'];

export interface PhoneCountry {
  /** ISO 3166 code, e.g. "DE" */
  code: string;
  /** Dialling code without +, e.g. "49" */
  dial: string;
  name: { en: string; de: string };
  /** Grouping of the national number, # is a digit */
  pattern: string;
  /** Fewest digits of a complete national number */
  min: number;
}

export const PHONE_COUNTRIES: PhoneCountry[] = [
  { code: 'DE', dial: '49', name: { en: 'Germany', de: 'Deutschland' }, pattern: '### #########', min: 10 },
  { code: 'AT', dial: '43', name: { en: 'Austria', de: 'Österreich' }, pattern: '### ########', min: 10 },
  { code: 'CH', dial: '41', name: { en: 'Switzerland', de: 'Schweiz' }, pattern: '## ### ## ##', min: 9 },
  { code: 'NL', dial: '31', name: { en: 'Netherlands', de: 'Niederlande' }, pattern: '# ## ## ## ##', min: 9 },
  { code: 'BE', dial: '32', name: { en: 'Belgium', de: 'Belgien' }, pattern: '### ## ## ##', min: 9 },
  { code: 'FR', dial: '33', name: { en: 'France', de: 'Frankreich' }, pattern: '# ## ## ## ##', min: 9 },
  { code: 'GB', dial: '44', name: { en: 'United Kingdom', de: 'Vereinigtes Königreich' }, pattern: '#### ######', min: 10 },
  { code: 'IE', dial: '353', name: { en: 'Ireland', de: 'Irland' }, pattern: '## ### ####', min: 9 },
  { code: 'ES', dial: '34', name: { en: 'Spain', de: 'Spanien' }, pattern: '### ### ###', min: 9 },
  { code: 'PT', dial: '351', name: { en: 'Portugal', de: 'Portugal' }, pattern: '### ### ###', min: 9 },
  { code: 'IT', dial: '39', name: { en: 'Italy', de: 'Italien' }, pattern: '### ### ####', min: 9 },
  { code: 'DK', dial: '45', name: { en: 'Denmark', de: 'Dänemark' }, pattern: '## ## ## ##', min: 8 },
  { code: 'SE', dial: '46', name: { en: 'Sweden', de: 'Schweden' }, pattern: '## ### ## ##', min: 9 },
  { code: 'NO', dial: '47', name: { en: 'Norway', de: 'Norwegen' }, pattern: '### ## ###', min: 8 },
  { code: 'PL', dial: '48', name: { en: 'Poland', de: 'Polen' }, pattern: '### ### ###', min: 9 },
  { code: 'US', dial: '1', name: { en: 'United States', de: 'Vereinigte Staaten' }, pattern: '(###) ###-####', min: 10 },
  { code: 'CA', dial: '1', name: { en: 'Canada', de: 'Kanada' }, pattern: '(###) ###-####', min: 10 },
  { code: 'AU', dial: '61', name: { en: 'Australia', de: 'Australien' }, pattern: '### ### ###', min: 9 },
  { code: 'JP', dial: '81', name: { en: 'Japan', de: 'Japan' }, pattern: '## #### ####', min: 10 },
];

const digitsOf = (s: string) => s.replace(/\D/g, '');
/** Digits for the example in the placeholder */
const SAMPLE = '1512345678901';
const maxDigits = (c: PhoneCountry) => (c.pattern.match(/#/g) ?? []).length;

/** Groups digits after the pattern of a country, as far as they go */
export function formatNational(digits: string, c: PhoneCountry) {
  let out = '';
  let i = 0;
  for (const ch of c.pattern) {
    if (i >= digits.length) break;
    if (ch === '#') out += digits[i++];
    else out += ch;
  }
  // Leftover digits beyond the pattern are kept, not dropped
  return (out + digits.slice(i)).replace(/[\s(-]+$/, '');
}

/** The country whose dialling code starts a number in international form, longest match first */
export function detectCountry(international: string, countries = PHONE_COUNTRIES, prefer?: string) {
  const d = digitsOf(international);
  const hits = countries.filter((c) => d.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length);
  if (!hits.length) return null;
  const best = hits.filter((c) => c.dial === hits[0].dial);
  return best.find((c) => c.code === prefer) ?? best[0];
}

export type PhoneInputVariant = 'select' | 'detect' | 'split' | 'verify';

export interface PhoneInputProps extends FieldOwnProps {
  variant?: PhoneInputVariant;
  /** E.164, e.g. "+4915112345678" */
  value?: string;
  defaultValue?: string;
  onValueChange?: (e164: string, info: { country: string; national: string; complete: boolean }) => void;
  /** The country at the start, e.g. "DE" */
  defaultCountry?: string;
  /** The countries on offer, default a list of 19 */
  countries?: PhoneCountry[];
  /** Countries shown first in the list */
  preferred?: string[];
  placeholder?: string;
  /** verify: called with the number, resolve when the code is on its way */
  onSendCode?: (e164: string) => Promise<void> | void;
  /** verify: seconds before the code can be sent again. Default 30. */
  resendAfter?: number;
  size?: ControlSize;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/** Two letters in a quiet box instead of a flag */
function CountryCode({ code, className }: { code: string; className?: string }) {
  return <span aria-hidden className={cn('inline-grid h-5 min-w-7 place-items-center rounded-[5px] bg-muted px-1 text-[10.5px] font-semibold tracking-[0.04em] text-muted-foreground', className)}>{code}</span>;
}

/** The searchable list of countries */
function CountryList({ countries, current, preferred, onPick, onClose, t, locale, id }: { countries: PhoneCountry[]; current: PhoneCountry; preferred: string[]; onPick: (c: PhoneCountry) => void; onClose: () => void; t: Texts; locale: 'en' | 'de'; id: string }) {
  const [q, setQ] = useState('');
  const sorted = useMemo(() => {
    const pref = preferred.map((p) => countries.find((c) => c.code === p)).filter(Boolean) as PhoneCountry[];
    const rest = countries.filter((c) => !preferred.includes(c.code)).sort((a, b) => a.name[locale].localeCompare(b.name[locale], locale));
    return [...pref, ...rest];
  }, [countries, preferred, locale]);
  const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
  const shown = q.trim() ? sorted.filter((c) => norm(`${c.name[locale]} ${c.code} +${c.dial}`).includes(norm(q.trim()).replace(/^00/, '+'))) : sorted;
  const [active, setActive] = useState(() => Math.max(0, shown.indexOf(current)));
  const searchRef = useRef<HTMLInputElement>(null);
  // The panel is hidden until it is placed, so autofocus would miss it: focus once it shows
  useEffect(() => {
    const id = requestAnimationFrame(() => requestAnimationFrame(() => searchRef.current?.focus()));
    return () => cancelAnimationFrame(id);
  }, []);
  useEffect(() => {
    if (active >= shown.length) setActive(Math.max(0, shown.length - 1));
  }, [shown.length, active]);
  useEffect(() => {
    revealOption(`${id}-${active}`);
  }, [active, id]);
  return (
    <div className="w-[17.5rem] max-w-[calc(100vw-16px)]">
      <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
      <div className="flex h-11 items-center gap-2 border-b border-border px-3">
        <Icon name="search" size={15} className="shrink-0 text-subtle-foreground" />
        <input
          ref={searchRef}
          role="combobox"
          aria-expanded
          aria-controls={`${id}-list`}
          aria-activedescendant={shown.length ? `${id}-${active}` : undefined}
          aria-label={t.search}
          placeholder={t.search}
          value={q}
          onChange={(e) => {
            setQ(e.target.value);
            setActive(0);
          }}
          onKeyDown={(e) => {
            if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
              e.preventDefault();
              setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % Math.max(1, shown.length));
            } else if (e.key === 'Enter' && shown[active]) {
              e.preventDefault();
              onPick(shown[active]);
            } else if (e.key === 'Escape') {
              e.preventDefault();
              e.stopPropagation();
              onClose();
            }
          }}
          className="h-full w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13px]"
        />
      </div>
      <div id={`${id}-list`} role="listbox" aria-label={t.country} className="kit-scrollbar relative max-h-64 overflow-y-auto overscroll-contain p-1.5">
        <ActiveMarker id={shown.length ? `${id}-${active}` : undefined} version={`${q}|${shown.length}`} />
        {shown.map((c, i) => (
          <div
            key={c.code}
            id={`${id}-${i}`}
            role="option"
            aria-selected={c === current}
            onPointerMove={() => setActive(i)}
            onPointerDown={(e) => e.preventDefault()}
            onClick={() => onPick(c)}
            style={{ '--i': i } as React.CSSProperties}
            className={cn('kitfx-select-row relative flex min-h-11 cursor-default items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground sm:min-h-9', i === preferred.length - 1 && preferred.length > 0 && !q && 'mb-1 after:absolute after:inset-x-1 after:-bottom-[3px] after:h-px after:bg-border after:content-[""]')}
          >
            <CountryCode code={c.code} />
            <span className="min-w-0 flex-1 truncate">{c.name[locale]}</span>
            <span className="shrink-0 text-xs tabular-nums text-subtle-foreground">+{c.dial}</span>
            {c === current && <Icon name="check" size={14} strokeWidth={2} className="shrink-0" />}
          </div>
        ))}
        {shown.length === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
      </div>
    </div>
  );
}

function PhoneBox(props: Omit<PhoneInputProps, keyof FieldOwnProps> & { fieldError?: boolean }) {
  const {
    variant = 'select', value, defaultValue, onValueChange, defaultCountry = 'DE', countries = PHONE_COUNTRIES, preferred = ['DE', 'AT', 'CH'], placeholder, onSendCode, resendAfter = 30,
    size = 'md', name, id, disabled, required, invalid, labels, className,
  } = props;
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const c = useControl({ id, invalid, disabled, required });
  const start = value ?? defaultValue ?? '';
  const startCountry = (start.startsWith('+') && detectCountry(start, countries, defaultCountry)) || countries.find((x) => x.code === defaultCountry) || countries[0];
  const [country, setCountry] = useState<PhoneCountry>(startCountry);
  const [national, setNational] = useState(() => (start.startsWith('+') ? digitsOf(start).slice(startCountry.dial.length) : digitsOf(start)));
  // detect: the whole number as typed, with + and dialling code
  const [raw, setRaw] = useState(() => (start ? `+${startCountry.dial} ${formatNational(digitsOf(start).slice(startCountry.dial.length), startCountry)}` : ''));
  const [open, setOpen] = useState(false);
  const mounted = useMounted();
  const boxRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const listId = useId();
  const refs = useRef([triggerRef, panelRef]).current;
  const close = useCallback(() => setOpen(false), []);
  useDismiss(open, close, refs);
  useShake(boxRef, c.invalid);

  const detected = variant === 'detect' ? (raw.trim().startsWith('+') ? detectCountry(raw, countries, defaultCountry) : null) : country;
  const nat = variant === 'detect' ? (detected ? digitsOf(raw).slice(detected.dial.length) : '') : national;
  const active = detected ?? country;
  const complete = Boolean(detected) && nat.length >= active.min && nat.length <= maxDigits(active);
  const e164 = detected && nat ? `+${active.dial}${nat}` : '';

  const report = (cn_: PhoneCountry, n: string) => onValueChange?.(n ? `+${cn_.dial}${n}` : '', { country: cn_.code, national: n, complete: n.length >= cn_.min && n.length <= maxDigits(cn_) });
  const typeNational = (text: string) => {
    let d = digitsOf(text);
    // A pasted number in international form picks its country
    if (/^\s*(\+|00)/.test(text)) {
      const found = detectCountry(text.replace(/^\s*00/, '+'), countries, country.code);
      if (found) {
        setCountry(found);
        d = digitsOf(text.replace(/^\s*00/, '')).slice(found.dial.length);
        setNational(d.slice(0, maxDigits(found) + 2));
        report(found, d);
        return;
      }
    }
    // A leading trunk zero (0151…) is dropped, the dialling code replaces it
    d = d.replace(/^0+/, '').slice(0, maxDigits(country) + 2);
    setNational(d);
    report(country, d);
  };
  const pickCountry = (next: PhoneCountry) => {
    setCountry(next);
    setOpen(false);
    report(next, national);
    requestAnimationFrame(() => inputRef.current?.focus());
  };

  /* ---------- verify: send a code and wait ---------- */
  const [stage, setStage] = useState<'idle' | 'sending' | 'sent'>('idle');
  const [left, setLeft] = useState(0);
  useEffect(() => {
    if (left <= 0) return;
    const tm = window.setTimeout(() => setLeft((s) => s - 1), 1000);
    return () => window.clearTimeout(tm);
  }, [left]);
  const send = async () => {
    if (!complete || stage === 'sending') return;
    setStage('sending');
    try {
      await onSendCode?.(e164);
      setStage('sent');
      setLeft(resendAfter);
    } catch {
      setStage('idle');
    }
  };

  const numberInput = (cls?: string, ariaLabel?: string) => (
    <input
      ref={inputRef}
      id={c.id}
      type="tel"
      inputMode="tel"
      autoComplete={variant === 'detect' ? 'tel' : 'tel-national'}
      aria-invalid={c['aria-invalid']}
      aria-describedby={c['aria-describedby']}
      aria-label={ariaLabel}
      disabled={c.disabled || (variant === 'verify' && stage === 'sent')}
      required={c.required}
      placeholder={placeholder ?? (variant === 'detect' ? `+${active.dial} ${formatNational(SAMPLE.slice(0, active.min), active)}` : formatNational(SAMPLE.slice(0, active.min), active))}
      value={variant === 'detect' ? raw : formatNational(national, country)}
      onChange={(e) => {
        if (variant === 'detect') {
          const v = e.target.value;
          const plus = v.trim().startsWith('+') || v.trim().startsWith('00');
          const d = digitsOf(v.replace(/^\s*00/, ''));
          const found = plus ? detectCountry(`+${d}`, countries, defaultCountry) : null;
          setRaw(plus ? (found ? `+${found.dial} ${formatNational(d.slice(found.dial.length), found)}`.trimEnd() : `+${d}`) : v);
          if (found) report(found, d.slice(found.dial.length));
        } else typeNational(e.target.value);
      }}
      className={cn('h-full w-0 min-w-0 flex-1 bg-transparent tabular-nums text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed', cls)}
    />
  );
  const trailing = complete && (
    <span className="me-3 flex shrink-0 items-center" title={t.valid}>
      <SuccessMark />
      <span className="sr-only">{t.valid}</span>
    </span>
  );
  const trigger = (cls: string) => (
    <button
      ref={triggerRef}
      type="button"
      aria-haspopup="listbox"
      aria-expanded={open}
      aria-label={`${t.country}: ${country.name[locale]} +${country.dial}`}
      disabled={c.disabled || (variant === 'verify' && stage === 'sent')}
      onClick={() => setOpen(!open)}
      onKeyDown={(e) => {
        if (e.key === 'ArrowDown' && !open) {
          e.preventDefault();
          setOpen(true);
        }
      }}
      className={cn('flex shrink-0 items-center gap-1.5 outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] disabled:pointer-events-none', cls)}
    >
      <CountryCode key={country.code} code={country.code} className="transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0" />
      <span className="tabular-nums text-foreground">+{country.dial}</span>
      <Icon name="chevron-down" size={13} className={cn('text-subtle-foreground transition-[rotate] duration-300', open && 'rotate-180')} />
    </button>
  );
  const panel = mounted && open && (
    <Floating anchor={variant === 'split' ? triggerRef : boxRef} side="bottom" align="start" offset={6} ref={panelRef}>
      {(pos) => (
        <div className={cn('overflow-hidden', panelSurface, unfold(pos?.side).className)}>
          <CountryList countries={countries} current={country} preferred={preferred} onPick={pickCountry} onClose={() => { setOpen(false); triggerRef.current?.focus(); }} t={t} locale={locale} id={listId} />
        </div>
      )}
    </Floating>
  );
  const hidden = name ? <input type="hidden" name={name} value={e164} /> : null;

  /* ---------- detect ---------- */
  if (variant === 'detect') {
    return (
      <>
        <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
          <span className="ms-3 grid w-8 shrink-0 place-items-center">
            {detected ? <CountryCode key={detected.code} code={detected.code} className="transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] starting:scale-50 starting:opacity-0" /> : <Icon name="phone" size={16} className="text-subtle-foreground" />}
          </span>
          {numberInput('ps-2 pe-3')}
          {trailing}
        </div>
        <span aria-live="polite" className="sr-only">{detected ? detected.name[locale] : ''}</span>
        {raw.trim() && !raw.trim().startsWith('+') && <p className="mt-1.5 text-[13px] text-muted-foreground">{t.unknown}</p>}
        {hidden}
      </>
    );
  }

  /* ---------- split: two fields ---------- */
  if (variant === 'split') {
    return (
      <div ref={boxRef} className={cn('grid w-full grid-cols-[auto_minmax(0,1fr)] gap-2', className)}>
        {trigger(cn('h-11 rounded-lg px-3 text-base sm:text-sm', size === 'lg' && 'h-12', controlBox))}
        <div data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex items-center', CONTROL_HEIGHT[size], controlBox)}>
          {numberInput('ps-3.5 pe-3')}
          {trailing}
        </div>
        {panel}
        {hidden}
      </div>
    );
  }

  /* ---------- select and verify: country and number in one box ---------- */
  return (
    <div className={cn('grid w-full gap-2', className)}>
      <div className="flex w-full gap-2 max-sm:flex-col">
        <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('flex min-w-0 items-center overflow-hidden max-sm:w-full sm:flex-1', CONTROL_HEIGHT[size], controlBox)}>
          {trigger('h-full border-e border-border ps-3 pe-2.5')}
          {numberInput('ps-3 pe-3')}
          {trailing}
        </div>
        {variant === 'verify' && (
          <button
            type="button"
            onClick={stage === 'sent' ? (left > 0 ? undefined : send) : send}
            disabled={!complete || stage === 'sending' || (stage === 'sent' && left > 0)}
            className={cn(
              'relative inline-flex h-11 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-lg px-4 text-sm font-medium outline-none transition-[background-color,color,opacity,scale] duration-200 active:scale-[0.97] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_30%,transparent)] disabled:cursor-not-allowed',
              stage === 'sent' ? 'border border-border-strong bg-background text-foreground disabled:text-muted-foreground' : 'bg-primary text-primary-foreground disabled:opacity-50',
            )}
          >
            {stage === 'sending' && <Icon name="loader" size={15} className="animate-spin motion-reduce:animate-none" />}
            {stage === 'idle' ? t.send : stage === 'sending' ? t.sending : left > 0 ? `${t.resendIn} ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : t.resend}
            {/* The countdown drains as a thin line along the bottom */}
            {stage === 'sent' && left > 0 && <span aria-hidden className="absolute inset-x-0 bottom-0 h-[2px] origin-left bg-primary transition-[scale] duration-1000 ease-linear" style={{ scale: `${left / resendAfter} 1` }} />}
          </button>
        )}
      </div>
      {variant === 'verify' && stage === 'sent' && (
        <p role="status" className="flex flex-wrap items-center gap-x-2 text-[13px] text-muted-foreground transition-[opacity,translate] duration-300 starting:-translate-y-1 starting:opacity-0">
          <Icon name="check-circle" size={15} className="text-success" />
          {t.sent} <span className="font-medium tabular-nums text-foreground">+{country.dial} {formatNational(national, country)}</span>
          <button type="button" onClick={() => { setStage('idle'); setLeft(0); requestAnimationFrame(() => inputRef.current?.focus()); }} className="inline-flex min-h-11 items-center font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:underline sm:min-h-0">
            {t.change}
          </button>
        </p>
      )}
      {panel}
      {hidden}
    </div>
  );
}

/**
 * A phone number with its country, grouped as you type, given back in E.164.
 *   <PhoneInput label="Mobile" defaultCountry="DE" />
 *   <PhoneInput variant="detect" label="Phone" />
 *   <PhoneInput variant="split" label="Phone" preferred={['DE', 'AT', 'CH']} />
 *   <PhoneInput variant="verify" label="Mobile" onSendCode={(n) => api.sendCode(n)} />
 */
export function PhoneInput({ label, hint, error, optional, labelAction, className, ...rest }: PhoneInputProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <PhoneBox {...rest} invalid={rest.invalid ?? (error ? true : undefined)} className={wrapped ? undefined : className} />);
}

Häufige Fragen

Wie installiere ich Telefonfeld?

Mit aiw add @ai-website/phone-input. Der Befehl legt die Datei components/ui/phone-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, input, select und popover. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Telefonfeld?

4 Varianten, Land und Nummer, Erkennt das Land, Zwei Felder und Code senden. Du wählst sie mit der Prop variant, zum Beispiel variant="split".

Kann ich die Texte von Telefonfeld übersetzen?

Ja. Telefonfeld bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Land“, „Land oder Vorwahl suchen“ und „Kein Land gefunden. Versuch die Vorwahl.“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Telefonfeld einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Telefonfeld mit aiw_components und installieren sie mit aiw_add @ai-website/phone-input. Nenn im Prompt die Variante, etwa variant="detect", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Telefonfeld ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PhoneInput-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert