Skip to main content
astrocraft-ui/ components · 101

Phone Input

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/phone-input

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/phone-input --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/phone-input --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/phone-input --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/phone-input --theme tailwind --bridge lumos

Live demo

PhoneInput & CurrencyInput

The phone field groups digits the way the chosen country's data says to, and submits E.164 from a hidden input; switch country and the same digits regroup. The money field formats on BLUR, not while you type — a field that regroups mid-keystroke moves the caret out from under you — and reads both 1,234.56 and1.234,56 correctly, whichparseFloat does not.

Phone

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`CountrySelect.astro``country-select``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
——
`PhoneInput.astro``phone-input` `phone-input-field` `phone-input-value``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-value`—

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/phone-input/CountrySelect.astro
---
// src/components/ui/forms/phone-input/CountrySelect.astro — PhoneInput compound part (see ../../README.md).
// The dial-code picker, as a native <select>. Native because a country list is long: a <select> gets
// the platform's own scrolling, type-ahead ("g", "e", "r" jumps to Germany) and, on a phone, the
// OS's full-screen picker — none of which a custom listbox reproduces for free.
//
// THE LIBRARY SHIPS NO COUNTRY DATA, on purpose. A list of 250 countries is data, it is opinionated
// (names, order, which territories appear), it needs translating, and it would be dead weight for
// the consumer who supports three countries. So you pass `countries` — and with each one, optionally,
// the two facts this library will not guess: how to group the digits, and whether there is a trunk
// prefix to drop (see phone.ts on why guessing that would corrupt Italian numbers).
import type { HTMLAttributes } from "astro/types";

interface Country {
  /** ISO code, used as the option's value: `GB`. */
  code: string;
  /** What to show: "United Kingdom (+44)". */
  label: string;
  /** Dial code, digits only or with a `+`: `44`. */
  dial: string;
  /** Grouping for the national number, `#` per digit: `"#### ### ####"`. */
  format?: string;
  /** A national-only prefix to drop when composing E.164: the UK's leading `0`. */
  trunkPrefix?: string;
}

