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.
Live demo
Section titled “Live demo”Open the picker, search canada, and pick it. The button shows the Canadian flag on +1, with the
empty field’s placeholder now a Canadian example. 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.
The keyboard works throughout. 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 a trunk prefix with no
place 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, the input, and a
popup with the search box and the list.
callingCodeInInput: false
keeps the calling code out of the input, leaving the selected region to supply it.
The wiring runs both ways. A click on a row calls select, which updates the button, hands the
region to setRegion, closes the popup, and returns focus to
the input. Coming back, the subscriber follows the resolved
state.region, moving the flag when the digits land in
another region on the same calling code.
The search box doubles as the keyboard cursor, where the arrow keys walk the rows through
aria-activedescendant and Enter picks the active one. Escape closes the popup and hands focus
back to the region button, while an outside press or focus moving out of the widget closes it too.
Inside a framework component, call each one’s destroy on
unmount and remove the document-level press listener with them.
<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" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="phone-field-listbox" aria-label="Select region" > <span class="phone-field-flag"></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" role="combobox" aria-expanded="true" aria-controls="phone-field-listbox" aria-autocomplete="list" aria-label="Search regions" placeholder="Search regions" /> <ul id="phone-field-listbox" role="listbox" 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 .phone-field-flag { font-size: 1.125rem; line-height: 1;}
#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:hover,#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, getCallingCodeForRegion } from '@telixon/core';import { createPhoneInput, createRegionList, regionToFlagEmoji } from '@telixon/web-sdk';
await ensureEngineReady();
// Elements of the field and its popup.const root = document.querySelector('#complete-field');const trigger = root.querySelector('.phone-field-region');const flag = root.querySelector('.phone-field-flag');const code = root.querySelector('.phone-field-code');const input = root.querySelector('#phone-field-input');const status = root.querySelector('.phone-field-status');const popup = root.querySelector('.phone-field-menu');const control = root.querySelector('.phone-field-control');const search = root.querySelector('.phone-field-search');const list = root.querySelector('ul');
// The picker and the phone are independent machines.const regions = createRegionList({ prioritize: ['US', 'CA', 'GB'], dataFactory: ({ region }) => regionToFlagEmoji(region),});
const phone = createPhoneInput({ mode: 'international', defaultRegion: 'US', display: { callingCodeInInput: false }, input,});
// The selected region in one shared shape.function regionOption(region) { return { region, callingCode: getCallingCodeForRegion(region), data: regionToFlagEmoji(region) };}
let selected = regionOption('US');
function renderTrigger() { flag.textContent = selected.data; code.textContent = '+' + selected.callingCode;}
// Shown when the search matches nothing.const emptyRow = document.createElement('li');emptyRow.className = 'phone-field-empty';emptyRow.textContent = 'No matches';
// Full rebuild when the search changes the option set.function renderList(state) { if (state.options.length === 0) { list.replaceChildren(emptyRow); return; }
list.replaceChildren( ...state.options.map((option) => { const row = document.createElement('li'); row.id = 'phone-field-option-' + option.region; row.dataset.region = option.region; row.setAttribute('role', 'option'); row.setAttribute('aria-selected', String(option.region === selected.region));
const rowFlag = document.createElement('span'); rowFlag.textContent = option.data; const rowName = document.createElement('span'); rowName.textContent = option.displayName; const rowCode = document.createElement('span'); rowCode.className = 'phone-field-option-code'; rowCode.textContent = '+' + option.callingCode;
row.append(rowFlag, rowName, rowCode); return row; }), );}
// Move the highlight without rebuilding the list.function markSelected(region) { const previous = list.querySelector('[aria-selected="true"]'); if (previous) previous.setAttribute('aria-selected', 'false'); const next = list.querySelector(`[data-region="${region}"]`); if (next) next.setAttribute('aria-selected', 'true');}
// The keyboard cursor. Focus stays in the search while aria-activedescendant names the active row.let activeRegion = null;
function setActive(region) { const previous = list.querySelector('[data-active="true"]'); if (previous) previous.removeAttribute('data-active'); search.removeAttribute('aria-activedescendant'); activeRegion = null;
const next = region === null ? null : list.querySelector(`[data-region="${region}"]`); if (next === null) return; activeRegion = region; next.setAttribute('data-active', 'true'); search.setAttribute('aria-activedescendant', next.id); // Scroll the list only; scrollIntoView would drag every scrollable ancestor, the page included. const rowRect = next.getBoundingClientRect(); const listRect = list.getBoundingClientRect(); if (rowRect.top < listRect.top) list.scrollTop += rowRect.top - listRect.top; else if (rowRect.bottom > listRect.bottom) list.scrollTop += rowRect.bottom - listRect.bottom;}
// Wraps past either end; without a cursor, Down starts at the first row and Up at the last.function moveActive(step) { const rows = [...list.children]; if (rows.length === 0) return; const index = rows.findIndex((row) => row.dataset.region === activeRegion); const nextIndex = index === -1 ? (step > 0 ? 0 : rows.length - 1) : (index + step + rows.length) % rows.length; setActive(rows[nextIndex].dataset.region);}
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';}
// Reflect the chosen region in the button and the list.function applySelection(region) { selected = regionOption(region); renderTrigger(); markSelected(region);}
// Under a shared calling code the digits decide the region; follow it.function followResolvedRegion(region) { if (region === null || region === selected.region) return; applySelection(region);}
// Flip the popup above the field when the viewport below cannot fit it.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`; }}
function openPopup() { popup.hidden = false; trigger.setAttribute('aria-expanded', 'true'); search.value = ''; regions.search(''); list.scrollTop = 0; positionPopup(); setActive(selected.region); search.focus({ preventScroll: true });}
function closePopup() { popup.hidden = true; trigger.setAttribute('aria-expanded', 'false'); setActive(null);}
// The only bridge from the picker to the phone.function select(region) { applySelection(region); phone.setRegion(region); closePopup(); input.focus();}
trigger.addEventListener('click', () => (popup.hidden ? openPopup() : closePopup()));search.addEventListener('input', () => regions.search(search.value));search.addEventListener('keydown', (event) => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); moveActive(event.key === 'ArrowDown' ? 1 : -1); return; } if (event.key === 'Enter' && activeRegion !== null) { event.preventDefault(); select(activeRegion); }});list.addEventListener('click', (event) => { const row = event.target.closest('li'); if (row) select(row.dataset.region);});root.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !popup.hidden) { closePopup(); trigger.focus(); }});// Dismiss on the press itself. Releasing a text-selection drag outside then leaves the popup open.// In a framework component, remove this document listener on unmount, next to each machine's destroy.function dismissOnOutsidePress(event) { if (!popup.hidden && !root.contains(event.target)) closePopup();}document.addEventListener('pointerdown', dismissOnOutsidePress);// Tabbing out of the widget closes the popup; a null relatedTarget means a click, which the handlers above own.root.addEventListener('focusout', (event) => { if (!popup.hidden && event.relatedTarget && !root.contains(event.relatedTarget)) closePopup();});
// A new option set moves the cursor to the first row, which lets Enter pick the top match.regions.subscribe((state) => { renderList(state); setActive(state.options.length > 0 ? state.options[0].region : null);});phone.subscribe((state) => { followResolvedRegion(state.region); renderStatus(state);});
// The subscriptions fire only on later changes; render the initial state manually.renderTrigger();renderList(regions.getState());renderStatus(phone.getState());PhoneInput and RegionList never learn about each other. All the wiring lives in the demo’s own functions.