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.

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.

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.

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.

The courier calls this number.

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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the number input.
valuestringnoneControlled number in E.164, such as "+447700900123"; "" while empty.
defaultValuestring""Uncontrolled starting number in E.164.
onValueChange(value: string) => voidnoneCalled with the E.164 number on every change of digits or country.
defaultCountrystring"GB"ISO code of the country shown first when the value does not say.
countries(string | PhoneCountry)[]noneISO codes to keep from the built-in list of about 70, or your own { code, name, dial, trunk, lengths, groups } entries.
namestringnoneAdds a hidden input carrying the E.164 value.
requiredbooleannoneNative required on the number input.
disabledbooleannoneDisables the country button and the number input.
hintReactNodenoneHelp text under the control.
errorReactNodenoneError text. Replaces the part's own length message.
placeholderstringnoneDefaults to a sample number in the country's grouping.
...restComponentProps<"div">noneEvery native div attribute on the wrapper.

Anatomy

root.eq-phone-field
label.eq-phone-field-label
control.eq-phone-field-controlThe bordered box; data-invalid while a message shows.
country.eq-phone-field-countryButton that opens the country list.
iso.eq-phone-field-isoTwo-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-inputtype=tel with the national number, formatted as you type.
hint.eq-phone-field-hint
error.eq-phone-field-error
popover.eq-phone-field-popoverManual popover in the top layer.
search.eq-phone-field-search
search input.eq-phone-field-search-inputrole=combobox; matches name, ISO code or dial code.
list.eq-phone-field-list
option.eq-phone-field-optiondata-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-statusVisually hidden live region with the match count.

Keyboard

EnterSpaceArrowDownArrowUpOn the country button, opens the list on the current country.
A-ZOn the country button, opens the list and starts a search with that letter.
ArrowDownArrowUpMoves through the countries, wrapping.
PageDownPageUpMoves eight countries at a time.
EnterPicks the highlighted country and moves to the number. Never submits the form.
EscapeCloses the list and returns focus to the country button.
BackspaceIn 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index