Skip to content
design
Components

Phone input

A phone number as a country with its calling code and the national number, stored in E.164 form.

On this page
Paste +31 6 12345678 to see the country follow.
The page receives: no number yet
A crew member's phone starting in the vessel's country, with a searchable country list, the number grouped as it is typed and the E.164 value the page receives, beside an emergency contact's number.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A crew member's phone, required, starting in the vessel's country: the country button opens a list searched by " +
  "name or calling code, the number groups as it is typed, a pasted international number brings its country, and " +
  "the page receives E.164; beside it an emergency contact's French number.";

export default function Showcase(): ReactElement {
  const [phone, setPhone]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  return (
    <div style={{ width: "100%", maxWidth: 420, display: "grid", gap: "var(--space-lg)" }}>
      <PhoneInput
        label="Phone"
        required
        defaultCountry="GB"
        value={phone}
        onValueChange={setPhone}
        hint="Paste +31 6 12345678 to see the country follow."
      />
      <PhoneInput label="Emergency phone" defaultValue="+33639981234" />
      <output style={{ fontSize: 12, color: "var(--text-dim)" }}>
        The page receives: {phone === "" ? "no number yet" : phone}
      </output>
    </div>
  );
}

A phone input is a country button and the national number. The button shows the country's code and calling code (GB +44) and opens a list of every country, named in the crew's language and searched by name, code or calling code. The field starts in defaultCountry, which the page sets to the vessel's country. The national digits are grouped as they are typed the way the country writes them after its calling code (7345 615225 in the United Kingdom, 20 7946 0958 in London, 6 39 98 12 34 in France), from the formats of Google's libphonenumber; a country it has no format for shows the digits as typed. The grouping is for reading only, and the value is the digits.

The page receives the number in E.164 form (+447700900123). The national number loses the trunk prefix dialed only inside the country, by libphonenumber's rules for it: 0 in the United Kingdom, 1 in North America, 8 in Russia, 06 in Hungary. A country without one keeps its leading 0, which is part of the number (Italy, Benin), and a prefix that is part of the number stays (a Russian 800 number typed without its 8). A number typed or pasted in international form (+31 6 12345678, 0044 7700 900123) sets the country, as soon as its calling code is complete. The field refuses what bosund's E.164 pattern refuses: more than 15 digits with the calling code.

