Build the complete field
This guide wires the phone field and the region picker into one widget, where the picker supplies the calling code and the input holds the rest. The flag sprite draws the flags.
Live demo
Section titled “Live demo”Open the picker, search canada, and pick it. The button shows the Canadian flag and +1. The
placeholder switches to a Canadian example number. Type 6045550132 and the field shows
604-555-0132 with the status Valid: +16045550132. Reopen the picker and choose United States.
604 still belongs to Canada, which flips the flag straight back.
Undo reaches the picker as well. Cmd/Ctrl+Z steps back through the digits, then through the region change itself, carrying the flag and the calling code with it. Cmd/Ctrl+Shift+Z replays the whole sequence.
Search united k and press Enter, or walk the rows with the arrow keys. Select the United Kingdom
and type 07400123456. The leading zero is the UK trunk prefix and does not belong after +44.
The status reads NATIONAL_PREFIX_PRESENT.
The code
Section titled “The code”The demo and the source are the same three files. The markup is the region button with a flag cell, the input, a popup with the search box and the list, and a status line.
The script creates three widgets. createRegionList
builds the rows with US, CA, and GB pinned on top.
createPhoneInput binds the input with
callingCodeInInput: false,
which keeps the calling code out of the field and leaves it to the selected region.
createRegionPicker takes both. From then on the
phone owns the region. A pick calls the phone’s setRegion. The
picker follows the phone’s resolved region back and
moves the flag when the digits land in another region on the same calling code.
The attachRegionPicker call matches
Build a region picker, except that renderTrigger writes the
calling code next to the flag and returnFocusTo sends focus to the input after a pick.
The demo positions the popup itself. positionPopup flips it above the field when there is no
room below. renderStatus draws the placeholder and the status from each emitted
state.
When a framework component unmounts, call destroy on the
attachment and on each widget.
<div id="complete-field" class="phone-field"> <label for="phone-field-input">Phone number</label>
<div class="phone-field-control"> <button class="phone-field-region" aria-label="Select region"> <span class="tlx-flag" aria-hidden="true"><span class="tlx-flag__image"></span></span> <span class="phone-field-code"></span> <svg class="phone-field-chevron" viewBox="0 0 10 6" aria-hidden="true"> <path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" /> </svg> </button> <input id="phone-field-input" type="tel" /> </div>
<div class="phone-field-menu" hidden> <input class="phone-field-search" type="text" aria-label="Search regions" placeholder="Search regions" /> <ul aria-label="Regions"></ul> </div>
<p class="phone-field-status"></p></div>#complete-field { --surface: #ffffff; --text: #1c1f1e; --muted: #66716d; --border: #c9d1ce; --accent: #26997b; --valid: #1d7a5f; --error: #b3384f;
position: relative; color: var(--text);}
#complete-field label { display: block; margin-bottom: 0.375rem; font-size: 0.875rem; font-weight: 600;}
#complete-field .phone-field-control { display: flex; align-items: stretch; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--surface);}
#complete-field .phone-field-control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent);}
#complete-field .phone-field-control input { flex: 1; padding: 0.5rem 0.75rem; border: none; background: transparent; color: inherit; font-size: 1rem; font-variant-numeric: tabular-nums; outline: none;}
#complete-field input::placeholder { color: var(--muted);}
#complete-field .phone-field-region { display: flex; align-items: center; gap: 0.375rem; padding: 0 0.75rem; border: none; border-right: 1px solid var(--border); border-radius: 0.375rem 0 0 0.375rem; background: transparent; color: inherit; font-size: 0.875rem; cursor: pointer;}
#complete-field .phone-field-region:hover { background: color-mix(in srgb, var(--text) 5%, transparent);}
#complete-field .tlx-flag { width: 1.25rem; border-radius: 2px;}
#complete-field .phone-field-code { color: var(--muted); font-variant-numeric: tabular-nums;}
#complete-field .phone-field-chevron { width: 0.625rem; height: 0.375rem; color: var(--muted);}
@media (prefers-reduced-motion: no-preference) { #complete-field .phone-field-chevron { transition: transform 150ms ease; }}
#complete-field .phone-field-region[aria-expanded='true'] .phone-field-chevron { transform: rotate(180deg);}
#complete-field .phone-field-menu { position: absolute; z-index: 10; margin-top: 0.375rem; width: min(24rem, 100%); padding: 0.5rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--surface); box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 8px 24px rgb(0 0 0 / 0.1);}
#complete-field .phone-field-menu input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--surface); color: inherit; font-size: 0.875rem;}
#complete-field .phone-field-menu input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 1px var(--accent);}
#complete-field ul { margin: 0.5rem 0 0; padding: 0; list-style: none; max-height: 15rem; overflow-y: auto; scrollbar-width: thin;}
#complete-field li { display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: 0.375rem; font-size: 0.875rem; cursor: pointer;}
#complete-field li[data-active='true'] { background: rgb(38 153 123 / 0.08);}
#complete-field li[aria-selected='true'] { background: rgb(38 153 123 / 0.14);}
#complete-field li .phone-field-option-code { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums;}
#complete-field li.phone-field-empty { justify-content: center; color: var(--muted); cursor: default; pointer-events: none;}
#complete-field .phone-field-status { margin: 0.5rem 0 0; min-height: 1.25rem; font-size: 0.875rem; color: var(--muted);}
#complete-field .phone-field-status[data-tone='valid'] { color: var(--valid);}
#complete-field .phone-field-status[data-tone='error'] { color: var(--error);}import { ensureEngineReady } from '@telixon/core';import { attachRegionPicker, createPhoneInput, createRegionList, createRegionPicker } from '@telixon/web-sdk';import { flagTransform } from '@telixon/web-sdk/flags';
await ensureEngineReady();
// The field holds the region button with its flag and calling code, the input, and the status line.const root = document.querySelector('#complete-field');const control = root.querySelector('.phone-field-control');const trigger = root.querySelector('.phone-field-region');const flag = trigger.querySelector('.tlx-flag__image');const code = trigger.querySelector('.phone-field-code');const input = root.querySelector('#phone-field-input');const status = root.querySelector('.phone-field-status');
// The popup holds the search box and the list.const popup = root.querySelector('.phone-field-menu');const search = root.querySelector('.phone-field-search');const listbox = root.querySelector('ul');
// Three headless widgets. The phone owns the region, which the picker follows.const regions = createRegionList({ prioritize: ['US', 'CA', 'GB'] });
const phone = createPhoneInput({ mode: 'international', defaultRegion: 'US', display: { callingCodeInInput: false }, input,});
const picker = createRegionPicker({ regions, phone });
// One sprite cell. The transform brings the region's flag into view.function flagCell(region) { const cell = document.createElement('span'); cell.className = 'tlx-flag'; cell.setAttribute('aria-hidden', 'true'); const image = document.createElement('span'); image.className = 'tlx-flag__image'; image.style.transform = flagTransform(region); cell.append(image); return cell;}
// A row is a flag, a name, and a calling code. The adapter adds the option attributes.function renderOption(option) { const row = document.createElement('li'); const name = document.createElement('span'); name.textContent = option.displayName; const rowCode = document.createElement('span'); rowCode.className = 'phone-field-option-code'; rowCode.textContent = '+' + option.callingCode; row.append(flagCell(option.region), name, rowCode); return row;}
// Shown when the search matches nothing.function renderEmpty() { const row = document.createElement('li'); row.className = 'phone-field-empty'; row.textContent = 'No matches'; return row;}
// The button shows the selected region. An empty selection shows the neutral cell.function renderTrigger(selected) { flag.style.transform = flagTransform(selected === null ? null : selected.region); code.textContent = selected === null ? '' : '+' + selected.callingCode;}
// The adapter wires the DOM and hands focus back to the input after a pick.attachRegionPicker({ picker, trigger, popup, search, listbox, renderOption, renderEmpty, renderTrigger, returnFocusTo: input,});
// Flip the popup above the field when the viewport below cannot fit it. The adapter subscribed// first, which leaves the popup visible and measurable here.function positionPopup() { popup.style.bottom = ''; const controlRect = control.getBoundingClientRect(); const needed = popup.offsetHeight + 8; if (window.innerHeight - controlRect.bottom < needed && controlRect.top > needed) { popup.style.bottom = `${root.offsetHeight - control.offsetTop + 6}px`; }}
// Once per opening. Later emits while open, such as typing in the search box, leave the position alone.let wasOpen = false;picker.subscribe((state) => { if (state.open && !wasOpen) positionPopup(); wasOpen = state.open;});
// The placeholder and the status line follow the phone.function renderStatus(state) { input.placeholder = state.placeholder ?? '';
if (state.value === '') { status.textContent = 'Type a number.'; status.dataset.tone = ''; return; }
if (state.validationError === null) { status.textContent = 'Valid: ' + phone.getPhoneNumber().formatE164(); status.dataset.tone = 'valid'; return; }
status.textContent = state.validationError.kind; status.dataset.tone = 'error';}
phone.subscribe(renderStatus);
// The subscription fires only on later changes; render the initial state manually.renderStatus(phone.getState());