Skip to content

Input and picker sync

A phone input and a region picker are two views of one region. This guide wires them so a picker selection drives the input, and a region the input auto-detects from the digits drives the picker. Both are headless controllers, so the wiring is two subscriptions and one call each way. Read Region picker and the input tutorial first if either controller is new.

The picker and the input each own a region and expose one lever to set it:

Direction Trigger Call
Picker to input user selects a region phoneInput.setRegion(region)
Input to picker input resolves a region from digits read state.region, set picker’s selected value

phoneInput.setRegion(region) switches the active region and re-resolves the current digits under its rules. It pushes a new history entry, so it is undoable. state.region is the region the input resolved for the digits currently typed, or null when nothing has resolved yet.

When the user picks a region, call setRegion. The input re-formats and re-validates whatever is already typed against the new region and emits one state.

regionSelect.addEventListener('change', () => {
phoneInput.setRegion(regionSelect.value as RegionCode);
});

setRegion always resolves to the region you pass; the picker is the source of truth for that call. It does not read the picker’s options list, so a region absent from a filtered picker can still be set if you pass it. Keep the value you send inside the picker’s filter to avoid a selection the picker cannot display.

In international mode the input reads the calling code from the digits and resolves the region on its own. Typing +44 20 7946 0958 resolves state.region to 'GB'; typing +380 67 123 4567 resolves it to 'UA'. When that happens you have not touched the picker, so reflect the new region back onto it in the input’s subscription:

phoneInput.subscribe((state) => {
if (state.region !== null && state.region !== regionSelect.value) {
regionSelect.value = state.region;
}
});

Guard against a feedback loop: only assign when the value actually differs. Setting a <select>’s value does not fire change, so this assignment does not re-enter the picker to input path; if your picker emits on programmatic selection, apply the same “skip when unchanged” guard there.

state.region is null before any calling code completes and whenever the digits do not resolve under the active filters. Leave the picker on its last value in that case rather than clearing it, so a half-typed number does not blank the selection.

strict mode changes what the input reports as its region, and that changes what the picker sees. Without strict, the input resolves digits to their actual region: a Canadian number typed into a US-defaulted input reports state.region === 'CA', and the input-to-picker subscription moves the picker to Canada. With strict: true, the input keeps its configured region for numbers that share a calling code: the same Canadian number reports state.region === 'US', and the picker stays on the United States. Under strict, that Canadian number is also invalid for the United States, so state.validationError is { kind: 'PATTERN_MISMATCH' }.

Decide which you want before wiring the input-to-picker direction. If the picker is the user’s declared region and must not drift, use strict and let the picker stay put. If the input should follow wherever the digits actually belong, leave strict off. See Strict mode.

One international input with a synced picker. The picker offers the regions the input accepts by sharing the same regionFilter, and each option renders its flag through regionToFlagEmoji.

phone-field.ts
import { ensureEngineReady, type RegionCode } from '@telixon/core';
import { createPhoneInput, createRegionList, regionToFlagEmoji } from '@telixon/web-sdk';
await ensureEngineReady();
const inputElement = document.querySelector<HTMLInputElement>('#phone')!;
const regionSelect = document.querySelector<HTMLSelectElement>('#region')!;
const allowedRegions: readonly RegionCode[] = ['US', 'CA', 'GB', 'UA'];
const phoneInput = createPhoneInput({
mode: 'international',
input: inputElement,
regionFilter: allowedRegions,
display: { callingCodeInInput: true, plusPrefix: 'fixed' },
});
const regionList = createRegionList({
regionFilter: allowedRegions,
sort: 'alphabetical',
});
// Render and re-render the picker from region-list state.
function renderPicker(options: readonly { region: RegionCode; displayName: string; callingCode: string }[]): void {
regionSelect.replaceChildren(
...options.map((option) => {
const element = document.createElement('option');
element.value = option.region;
element.textContent = `${regionToFlagEmoji(option.region)} ${option.displayName} +${option.callingCode}`;
return element;
}),
);
}
renderPicker(regionList.getState().options);
regionList.subscribe((state) => renderPicker(state.options));
// Picker to input.
regionSelect.addEventListener('change', () => {
phoneInput.setRegion(regionSelect.value as RegionCode);
});
// Input to picker: follow an auto-detected region without re-entering the change handler.
phoneInput.subscribe((state) => {
if (state.region !== null && state.region !== regionSelect.value) {
regionSelect.value = state.region;
}
});

Typing +380 67 123 4567 resolves state.region to 'UA' and moves the picker to Ukraine; choosing United Kingdom in the picker calls setRegion('GB') and re-resolves the digits under United Kingdom rules. Both controllers stay in agreement in either direction.

Call phoneInput.destroy() and regionList.destroy() when the field unmounts. destroy on the input detaches its DOM listeners; on the region list it clears subscribers. Both are idempotent.