Inputs
Phone field
A phone number with a searchable country code picker, light formatting and an E.164 value.
- Built on
<input type=tel>Popover APIautocomplete=tel-nationalsetCustomValidityaria-activedescendant- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 8.9 kB
- Dependencies
- React
For sign-in codes only.
- Argentina+54
- Australia+61
- Austria+43
- Bangladesh+880
- Belgium+32
- Brazil+55
- Bulgaria+359
- Canada+1
- Chile+56
- China+86
- Colombia+57
- Croatia+385
- Cyprus+357
- Czechia+420
- Denmark+45
- Egypt+20
- Estonia+372
- Finland+358
- France+33
- Germany+49
- Ghana+233
- Greece+30
- Hong Kong+852
- Hungary+36
- Iceland+354
- India+91
- Indonesia+62
- Ireland+353
- Israel+972
- Italy+39
- Japan+81
- Kazakhstan+7
- Kenya+254
- Latvia+371
- Lithuania+370
- Luxembourg+352
- Malaysia+60
- Malta+356
- Mexico+52
- Morocco+212
- Netherlands+31
- New Zealand+64
- Nigeria+234
- Norway+47
- Pakistan+92
- Peru+51
- Philippines+63
- Poland+48
- Portugal+351
- Qatar+974
- Romania+40
- Russia+7
- Saudi Arabia+966
- Serbia+381
- Singapore+65
- Slovakia+421
- Slovenia+386
- South Africa+27
- South Korea+82
- Spain+34
- Sri Lanka+94
- Sweden+46
- Switzerland+41
- Taiwan+886
- Thailand+66
- Türkiye+90
- Ukraine+380
- United Arab Emirates+971
- United Kingdom+44
- United States+1
- Vietnam+84
Try its options
Loading its options.
Code
import { PhoneField } from "@/components/equip/phone-field";
export default function Demo() {
return (
<div style={{ width: 216, maxWidth: "100%" }}>
<PhoneField label="Mobile number" defaultValue="+447700900123" hint="For sign-in codes only." />
</div>
);
}Your versionchange its options in Look, This part
Examples
E.164 value
Paste +1 415 555 0132 to switch country, or type a UK number with its leading 0: the value drops the 0, as E.164 does.
- Argentina+54
- Australia+61
- Austria+43
- Bangladesh+880
- Belgium+32
- Brazil+55
- Bulgaria+359
- Canada+1
- Chile+56
- China+86
- Colombia+57
- Croatia+385
- Cyprus+357
- Czechia+420
- Denmark+45
- Egypt+20
- Estonia+372
- Finland+358
- France+33
- Germany+49
- Ghana+233
- Greece+30
- Hong Kong+852
- Hungary+36
- Iceland+354
- India+91
- Indonesia+62
- Ireland+353
- Israel+972
- Italy+39
- Japan+81
- Kazakhstan+7
- Kenya+254
- Latvia+371
- Lithuania+370
- Luxembourg+352
- Malaysia+60
- Malta+356
- Mexico+52
- Morocco+212
- Netherlands+31
- New Zealand+64
- Nigeria+234
- Norway+47
- Pakistan+92
- Peru+51
- Philippines+63
- Poland+48
- Portugal+351
- Qatar+974
- Romania+40
- Russia+7
- Saudi Arabia+966
- Serbia+381
- Singapore+65
- Slovakia+421
- Slovenia+386
- South Africa+27
- South Korea+82
- Spain+34
- Sri Lanka+94
- Sweden+46
- Switzerland+41
- Taiwan+886
- Thailand+66
- Türkiye+90
- Ukraine+380
- United Arab Emirates+971
- United Kingdom+44
- United States+1
- Vietnam+84
value = empty
Code
"use client";
import { useState } from "react";
import { PhoneField } from "@/components/equip/phone-field";
export default function Example() {
const [value, setValue] = useState("");
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 260 }}>
<PhoneField label="Phone" value={value} onValueChange={setValue} defaultCountry="GB" />
<p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>value = {value || "empty"}</p>
</div>
);
}Shorter list and states
Pass country codes to keep only the countries you serve; the length check runs once the field is left.
- Germany+49
- Austria+43
- Switzerland+41
- Argentina+54
- Australia+61
- Austria+43
- Bangladesh+880
- Belgium+32
- Brazil+55
- Bulgaria+359
- Canada+1
- Chile+56
- China+86
- Colombia+57
- Croatia+385
- Cyprus+357
- Czechia+420
- Denmark+45
- Egypt+20
- Estonia+372
- Finland+358
- France+33
- Germany+49
- Ghana+233
- Greece+30
- Hong Kong+852
- Hungary+36
- Iceland+354
- India+91
- Indonesia+62
- Ireland+353
- Israel+972
- Italy+39
- Japan+81
- Kazakhstan+7
- Kenya+254
- Latvia+371
- Lithuania+370
- Luxembourg+352
- Malaysia+60
- Malta+356
- Mexico+52
- Morocco+212
- Netherlands+31
- New Zealand+64
- Nigeria+234
- Norway+47
- Pakistan+92
- Peru+51
- Philippines+63
- Poland+48
- Portugal+351
- Qatar+974
- Romania+40
- Russia+7
- Saudi Arabia+966
- Serbia+381
- Singapore+65
- Slovakia+421
- Slovenia+386
- South Africa+27
- South Korea+82
- Spain+34
- Sri Lanka+94
- Sweden+46
- Switzerland+41
- Taiwan+886
- Thailand+66
- Türkiye+90
- Ukraine+380
- United Arab Emirates+971
- United Kingdom+44
- United States+1
- Vietnam+84
Code
import { PhoneField } from "@/components/equip/phone-field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 260 }}>
<PhoneField label="Store phone" countries={["DE", "AT", "CH"]} defaultCountry="DE" defaultValue="+493012345" />
<PhoneField label="Support line" defaultValue="+18005550199" defaultCountry="US" disabled />
</div>
);
}In a form
The hidden input carries the E.164 number; required and the length check stop the submit natively.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { PhoneField } from "@/components/equip/phone-field";
export default function Example() {
const [sent, setSent] = useState<string | null>(null);
return (
<form
style={{ display: "grid", gap: 12, width: "100%", maxWidth: 260 }}
onSubmit={(event) => {
event.preventDefault();
setSent(String(new FormData(event.currentTarget).get("phone")));
}}
>
<PhoneField label="Delivery contact" name="phone" required defaultCountry="IN" hint="The courier calls this number." />
<Button type="submit" variant="outline" style={{ justifySelf: "start" }}>
Save contact
</Button>
{sent && <p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>phone = {sent}</p>}
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the number input. |
value | string | none | Controlled number in E.164, such as "+447700900123"; "" while empty. |
defaultValue | string | "" | Uncontrolled starting number in E.164. |
onValueChange | (value: string) => void | none | Called with the E.164 number on every change of digits or country. |
defaultCountry | string | "GB" | ISO code of the country shown first when the value does not say. |
countries | (string | PhoneCountry)[] | none | ISO codes to keep from the built-in list of about 70, or your own { code, name, dial, trunk, lengths, groups } entries. |
name | string | none | Adds a hidden input carrying the E.164 value. |
required | boolean | none | Native required on the number input. |
disabled | boolean | none | Disables the country button and the number input. |
hint | ReactNode | none | Help text under the control. |
error | ReactNode | none | Error text. Replaces the part's own length message. |
placeholder | string | none | Defaults to a sample number in the country's grouping. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper. |
Anatomy
| root | .eq-phone-field | |
|---|---|---|
| label | .eq-phone-field-label | |
| control | .eq-phone-field-control | The bordered box; data-invalid while a message shows. |
| country | .eq-phone-field-country | Button that opens the country list. |
| iso | .eq-phone-field-iso | Two-letter code, hidden from assistive tech; the button's name has the country. |
| dial | .eq-phone-field-dial | |
| chevron | .eq-phone-field-chevron | |
| input | .eq-phone-field-input | type=tel with the national number, formatted as you type. |
| hint | .eq-phone-field-hint | |
| error | .eq-phone-field-error | |
| popover | .eq-phone-field-popover | Manual popover in the top layer. |
| search | .eq-phone-field-search | |
| search input | .eq-phone-field-search-input | role=combobox; matches name, ISO code or dial code. |
| list | .eq-phone-field-list | |
| option | .eq-phone-field-option | data-active marks the row under visual focus. |
| option name | .eq-phone-field-option-name | |
| option dial | .eq-phone-field-option-dial | |
| empty | .eq-phone-field-empty | |
| status | .eq-phone-field-status | Visually hidden live region with the match count. |
Keyboard
| EnterSpaceArrowDownArrowUp | On the country button, opens the list on the current country. |
|---|---|
| A-Z | On the country button, opens the list and starts a search with that letter. |
| ArrowDownArrowUp | Moves through the countries, wrapping. |
| PageDownPageUp | Moves eight countries at a time. |
| Enter | Picks the highlighted country and moves to the number. Never submits the form. |
| Escape | Closes the list and returns focus to the country button. |
| Backspace | In the number, just after a space or bracket, deletes the digit before it. |
Notes
- Countries are listed by name and dial code, with no flags; +1 and +7 are shared, so countries are told apart by ISO code and a pasted +1 number keeps the current one when it also uses +1.
- Typing or pasting a number that starts with + or 00 switches to the country it names.
- A leading trunk 0, as in UK 07700 900123, stays on screen but is dropped from the E.164 value.
- Formatting is light grouping for reading, with the caret kept in place; it is not a numbering plan, so validate on the server with a library such as libphonenumber.
- A number shorter or longer than the country allows is set with setCustomValidity on the input; the message shows once the field has been left.
- Numbers are capped at the 15 digits E.164 allows, country code included.
- The built-in dial codes and lengths were written from reference knowledge and the length check can reject a real number, such as a short service line; check the countries you rely on, pass your own lengths, or turn the check off with your own countries list.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/phone-field.tsx568 lines
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, KeyboardEvent, ReactNode } from "react";
import "./phone-field.css";
export type PhoneCountry = {
/** ISO 3166 code, such as "GB". Countries are told apart by this, since +1 and +7 are shared. */
code: string;
name: string;
/** Country calling code without the plus, such as "44". */
dial: string;
/** National digits are dialled with a leading 0 that E.164 drops, as in 07700 900123. */
trunk?: boolean;
/** Fewest and most national digits, without the trunk 0. Defaults to 6 and what E.164 leaves. */
lengths?: [number, number];
/** Group sizes for display. The last group takes any extra digits. */
groups?: number[];
};
/* Dial codes and lengths are a light guide for formatting and a first check, not a numbering plan. */
const COUNTRIES: PhoneCountry[] = [
{ code: "AR", name: "Argentina", dial: "54", trunk: true, groups: [2, 4, 4] },
{ code: "AU", name: "Australia", dial: "61", trunk: true, lengths: [9, 9], groups: [3, 3, 3] },
{ code: "AT", name: "Austria", dial: "43", trunk: true, groups: [3, 3, 4] },
{ code: "BD", name: "Bangladesh", dial: "880", trunk: true, groups: [4, 6] },
{ code: "BE", name: "Belgium", dial: "32", trunk: true, lengths: [8, 9], groups: [3, 2, 2, 2] },
{ code: "BR", name: "Brazil", dial: "55", trunk: true, lengths: [10, 11], groups: [2, 5, 4] },
{ code: "BG", name: "Bulgaria", dial: "359", trunk: true, groups: [2, 3, 4] },
{ code: "CA", name: "Canada", dial: "1", lengths: [10, 10], groups: [3, 3, 4] },
{ code: "CL", name: "Chile", dial: "56", lengths: [9, 9], groups: [1, 4, 4] },
{ code: "CN", name: "China", dial: "86", trunk: true, lengths: [10, 11], groups: [3, 4, 4] },
{ code: "CO", name: "Colombia", dial: "57", lengths: [10, 10], groups: [3, 3, 4] },
{ code: "HR", name: "Croatia", dial: "385", trunk: true, groups: [2, 3, 4] },
{ code: "CY", name: "Cyprus", dial: "357", lengths: [8, 8], groups: [2, 6] },
{ code: "CZ", name: "Czechia", dial: "420", lengths: [9, 9], groups: [3, 3, 3] },
{ code: "DK", name: "Denmark", dial: "45", lengths: [8, 8], groups: [2, 2, 2, 2] },
{ code: "EG", name: "Egypt", dial: "20", trunk: true, groups: [3, 3, 4] },
{ code: "EE", name: "Estonia", dial: "372", lengths: [7, 8], groups: [4, 4] },
{ code: "FI", name: "Finland", dial: "358", trunk: true, groups: [2, 3, 4] },
{ code: "FR", name: "France", dial: "33", trunk: true, lengths: [9, 9], groups: [1, 2, 2, 2, 2] },
{ code: "DE", name: "Germany", dial: "49", trunk: true, groups: [3, 4, 4] },
{ code: "GH", name: "Ghana", dial: "233", trunk: true, lengths: [9, 9], groups: [2, 3, 4] },
{ code: "GR", name: "Greece", dial: "30", lengths: [10, 10], groups: [3, 3, 4] },
{ code: "HK", name: "Hong Kong", dial: "852", lengths: [8, 8], groups: [4, 4] },
{ code: "HU", name: "Hungary", dial: "36", groups: [2, 3, 4] },
{ code: "IS", name: "Iceland", dial: "354", lengths: [7, 7], groups: [3, 4] },
{ code: "IN", name: "India", dial: "91", trunk: true, lengths: [10, 10], groups: [5, 5] },
{ code: "ID", name: "Indonesia", dial: "62", trunk: true, groups: [3, 4, 4] },
{ code: "IE", name: "Ireland", dial: "353", trunk: true, groups: [2, 3, 4] },
{ code: "IL", name: "Israel", dial: "972", trunk: true, groups: [2, 3, 4] },
{ code: "IT", name: "Italy", dial: "39", groups: [3, 3, 4] },
{ code: "JP", name: "Japan", dial: "81", trunk: true, lengths: [9, 10], groups: [2, 4, 4] },
{ code: "KZ", name: "Kazakhstan", dial: "7", lengths: [10, 10], groups: [3, 3, 2, 2] },
{ code: "KE", name: "Kenya", dial: "254", trunk: true, lengths: [9, 9], groups: [3, 3, 3] },
{ code: "LV", name: "Latvia", dial: "371", lengths: [8, 8], groups: [2, 3, 3] },
{ code: "LT", name: "Lithuania", dial: "370", lengths: [8, 8], groups: [3, 5] },
{ code: "LU", name: "Luxembourg", dial: "352", groups: [3, 3, 3] },
{ code: "MY", name: "Malaysia", dial: "60", trunk: true, groups: [2, 4, 4] },
{ code: "MT", name: "Malta", dial: "356", lengths: [8, 8], groups: [4, 4] },
{ code: "MX", name: "Mexico", dial: "52", lengths: [10, 10], groups: [2, 4, 4] },
{ code: "MA", name: "Morocco", dial: "212", trunk: true, lengths: [9, 9], groups: [3, 2, 2, 2] },
{ code: "NL", name: "Netherlands", dial: "31", trunk: true, lengths: [9, 9], groups: [1, 4, 4] },
{ code: "NZ", name: "New Zealand", dial: "64", trunk: true, groups: [2, 3, 4] },
{ code: "NG", name: "Nigeria", dial: "234", trunk: true, groups: [3, 3, 4] },
{ code: "NO", name: "Norway", dial: "47", lengths: [8, 8], groups: [3, 2, 3] },
{ code: "PK", name: "Pakistan", dial: "92", trunk: true, lengths: [9, 10], groups: [3, 7] },
{ code: "PE", name: "Peru", dial: "51", lengths: [8, 9], groups: [3, 3, 3] },
{ code: "PH", name: "Philippines", dial: "63", trunk: true, lengths: [9, 10], groups: [3, 3, 4] },
{ code: "PL", name: "Poland", dial: "48", lengths: [9, 9], groups: [3, 3, 3] },
{ code: "PT", name: "Portugal", dial: "351", lengths: [9, 9], groups: [3, 3, 3] },
{ code: "QA", name: "Qatar", dial: "974", lengths: [8, 8], groups: [4, 4] },
{ code: "RO", name: "Romania", dial: "40", trunk: true, lengths: [9, 9], groups: [3, 3, 3] },
{ code: "RU", name: "Russia", dial: "7", lengths: [10, 10], groups: [3, 3, 2, 2] },
{ code: "SA", name: "Saudi Arabia", dial: "966", trunk: true, lengths: [8, 9], groups: [2, 3, 4] },
{ code: "RS", name: "Serbia", dial: "381", trunk: true, groups: [2, 3, 4] },
{ code: "SG", name: "Singapore", dial: "65", lengths: [8, 8], groups: [4, 4] },
{ code: "SK", name: "Slovakia", dial: "421", trunk: true, lengths: [9, 9], groups: [3, 3, 3] },
{ code: "SI", name: "Slovenia", dial: "386", trunk: true, lengths: [8, 8], groups: [2, 3, 3] },
{ code: "ZA", name: "South Africa", dial: "27", trunk: true, lengths: [9, 9], groups: [2, 3, 4] },
{ code: "KR", name: "South Korea", dial: "82", trunk: true, groups: [2, 4, 4] },
{ code: "ES", name: "Spain", dial: "34", lengths: [9, 9], groups: [3, 3, 3] },
{ code: "LK", name: "Sri Lanka", dial: "94", trunk: true, lengths: [9, 9], groups: [2, 3, 4] },
{ code: "SE", name: "Sweden", dial: "46", trunk: true, groups: [2, 3, 2, 2] },
{ code: "CH", name: "Switzerland", dial: "41", trunk: true, lengths: [9, 9], groups: [2, 3, 2, 2] },
{ code: "TW", name: "Taiwan", dial: "886", trunk: true, groups: [3, 3, 3] },
{ code: "TH", name: "Thailand", dial: "66", trunk: true, lengths: [8, 9], groups: [2, 3, 4] },
{ code: "TR", name: "Türkiye", dial: "90", trunk: true, lengths: [10, 10], groups: [3, 3, 2, 2] },
{ code: "UA", name: "Ukraine", dial: "380", trunk: true, lengths: [9, 9], groups: [2, 3, 2, 2] },
{ code: "AE", name: "United Arab Emirates", dial: "971", trunk: true, groups: [2, 3, 4] },
{ code: "GB", name: "United Kingdom", dial: "44", trunk: true, lengths: [9, 10], groups: [4, 6] },
{ code: "US", name: "United States", dial: "1", lengths: [10, 10], groups: [3, 3, 4] },
{ code: "VN", name: "Vietnam", dial: "84", trunk: true, lengths: [9, 10], groups: [3, 3, 4] },
];
/** Which country a shared dial code means when nothing else says: +1 is the United States, +7 Russia. */
const MAIN = ["US", "RU"];
export type PhoneFieldProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
label: ReactNode;
hint?: ReactNode;
/** Error text. Replaces the part's own length check message. */
error?: ReactNode;
/** Controlled number in E.164, such as "+447700900123". "" while empty. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** ISO code of the country picked at first, when the value does not say. */
defaultCountry?: string;
/** Limit or replace the built-in list. Pass the codes to keep, or your own countries. */
countries?: (string | PhoneCountry)[];
/** Name of a hidden input that carries the E.164 value in a form. */
name?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
};
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
function supportsPopover(el: HTMLElement) {
return typeof el.showPopover === "function";
}
const digitsOf = (text: string) => text.replace(/\D/g, "");
/** Splits an international number into a country and its national digits, preferring the current country when dial codes are shared. */
function split(international: string, list: PhoneCountry[], prefer?: PhoneCountry): { country: PhoneCountry; national: string } | null {
const digits = digitsOf(international);
const fits = list.filter((c) => digits.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length);
if (fits.length === 0) return null;
const longest = fits.filter((c) => c.dial === fits[0].dial);
const country =
longest.find((c) => c.code === prefer?.code) ?? longest.find((c) => MAIN.includes(c.code)) ?? longest[0];
return { country, national: digits.slice(country.dial.length) };
}
/** National digits without the trunk 0, the way E.164 writes them. */
function significant(country: PhoneCountry, national: string) {
return country.trunk && national.startsWith("0") ? national.slice(1) : national;
}
function format(country: PhoneCountry, national: string) {
const trunk = country.trunk && national.startsWith("0") ? "0" : "";
const rest = national.slice(trunk.length);
const groups = country.groups ?? [3, 3, 4];
const out: string[] = [];
let at = 0;
groups.forEach((size, i) => {
if (at >= rest.length) return;
const take = i === groups.length - 1 ? rest.length - at : size;
out.push(rest.slice(at, at + take));
at += take;
});
const grouped = out.join(" ");
// North American numbers are written (415) 555 0132.
if (country.dial === "1" && rest.length > 3) return `(${rest.slice(0, 3)}) ${out.slice(1).join(" ")}`;
return trunk + grouped;
}
/** Caret index just after the nth digit in a formatted string. */
function caretAfter(text: string, digits: number) {
if (digits <= 0) {
const first = text.search(/\d/);
return first === -1 ? text.length : first;
}
let seen = 0;
for (let i = 0; i < text.length; i++) {
if (/\d/.test(text[i]) && ++seen === digits) return i + 1;
}
return text.length;
}
export function PhoneField({
label,
hint,
error,
value,
defaultValue = "",
onValueChange,
defaultCountry = "GB",
countries,
name,
required,
disabled,
placeholder,
className,
...rest
}: PhoneFieldProps) {
const id = useId();
const inputId = `${id}-number`;
const listId = `${id}-countries`;
const hintId = present(hint) ? `${id}-hint` : undefined;
const optionId = (index: number) => `${id}-country-${index}`;
const list: PhoneCountry[] = countries
? countries.flatMap((c) => (typeof c === "string" ? COUNTRIES.filter((k) => k.code === c.toUpperCase()) : [c]))
: COUNTRIES;
const fallback = list.find((c) => c.code === defaultCountry.toUpperCase()) ?? list[0];
function initial(e164: string) {
const found = e164 ? split(e164, list, fallback) : null;
return found ?? { country: fallback, national: "" };
}
const [state, setState] = useState(() => initial(value ?? defaultValue));
const [synced, setSynced] = useState(value);
const [touched, setTouched] = useState(false);
const [open, setOpen] = useState(false);
/** Read by key handlers, which can run again before a render has caught up with a fast typist. */
const openRef = useRef(false);
const setShown = (next: boolean) => {
openRef.current = next;
setOpen(next);
};
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
/** An international number being typed, shown as typed until its country code is complete. */
const [draft, setDraft] = useState<string | null>(null);
const { country, national } = state;
const max = Math.min(15 - country.dial.length, country.lengths?.[1] ?? 15);
const nsn = significant(country, national);
const e164 = nsn ? `+${country.dial}${nsn}` : "";
if (value !== undefined && value !== synced) {
setSynced(value);
if (value !== e164) setState(initial(value));
}
const rootRef = useRef<HTMLDivElement>(null);
const controlRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
/** Digits that should sit before the caret after the next render. */
const caret = useRef<number | null>(null);
const needle = query.trim().toLowerCase().replace(/^\+/, "");
// Names that start with the search come first, then names that contain it, then code matches.
const rank = (c: PhoneCountry) => {
const n = c.name.toLowerCase();
return n.startsWith(needle) ? 0 : n.includes(` ${needle}`) ? 1 : n.includes(needle) ? 2 : c.code.toLowerCase() === needle ? 3 : c.dial.startsWith(needle) ? 4 : 5;
};
const matches = needle ? list.filter((c) => rank(c) < 5).sort((a, b) => rank(a) - rank(b)) : list;
const activeIndex = active < matches.length ? active : matches.length - 1;
const [min, most] = country.lengths ?? [6, 15 - country.dial.length];
const problem = !nsn
? ""
: nsn.length < min
? `This number is too short for ${country.name}.`
: nsn.length > most
? `This number is too long for ${country.name}.`
: "";
const message = error || (touched && problem) || null;
const errorId = message ? `${id}-error` : undefined;
useLayoutEffect(() => {
inputRef.current?.setCustomValidity(problem);
}, [problem]);
useLayoutEffect(() => {
const input = inputRef.current;
if (caret.current === null || !input || document.activeElement !== input) return;
const at = caretAfter(input.value, caret.current);
input.setSelectionRange(at, at);
caret.current = null;
});
function update(next: { country: PhoneCountry; national: string }) {
setState(next);
const sig = significant(next.country, next.national);
const out = sig ? `+${next.country.dial}${sig}` : "";
if (out !== e164) onValueChange?.(out);
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const input = event.target;
const raw = input.value;
const before = digitsOf(raw.slice(0, input.selectionStart ?? raw.length)).length;
// A pasted international number picks its own country.
if (/^\s*(\+|00)/.test(raw)) {
const found = split(raw.replace(/^\s*00/, "+"), list, country);
if (!found) {
setDraft(raw.slice(0, 8));
return;
}
setDraft(null);
caret.current = found.national.length;
update({ country: found.country, national: found.national.slice(0, Math.min(15 - found.country.dial.length, found.country.lengths?.[1] ?? 15) + (found.country.trunk ? 1 : 0)) });
return;
}
setDraft(null);
const digits = digitsOf(raw);
const limit = max + (country.trunk && digits.startsWith("0") ? 1 : 0);
caret.current = Math.min(before, limit);
update({ country, national: digits.slice(0, limit) });
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const input = event.currentTarget;
const start = input.selectionStart ?? 0;
// Backspace just after a space or bracket deletes the digit before it, rather than a separator that comes straight back.
if (event.key === "Backspace" && start === input.selectionEnd && start > 0 && /\D/.test(input.value[start - 1])) {
const before = digitsOf(input.value.slice(0, start)).length;
if (before === 0) return;
event.preventDefault();
caret.current = before - 1;
update({ country, national: national.slice(0, before - 1) + national.slice(before) });
}
}
function show(seed = "") {
if (disabled) return;
setQuery(seed);
const index = seed ? 0 : list.indexOf(country);
setActive(Math.max(index, 0));
setShown(true);
}
function hide(refocus: "trigger" | "input" | null) {
setShown(false);
if (refocus === "trigger") triggerRef.current?.focus();
if (refocus === "input") inputRef.current?.focus();
}
function pick(next: PhoneCountry) {
caret.current = null;
update({ country: next, national: national.slice(0, Math.min(15 - next.dial.length, next.lengths?.[1] ?? 15) + (next.trunk ? 1 : 0)) });
hide("input");
}
useLayoutEffect(() => {
const popover = popoverRef.current;
const control = controlRef.current;
if (!popover || !control) return;
const native = supportsPopover(popover);
if (!open) {
if (native && popover.matches(":popover-open")) popover.hidePopover();
return;
}
if (native && !popover.matches(":popover-open")) popover.showPopover();
function place() {
if (!popover || !control) return;
const rect = control.getBoundingClientRect();
const width = Math.max(rect.width, 240);
popover.style.left = `${Math.max(4, Math.min(rect.left, window.innerWidth - width - 4))}px`;
popover.style.width = `${width}px`;
const height = popover.offsetHeight;
const below = rect.bottom + 4;
const above = below + height > window.innerHeight && rect.top - 4 - height >= 0;
popover.style.top = `${above ? rect.top - 4 - height : below}px`;
popover.dataset.side = above ? "top" : "bottom";
}
place();
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open, matches.length]);
useEffect(() => {
if (!open) return;
searchRef.current?.focus();
function outside(event: PointerEvent) {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setShown(false);
}
document.addEventListener("pointerdown", outside);
return () => document.removeEventListener("pointerdown", outside);
}, [open]);
useEffect(() => {
if (open) document.getElementById(optionId(activeIndex))?.scrollIntoView({ block: "nearest" });
});
// A form reset puts the number back to where it started.
useEffect(() => {
const form = inputRef.current?.form;
if (!form) return;
function reset() {
setTouched(false);
update(initial(defaultValue));
}
form.addEventListener("reset", reset);
return () => form.removeEventListener("reset", reset);
});
function handleTriggerKey(event: KeyboardEvent<HTMLButtonElement>) {
if (openRef.current && (event.key === "Enter" || event.key.startsWith("Arrow") || event.key === "Escape")) {
// Keys that land before focus has moved into the list still act on it.
handleSearchKey(event);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
show();
} else if (event.key.length === 1 && event.key !== " " && !event.metaKey && !event.ctrlKey && !event.altKey) {
// Typing on the button starts a search with that letter, or adds to one already open.
event.preventDefault();
event.stopPropagation();
if (openRef.current) {
setQuery((q) => q + event.key);
setActive(0);
} else show(event.key);
}
}
function handleSearchKey(event: KeyboardEvent<HTMLElement>) {
if (event.nativeEvent.isComposing) return;
const count = matches.length;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (count) setActive((activeIndex + 1) % count);
return;
case "ArrowUp":
event.preventDefault();
if (count) setActive((activeIndex - 1 + count) % count);
return;
case "PageDown":
case "PageUp":
event.preventDefault();
if (count) setActive(Math.max(0, Math.min(count - 1, activeIndex + (event.key === "PageDown" ? 8 : -8))));
return;
case "Enter":
// Always taken, so Enter never submits the surrounding form from the search box.
event.preventDefault();
if (matches[activeIndex]) pick(matches[activeIndex]);
return;
case "Escape":
event.preventDefault();
event.stopPropagation();
hide("trigger");
return;
}
}
return (
<div
{...rest}
ref={rootRef}
className={["eq-phone-field", className].filter(Boolean).join(" ")}
data-disabled={disabled || undefined}
onBlur={(event) => {
if (open && !(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) setShown(false);
}}
>
<label className="eq-phone-field-label" htmlFor={inputId}>
{label}
</label>
<div ref={controlRef} className="eq-phone-field-control" data-invalid={Boolean(message) || undefined}>
<button
ref={triggerRef}
type="button"
className="eq-phone-field-country"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-label={`Country code: ${country.name}, +${country.dial}`}
disabled={disabled}
onClick={() => (open ? hide("trigger") : show())}
onKeyDown={handleTriggerKey}
>
<span className="eq-phone-field-iso" aria-hidden="true">
{country.code}
</span>
<span className="eq-phone-field-dial">+{country.dial}</span>
<svg className="eq-phone-field-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
</svg>
</button>
<input
ref={inputRef}
id={inputId}
className="eq-phone-field-input"
type="tel"
inputMode="tel"
autoComplete="tel-national"
placeholder={placeholder ?? format(country, "0123456789".slice(0, Math.min(max, country.lengths?.[1] ?? 10)).replace(/^0/, "7"))}
value={draft ?? format(country, national)}
required={required}
disabled={disabled}
aria-invalid={Boolean(message) || undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => {
setTouched(true);
setDraft(null);
}}
/>
</div>
{present(hint) && (
<p className="eq-phone-field-hint" id={hintId}>
{hint}
</p>
)}
{message && (
<p className="eq-phone-field-error" id={errorId}>
{message}
</p>
)}
{name && <input type="hidden" name={name} value={e164} />}
<div
ref={popoverRef}
className="eq-phone-field-popover"
popover="manual"
hidden={!open}
onMouseDown={(event) => {
if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
}}
>
<div className="eq-phone-field-search">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" />
<path d="m10.5 10.5 3 3" />
</svg>
<input
ref={searchRef}
className="eq-phone-field-search-input"
type="text"
role="combobox"
aria-label="Search countries"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={matches.length ? optionId(activeIndex) : undefined}
autoComplete="off"
spellCheck={false}
placeholder="Country or code"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={handleSearchKey}
/>
</div>
<ul id={listId} className="eq-phone-field-list" role="listbox" aria-label="Countries">
{matches.map((c, index) => (
<li
key={c.code}
id={optionId(index)}
className="eq-phone-field-option"
role="option"
aria-selected={c.code === country.code}
data-active={index === activeIndex || undefined}
onClick={() => pick(c)}
>
<span className="eq-phone-field-option-name">{c.name}</span>
<span className="eq-phone-field-option-dial">+{c.dial}</span>
</li>
))}
</ul>
{matches.length === 0 && <div className="eq-phone-field-empty">No country matches</div>}
</div>
<div className="eq-phone-field-status" role="status" aria-live="polite">
{open && needle ? (matches.length === 0 ? "No country matches" : `${matches.length} ${matches.length === 1 ? "country" : "countries"}`) : ""}
</div>
</div>
);
}components/equip/phone-field.css336 lines
@layer equip {
.eq-phone-field {
position: relative;
display: grid;
/* The number input's intrinsic width must not set the field's width. */
grid-template-columns: minmax(0, 1fr);
gap: var(--eq-space-1);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
.eq-phone-field-label {
justify-self: start;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-phone-field-hint,
.eq-phone-field-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-phone-field-hint {
color: var(--eq-ink-2);
}
.eq-phone-field-error {
color: var(--eq-danger);
}
/* One bordered box: the country button, a hairline, then the number. */
.eq-phone-field-control {
box-sizing: border-box;
display: flex;
align-items: stretch;
height: var(--eq-h-m);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
transition: border-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
/* :where keeps this below the invalid rules, so a red border stays red on hover. */
.eq-phone-field-control:hover:where(:not(:has(:disabled))) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-phone-field-control:has(.eq-phone-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-phone-field-control[data-invalid],
.eq-phone-field-control:has(.eq-phone-field-input:user-invalid) {
border-color: var(--eq-danger);
}
.eq-phone-field-country {
display: flex;
flex: none;
align-items: center;
gap: var(--eq-space-1);
margin: 0;
padding: 0 var(--eq-space-2) 0 var(--eq-space-3);
border: 0;
border-inline-end: 1px solid var(--eq-line);
border-start-start-radius: calc(var(--eq-radius-m) - 1px);
border-end-start-radius: calc(var(--eq-radius-m) - 1px);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-phone-field-country:hover:not(:disabled) {
background: var(--eq-tint);
}
}
.eq-phone-field-country:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-phone-field-iso {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-phone-field-dial {
min-width: 3ch;
}
.eq-phone-field-chevron {
flex: none;
width: 14px;
height: 14px;
color: var(--eq-ink-2);
transition:
rotate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur) var(--eq-ease);
}
.eq-phone-field-country[aria-expanded="true"] .eq-phone-field-chevron {
rotate: 180deg;
}
.eq-phone-field-country:active:not(:disabled) .eq-phone-field-chevron {
scale: calc(var(--eq-press) - 0.04);
}
.eq-phone-field-input {
box-sizing: border-box;
flex: 1;
min-width: 0;
margin: 0;
padding: 0 var(--eq-space-3);
border: 0;
border-start-end-radius: inherit;
border-end-end-radius: inherit;
background: transparent;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
outline: none;
}
.eq-phone-field-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-phone-field[data-disabled] .eq-phone-field-control {
opacity: 0.45;
cursor: not-allowed;
}
.eq-phone-field[data-disabled] .eq-phone-field-control * {
cursor: not-allowed;
}
/* ---- the floating country list ---- */
.eq-phone-field-popover {
box-sizing: border-box;
position: fixed;
inset: auto;
display: flex;
flex-direction: column;
max-height: min(320px, calc(100dvh - 2 * var(--eq-space-4)));
margin: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink);
opacity: 1;
scale: 1;
translate: 0 0;
transform-origin: top left;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-phone-field-popover[data-side="top"] {
transform-origin: bottom left;
}
/* Leaving is quicker than arriving, and does not spring. */
.eq-phone-field-popover[hidden] {
display: none;
opacity: 0;
scale: 0.96;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
@starting-style {
.eq-phone-field-popover:not([hidden]) {
opacity: 0;
scale: 0.96;
translate: 0 -4px;
}
.eq-phone-field-popover[data-side="top"]:not([hidden]) {
translate: 0 4px;
}
}
.eq-phone-field-search {
display: flex;
flex: none;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
border-bottom: 1px solid var(--eq-line);
color: var(--eq-ink-3);
}
.eq-phone-field-search > svg {
flex: none;
width: 16px;
height: 16px;
}
.eq-phone-field-search-input {
flex: 1;
min-width: 0;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
outline: none;
}
.eq-phone-field-search-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-phone-field-list {
display: grid;
align-content: start;
flex: 1 1 auto;
min-height: 0;
margin: 0;
padding: var(--eq-space-1);
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
}
.eq-phone-field-option {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-phone-field-option:active {
scale: var(--eq-press);
}
@media (hover: hover) {
.eq-phone-field-option:hover {
background: var(--eq-tint);
}
}
/* The row under visual focus, the one aria-activedescendant names. */
.eq-phone-field-option[data-active] {
background: var(--eq-tint-strong);
}
/* The country in use is the selected row. */
.eq-phone-field-option[aria-selected="true"] {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-phone-field-option[aria-selected="true"][data-active] {
background: color-mix(in srgb, var(--eq-solid) 85%, var(--eq-solid-ink));
}
.eq-phone-field-option-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-phone-field-option-dial {
flex: none;
color: var(--eq-ink-2);
font-variant-numeric: tabular-nums;
}
.eq-phone-field-option[aria-selected="true"] .eq-phone-field-option-dial {
color: inherit;
opacity: 0.75;
}
.eq-phone-field-empty {
display: flex;
align-items: center;
min-height: var(--eq-h-s);
padding: var(--eq-space-1) var(--eq-space-3) var(--eq-space-2);
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
}
.eq-phone-field-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}