type Props = HTMLAttributes<"select"> & {
  countries: readonly Country[];
  value?: string;
  label?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const {
  countries,
  value,
  label = "Country",
  size = "md",
  state = "default",
  class: className,
  ...rest
} = Astro.props;
---

<select
  aria-label={label}
  data-slot="country-select"
  data-size={size}
  data-state={state}
  class={className}
  {...rest}
>
  {
    countries.map((country) => (
      <option
        value={country.code}
        selected={country.code === value}
        data-dial={country.dial}
        data-format={country.format}
        data-trunk={country.trunkPrefix}
      >
        {country.label}
      </option>
    ))
  }
</select>
src/components/ui/forms/phone-input/PhoneInput.astro
---
// src/components/ui/forms/phone-input/PhoneInput.astro — headless primitive (see ../../README.md).
// A country picker beside a real <input type="tel">, with a hidden input carrying E.164 — the
// `+442071234567` form every telephony API and every database wants, composed from two things a
// user should never have to think about at once.
//
// The visible field shows the national number, grouped as the chosen country says to group it (the
// pattern comes from the country data you pass to CountrySelect — this library ships no phone number
// database; see phone.ts for why, and what it does instead).
//
// Typing is REFORMATTED as it goes, which means moving the caret. The script only rewrites the field
// when the text actually changed and parks the caret at the end, which is right for typing and for
// pasting, and wrong for editing the middle of a number — a caret-preserving reformatter needs to map
// positions through the pattern.
//
// ponytail: no validation of length or plausibility. `<input type="tel">` deliberately has no
// built-in validation either, because there is no pattern that is right everywhere; add `pattern` or
// `minlength` per country if your form needs it.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  /** The national number to start with, as typed. */
  value?: string;
  /** The hidden input's name — the one that carries the E.164 value. */
  name?: string;
  label?: string;
  placeholder?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const {
  value,
  name,
  label = "Phone number",
  placeholder,
  size = "md",
  state = "default",
  class: className,
  ...rest
} = Astro.props;
---

<div data-slot="phone-input">
  <slot />
  <input
    type="tel"
    inputmode="tel"
    autocomplete="tel-national"
    value={value}
    aria-label={label}
    placeholder={placeholder}
    data-slot="phone-input-field"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  {name && <input type="hidden" name={name} data-slot="phone-input-value" />}
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { applyPattern, digitsOf, toE164 } from "./phone";

  function wire(root: HTMLElement) {
    const field = root.querySelector<HTMLInputElement>('[data-slot="phone-input-field"]');
    if (!field) return;
    const select = root.querySelector<HTMLSelectElement>('[data-slot="country-select"]');
    const hidden = root.querySelector<HTMLInputElement>('[data-slot="phone-input-value"]');

    const country = () => select?.selectedOptions[0]?.dataset ?? {};

    const sync = ({ reformat = true } = {}) => {
      const { dial = "", format, trunk } = country();
      if (reformat) {
        const grouped = applyPattern(digitsOf(field.value), format);
        // Only write when it changed: assigning `.value` moves the caret to the end, so doing it on
        // every keystroke regardless would fight the user even when nothing was reformatted.
        if (grouped !== field.value) field.value = grouped;
      }
      const e164 = toE164(dial, field.value, trunk);
      root.dataset.value = e164;
      if (hidden) hidden.value = e164;
    };

    field.addEventListener("input", () => sync());
    // Changing country re-groups the number that is already there — the same digits, the new shape.
    select?.addEventListener("change", () => sync());
    sync({ reformat: Boolean(field.value) });
  }

  onReadyOnce('[data-slot="phone-input"]', wire);
</script>
src/components/ui/forms/phone-input/index.ts
import CountrySelect from "./CountrySelect.astro";
import PhoneInput from "./PhoneInput.astro";

export { CountrySelect, PhoneInput };
export default PhoneInput;
src/components/ui/forms/phone-input/phone.ts
// src/components/ui/forms/phone-input/phone.ts — digits in, E.164 out, plus the display grouping. Pure, so
// it is checkable without a DOM (see phone.test.ts).
//
// WHAT THIS DELIBERATELY IS NOT: a phone number library. Knowing that a London number is
// `020 7123 4567` but a Milan one keeps its leading zero takes a data table, and the smallest honest
// one (libphonenumber) is bigger than this entire library. So the knowledge is DATA the consumer
// supplies per country — a grouping pattern, and whether there is a trunk prefix to drop — and this
// module applies it. Supply nothing and nothing is guessed: the digits are passed through, and the
// submitted value is the dial code followed by what was typed.

/** Every digit in `text`, in order, and nothing else. */
export function digitsOf(text: string): string {
  return text.replace(/\D/g, "");
}

/**
 * Lay `digits` out along a grouping pattern, `#` standing for a digit — `"### ### ####"`.
 *
 * Literals appear only once there is a digit to follow them, so the field never shows a dangling
 * space or bracket ahead of the typing. With no pattern the digits come back untouched.
 *
 * @example applyPattern("2071234", "### ### ####") // => "207 123 4"
 */
export function applyPattern(digits: string, pattern?: string | null): string {
  if (!pattern) return digits;
  let out = "";
  let i = 0;
  for (const char of pattern) {
    if (i >= digits.length) break;
    if (char === "#") {
      out += digits[i];
      i += 1;
    } else {
      out += char;
    }
  }
  // More digits than the pattern accounts for are kept rather than swallowed: a pattern that is
  // wrong for this number must not silently truncate someone's phone number.
  return out + digits.slice(i);
}

/**
 * The value a form should submit: `+` then the dial code then the national digits, with no spaces —
 * which is what E.164 is, and what every telephony API expects.
 *
 * @param trunkPrefix - a national-only prefix to drop (the UK's leading `0`). Supplied per country;
 *   never guessed, because Italy keeps its leading zero and a guess would corrupt the number.
 * @example toE164("44", "020 7123 4567", "0") // => "+442071234567"
 */
export function toE164(dial: string, national: string, trunkPrefix?: string | null): string {
  const code = digitsOf(dial);
  let digits = digitsOf(national);
  if (trunkPrefix && digits.startsWith(trunkPrefix)) digits = digits.slice(trunkPrefix.length);
  return digits ? `+${code}${digits}` : "";
}

What you get

The component source, copied into your project by npx astrocraft-ui add forms/phone-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.