A whole number is checked against libphonenumber's number patterns, as its isValidNumber and getNumberType check it. Under the field it names the number's kind and the country it belongs to (Mobile, United Kingdom, Landline, Guernsey for a +44 number in Guernsey's ranges), telling apart landlines, mobiles, toll-free, premium-rate, shared-cost, VoIP, personal, pager, business and voicemail numbers where the country's plan does. A number no country with its calling code has is doubted once the crew leave the field, by its cause: too few digits (or only enough to dial locally), too many, a length no number has, or no number starting this way. The doubt never makes the field invalid, and the field reports the number as typed: the patterns age, and a range opened after the build is a real number. The patterns load in a chunk of their own once the field holds a number.

The number input takes digits only. Text with a letter in it is kept as typed and not read: the field says letters and what follows them are not saved, so an extension typed after a number ("ext 1") never joins its digits.

Extension

A number that reaches a switchboard (a marina office, an agent, a yard) can have an extension. E.164 has none, so showExtension adds an input for it after the number, marked with the crew's word for it (ext., Dw., poste, int., tst.), with autocomplete="tel-extension" so a browser fills it from the contact. It takes digits, is reported apart from the number through onExtensionChange and sent with a form under extensionName. A letter typed after the number moves the focus to it, so "07700 900123 ext 12" fills both. A crew member's mobile has no extension, and a form that does not ask for one leaves showExtension off.

A marina office's switchboard number with its extension, which goes in an input of its own after the number.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "A marina office's switchboard number with its extension, which goes in an input of its own after the number.";

export default function Extension(): ReactElement {
  return <PhoneInput label="Marina office" defaultValue="+442079460123" showExtension defaultExtension="214" />;
}

Examples

Empty

An empty number in the vessel's country, which the page passes as `defaultCountry`; a phone shows its dial pad.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "An empty number in the vessel's country, which the page passes as `defaultCountry`; a phone shows its dial pad.";

export default function Empty(): ReactElement {
  return <PhoneInput label="Phone" defaultCountry="NL" />;
}

Filled

A held E.164 number shows in its own country, its national digits grouped for reading.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "A held E.164 number shows in its own country, its national digits grouped for reading.";

export default function Filled(): ReactElement {
  return <PhoneInput label="Phone" defaultValue="+447700900123" />;
}

Numbers as written at home

GB07700 9001237700 900123+447700900123
NL06-123456786 12345678+31612345678
FR06 39 98 12 346 39 98 12 34+33639981234
US(202) 555-0123202-555-0123+12025550123
IT06 1234 567806 1234 5678+390612345678
Numbers as the crew write them at home, and the E.164 form the field reports: the trunk 0 goes, except in Italy, where it is part of the number.
Code
import { formatNational, nationalDigits, toE164 } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "Numbers as the crew write them at home, and the E.164 form the field reports: the trunk 0 goes, except in Italy, " +
  "where it is part of the number.";

interface Written {
  readonly country: string;
  readonly text: string;
}

const NUMBERS: readonly Written[] = [
  { country: "GB", text: "07700 900123" },
  { country: "NL", text: "06-12345678" },
  { country: "FR", text: "06 39 98 12 34" },
  { country: "US", text: "(202) 555-0123" },
  { country: "IT", text: "06 1234 5678" },
];

export default function NationalForms(): ReactElement {
  return (
    <table style={{ fontFamily: "var(--font-mono)", fontSize: 13, borderCollapse: "collapse" }}>
      <tbody>
        {NUMBERS.map((number: Written): ReactElement => {
          const digits: string = nationalDigits(number.text, number.country);
          return (
            <tr key={number.country}>
              <td style={{ padding: "var(--space-xs) var(--space-md)", color: "var(--text-dim)" }}>{number.country}</td>
              <td style={{ padding: "var(--space-xs) var(--space-md)" }}>{number.text}</td>
              <td style={{ padding: "var(--space-xs) var(--space-md)" }}>{formatNational(digits, number.country)}</td>
              <td style={{ padding: "var(--space-xs) var(--space-md)" }}>{toE164(number.country, digits)}</td>
            </tr>
          );
        })}
      </tbody>
    </table>
  );
}

A number's kind

A whole number names its kind and the country it belongs to under the field, here a Dutch mobile.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "A whole number names its kind and the country it belongs to under the field, here a Dutch mobile.";

export default function Kind(): ReactElement {
  return <PhoneInput label="Phone" defaultValue="+31612345678" />;
}

Invalid

An E.164 number has at most 15 digits with its calling code; past that the field says how many it has.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "An E.164 number has at most 15 digits with its calling code; past that the field says how many it has.";

export default function Invalid(): ReactElement {
  return (
    <PhoneInput
      label="Phone"
      defaultValue="+4477009001234567"
      error="A phone number has at most 15 digits with the country code; this one has 16. Check the number."
    />
  );
}

Read-only

A read-only number: the country button does not open.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string = "A read-only number: the country button does not open.";

export default function ReadOnly(): ReactElement {
  return <PhoneInput label="Marina" defaultValue="+351912345678" readOnly />;
}

Disabled

A disabled phone field.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string = "A disabled phone field.";

export default function Disabled(): ReactElement {
  return <PhoneInput label="Phone" defaultValue="+31612345678" disabled />;
}

Inside a form field

Inside a form field, named by `aria-label`.
Code
import { FormField } from "@bosunkit/components/form-field";
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string = "Inside a form field, named by `aria-label`.";

export default function InFormField(): ReactElement {
  return (
    <FormField label="Shore contact">
      <PhoneInput aria-label="Shore contact phone" defaultCountry="DE" />
    </FormField>
  );
}

Paste

The paste button, at the end of the field, sets the country and the number. It reads:

  • a tel: link, its ;ext= as the extension and its other parameters left out;
  • a number in international form, with + or 00, and the (0) some write after the calling code;
  • a national number with a label or other text around it, in the field's country, never a date, a time or a vessel's MMSI or IMO number beside it;
  • an extension after the number, as each of the six languages marks one: ext, x, extensión, Durchwahl, poste, interno, toestel.

With showExtension a pasted extension fills its input; without it, the field takes the number and its status line says the extension is not saved.

An international number brings its country; among countries sharing a calling code, the field's own wins. The paste button shows only when the clipboard holds something the field can take, named by what it would paste (Paste 235 012 345). To know that, the field reads the clipboard when it takes focus and when the window comes back, and only where the browser lets the page read it without asking: the clipboard-read permission is granted. Where the browser would ask first, or does not know the permission (Firefox, Safari), the field reads nothing and shows no button, so no prompt appears on focus; Ctrl+V and a long press on a phone paste through the same reader. A read-only or disabled field shows no paste button.

  • tel:+31612345678
  • 0044 (0)7700 900123
  • Mobile: 07700 900123
  • tel:+33-6-39-98-12-34;ext=2
  • Hello
Copy one of these lines, then focus the field: where the browser lets the page read the clipboard, a paste button named by the number appears; Ctrl+V works everywhere. An international number brings its country, and a national one stays in the field's country.
Code
import { PhoneInput } from "@bosunkit/components/phone-input";
import type { ReactElement } from "react";

export const description: string =
  "Copy one of these lines, then focus the field: where the browser lets the page read the clipboard, a paste button named by the number appears; Ctrl+V works everywhere. An international number brings its country, and a national one stays in the field's country.";

const SAMPLES: readonly string[] = [
  "tel:+31612345678",
  "0044 (0)7700 900123",
  "Mobile: 07700 900123",
  "tel:+33-6-39-98-12-34;ext=2",
  "Hello",
];

export default function Paste(): ReactElement {
  return (
    <div style={{ width: "100%", maxWidth: 520, display: "grid", gap: "var(--space-md)" }}>
      <PhoneInput label="Phone" defaultCountry="GB" />
      <ul style={{ margin: 0, paddingLeft: "var(--space-lg)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
        {SAMPLES.map((sample: string): ReactElement => (
          <li key={sample} style={{ userSelect: "all", overflowWrap: "anywhere" }}>
            {sample}
          </li>
        ))}
      </ul>
    </div>
  );
}

Props

PropTypeDefaultDescription
labelstring | undefined–The field's name on the outline, translated by the caller; omit it inside a FormField.
valuestring | undefined–The number in E.164 form (+447700900123), "" for none.
defaultValuestring | undefined–The number it starts with when the caller does not hold value.
onValueChange((value: string) => void) | undefined–Called with the number in E.164 form as the crew type, "" while the national number is empty.
defaultCountrystring | undefined"GB"The country the field starts in, ISO 3166-1 alpha-2: the vessel's flag or home port, which the caller knows.
hintstring | undefined–Text under the field, translated by the caller.
errorstring | undefined–The caller's own error, shown in place of the field's.
requiredboolean | undefined–Whether the field must hold a number.
disabledboolean | undefined–Whether the field takes no input.
readOnlyboolean | undefined–Whether the field shows its number without letting the crew change it.
idstring | undefined–The national number input's id, for a Label's htmlFor.
namestring | undefined–The form field name; the form receives the number in E.164 form.
showExtensionboolean | undefinedfalseWhether the field has an extension input after the number, for a number that reaches a switchboard (a marina office, an agent, a yard). E.164 has no extension, so it is reported apart from the number; off by default.
extensionstring | undefined–The extension's digits, "" for none, with showExtension.
defaultExtensionstring | undefined–The extension it starts with when the caller does not hold extension.
onExtensionChange((extension: string) => void) | undefined–Called with the extension's digits as the crew type or paste them.
extensionNamestring | undefined–The form field name the extension's digits are sent under.
placeholderstring | undefined–Text shown while the national number is empty and focused, translated by the caller.
aria-labelstring | undefined–Names the number input when there is no label.
classNamestring | undefined–Classes added to the field's root.
testIdstring | undefined–data-testid on the field.
refRef<HTMLInputElement> | undefined–The national number input.
motionMotionProp | undefined–false opens the country list at once and keeps the error line still; true also moves the error line.
  • Outlined field: the box every maritime input draws in.
  • Email input: the other contact field of a crew member.
  • Crew picker: a crew member's details, phone included, filled from a saved record.