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
Variants
Details
Was Telefonfeld mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Spezielle Eingabefelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/phone-input.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 11 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
variantoptionalPhoneInputVariantselectdetectsplitverify
valueoptionalstringE.164, e.g. "+4915112345678"
defaultValueoptionalstringonValueChangeoptional(e164: string, info: { country: string; national: string; complete: boolean }) => voiddefaultCountryoptionalstringThe country at the start, e.g. "DE"
countriesoptionalPhoneCountry[]The countries on offer, default a list of 19
preferredoptionalstring[]Countries shown first in the list
placeholderoptionalstringonSendCodeoptional(e164: string) => Promise<void> | voidverify: called with the number, resolve when the code is on its way
resendAfteroptionalnumberverify: seconds before the code can be sent again. Default 30.
sizeoptionalControlSizenameoptionalstringidoptionalstringdisabledoptionalbooleanrequiredoptionalbooleaninvalidoptionalboolean
Eingebaute Texte
Diese Texte schreibt Telefonfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 11 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| country | Englisch Country | Deutsch Land |
| search | Englisch Search a country or code | Deutsch Land oder Vorwahl suchen |
| empty | Englisch No country found. Try the dialling code. | Deutsch Kein Land gefunden. Versuch die Vorwahl. |
| valid | Englisch Number complete | Deutsch Nummer vollständig |
| send | Englisch Send code | Deutsch Code senden |
| sending | Englisch Sending… | Deutsch Wird gesendet… |
| resend | Englisch Send again | Deutsch Erneut senden |
| resendIn | Englisch Send again in | Deutsch Erneut senden in |
| sent | Englisch Code sent to | Deutsch Code gesendet an |
| change | Englisch Change number | Deutsch Nummer ändern |
| unknown | Englisch Start with + and the country code | Deutsch 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
'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.