Forms

PhoneInput

A country code select and a number box, sent together in international form (+351912345678) with the country's ISO code.

#forms#input#select#user#interactive

Demo

Preview

Pick the country, then type the national number.

No number yet.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/phone_input.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/phone_input.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/phone_input.qwe
component PhoneInputDemoLive() {    state phone = null    <div.demo-stack>        <PhoneInput name="pi-demo-mobile" label="Mobile" country="PT" countries={["PT", "ES", "FR", "GB", "US"]}                    on_change={(p) => { phone = p }}/>        <p.demo-note>{phone == null || phone.e164 == "" ? "No number yet." : `Will call ${phone.e164} (${phone.country}).`}</p>    </div>}view PhoneInputDemo() {    <div.demo-stack>        <PhoneInput name="pi-demo-phone" label="Phone" value="+44 20 7946 0958" hint="Pick the country, then type the national number."/>        <PhoneInputDemoLive/>        <div.demo-row>            <PhoneInput name="pi-demo-sm" label="Small" country="JP" size="sm"/>            <PhoneInput name="pi-demo-bad" label="Phone" country="US" value="555" error="That number is too short."/>        </div>    </div>}

Source

ui/phone_input.qwe
// PhoneInput: a phone number with its country: a select of countries (flag and// dialling code) and a box for the national number. The whole number goes in a// hidden input named `name` in international form ("+351912345678"), and the// country's ISO code in `name`_country, so a plain <form> sends both.////   <PhoneInput name="phone" label="Phone" country="PT"/>//   <PhoneInput name="mobile" label="Mobile" value="+44 20 7946 0958" countries={["GB", "IE", "US"]}//               on_change={(p) => { phone = p.e164 }}/>//// value: a number to start with; with its + and code, the country is picked from it.// country: the country picked to start with, as ISO 3166 ("US"). countries: the ISO// codes offered, in that order (a list of 48 common ones by default).// placeholder: the box's ("Number"). name ("phone"), label, hint, error, required,// disabled, field (the box's id, "phone-input-" + name by default) and size ("sm"// or "md") as for every field. The box takes digits, spaces, dashes and brackets;// the hidden input gets the digits only.// on_change(phone): inside one of your components, given { e164, country,// national } as it is typed: the whole number ("" when empty), the ISO code and the// national digits.import ui/theme.qwe use ui_clslet phone_input_css = css`.ui-phone-input { display: grid; gap: 6px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-phone-input-label { font-size: 13px; font-weight: 500; }.ui-phone-input-req { margin-left: 2px; color: var(--ui-danger); }.ui-phone-input-box {  display: flex; align-items: stretch; width: 300px; max-width: 100%; height: 38px;  border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface);  transition: border-color .15s, box-shadow .15s;}.ui-phone-input-box:hover { border-color: var(--ui-strong); }.ui-phone-input-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-phone-input-country { position: relative; flex: none; display: flex; align-items: center; border-right: 1px solid var(--ui-border); }.ui-phone-input-country select {  appearance: none; height: 100%; margin: 0; padding: 0 24px 0 10px; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px var(--ui-font); cursor: pointer; border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm);}.ui-phone-input-country select:focus-visible { background: var(--ui-subtle); }.ui-phone-input-country::after {  content: ""; position: absolute; right: 10px; top: 50%; width: 6px; height: 6px; margin-top: -5px;  border: solid var(--ui-muted); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); pointer-events: none;}.ui-phone-input-country option { color: var(--ui-fg); background: var(--ui-surface); }.ui-phone-input-code { flex: none; align-self: center; padding: 0 2px 0 10px; color: var(--ui-muted); font-variant-numeric: tabular-nums; user-select: none; }.ui-phone-input-box input {  flex: 1; min-width: 0; padding: 0 12px 0 6px; border: 0; outline: none; background: transparent;  color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums;}.ui-phone-input-box input::placeholder { color: var(--ui-muted); opacity: 1; }.ui-phone-input-sm .ui-phone-input-box { height: 30px; width: 250px; }.ui-phone-input-sm .ui-phone-input-box input, .ui-phone-input-sm .ui-phone-input-country select { font-size: 13px; }.ui-phone-input-invalid .ui-phone-input-box, .ui-phone-input-invalid .ui-phone-input-box:hover { border-color: var(--ui-danger); }.ui-phone-input-hint, .ui-phone-input-error { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-phone-input-error { color: var(--ui-danger); }.ui-phone-input-disabled { opacity: .55; }.ui-phone-input-disabled input, .ui-phone-input-disabled select { cursor: not-allowed; }@media (prefers-reduced-motion: reduce) { .ui-phone-input-box { transition: none; } }`let phone_input_countries = [    { iso: "US", name: "United States", code: "1", flag: "🇺🇸" }, { iso: "CA", name: "Canada", code: "1", flag: "🇨🇦" },    { iso: "GB", name: "United Kingdom", code: "44", flag: "🇬🇧" }, { iso: "IE", name: "Ireland", code: "353", flag: "🇮🇪" },    { iso: "PT", name: "Portugal", code: "351", flag: "🇵🇹" }, { iso: "ES", name: "Spain", code: "34", flag: "🇪🇸" },    { iso: "FR", name: "France", code: "33", flag: "🇫🇷" }, { iso: "DE", name: "Germany", code: "49", flag: "🇩🇪" },    { iso: "IT", name: "Italy", code: "39", flag: "🇮🇹" }, { iso: "NL", name: "Netherlands", code: "31", flag: "🇳🇱" },    { iso: "BE", name: "Belgium", code: "32", flag: "🇧🇪" }, { iso: "CH", name: "Switzerland", code: "41", flag: "🇨🇭" },    { iso: "AT", name: "Austria", code: "43", flag: "🇦🇹" }, { iso: "DK", name: "Denmark", code: "45", flag: "🇩🇰" },    { iso: "SE", name: "Sweden", code: "46", flag: "🇸🇪" }, { iso: "NO", name: "Norway", code: "47", flag: "🇳🇴" },    { iso: "FI", name: "Finland", code: "358", flag: "🇫🇮" }, { iso: "IS", name: "Iceland", code: "354", flag: "🇮🇸" },    { iso: "PL", name: "Poland", code: "48", flag: "🇵🇱" }, { iso: "CZ", name: "Czechia", code: "420", flag: "🇨🇿" },    { iso: "HU", name: "Hungary", code: "36", flag: "🇭🇺" }, { iso: "RO", name: "Romania", code: "40", flag: "🇷🇴" },    { iso: "GR", name: "Greece", code: "30", flag: "🇬🇷" }, { iso: "TR", name: "Türkiye", code: "90", flag: "🇹🇷" },    { iso: "UA", name: "Ukraine", code: "380", flag: "🇺🇦" }, { iso: "IL", name: "Israel", code: "972", flag: "🇮🇱" },    { iso: "AE", name: "United Arab Emirates", code: "971", flag: "🇦🇪" }, { iso: "SA", name: "Saudi Arabia", code: "966", flag: "🇸🇦" },    { iso: "EG", name: "Egypt", code: "20", flag: "🇪🇬" }, { iso: "MA", name: "Morocco", code: "212", flag: "🇲🇦" },    { iso: "NG", name: "Nigeria", code: "234", flag: "🇳🇬" }, { iso: "KE", name: "Kenya", code: "254", flag: "🇰🇪" },    { iso: "ZA", name: "South Africa", code: "27", flag: "🇿🇦" }, { iso: "IN", name: "India", code: "91", flag: "🇮🇳" },    { iso: "PK", name: "Pakistan", code: "92", flag: "🇵🇰" }, { iso: "BD", name: "Bangladesh", code: "880", flag: "🇧🇩" },    { iso: "CN", name: "China", code: "86", flag: "🇨🇳" }, { iso: "JP", name: "Japan", code: "81", flag: "🇯🇵" },    { iso: "KR", name: "South Korea", code: "82", flag: "🇰🇷" }, { iso: "SG", name: "Singapore", code: "65", flag: "🇸🇬" },    { iso: "PH", name: "Philippines", code: "63", flag: "🇵🇭" }, { iso: "ID", name: "Indonesia", code: "62", flag: "🇮🇩" },    { iso: "TH", name: "Thailand", code: "66", flag: "🇹🇭" }, { iso: "VN", name: "Vietnam", code: "84", flag: "🇻🇳" },    { iso: "AU", name: "Australia", code: "61", flag: "🇦🇺" }, { iso: "NZ", name: "New Zealand", code: "64", flag: "🇳🇿" },    { iso: "BR", name: "Brazil", code: "55", flag: "🇧🇷" }, { iso: "MX", name: "Mexico", code: "52", flag: "🇲🇽" },    { iso: "AR", name: "Argentina", code: "54", flag: "🇦🇷" }, { iso: "CL", name: "Chile", code: "56", flag: "🇨🇱" },    { iso: "CO", name: "Colombia", code: "57", flag: "🇨🇴" },]// A number such as "+44 20 7946 0958" as { iso, national }: the country whose code// is its longest match (the one given when codes are shared, as +1 is), and the// digits after it. Text without a + is national digits for `country`.fn phone_input_split(value: str?, country: str, offered: list) {    let t = trim(value ?? "")    let digits = re.replace(t, "[^0-9]", "")    if !starts_with(t, "+") || digits == "" { return { iso: country, national: digits } }    let fits = offered | filter((c) => starts_with(digits, c.code))    if len(fits) == 0 { return { iso: country, national: digits } }    let longest = max(fits | map((c) => len(c.code)))    let best = fits | filter((c) => len(c.code) == longest)    let pick = find(best, (c) => c.iso == country) ?? best[0]    { iso: pick.iso, national: slice(digits, len(pick.code)) }}component PhoneInput(name: str = "phone", label: str? = null, value: str? = null, country: str = "US", countries: list? = null,                     placeholder: str = "Number", hint: str? = null, error: str? = null, required: bool = false,                     disabled: bool = false, field: str? = null, size: str = "md", on_change = null, class: str? = null) {    style global phone_input_css    let offered = countries == null ? phone_input_countries        : countries | map((k) => find(phone_input_countries, (c) => c.iso == upper(k))) | filter((c) => c != null)    let start = phone_input_split(value, upper(country), offered)    state iso = start.iso    state typed = start.national    let cur = find(offered, (c) => c.iso == iso) ?? offered[0]    let national = re.replace(typed, "[^0-9]", "")    let e164 = national == "" ? "" : `+${cur.code}${national}`    let id = field ?? `phone-input-${name}`    let described = ui_cls([hint != null && `${id}-hint`, error != null && `${id}-error`])    let tell = () => {        let n = re.replace(typed, "[^0-9]", "")        on_change && on_change({ e164: n == "" ? "" : `+${cur.code}${n}`, country: cur.iso, national: n })    }    <div class={ui_cls(["ui-phone-input", size == "sm" && "ui-phone-input-sm", error != null && "ui-phone-input-invalid",                        disabled && "ui-phone-input-disabled", class])}>        {label != null && <label.ui-phone-input-label for={id}>            {label}{required && <span.ui-phone-input-req aria-hidden="true">*</span>}        </label>}        <div.ui-phone-input-box>            <div.ui-phone-input-country>                <select aria-label="Country" disabled={disabled} value={iso}                        @change={(e) => {                            iso = e.value                            let c = find(offered, (c) => c.iso == e.value) ?? offered[0]                            let n = re.replace(typed, "[^0-9]", "")                            on_change && on_change({ e164: n == "" ? "" : `+${c.code}${n}`, country: c.iso, national: n })                        }}>                    {for c in offered => <option value={c.iso} selected={c.iso == iso}>{c.flag} {c.name} (+{c.code})</option>}                </select>            </div>            <span.ui-phone-input-code aria-hidden="true">+{cur.code}</span>            <input type="tel" id={id} value={typed} inputmode="tel" autocomplete="tel-national" placeholder={placeholder}                   required={required} disabled={disabled} aria-invalid={error != null ? "true" : null}                   aria-describedby={described != "" ? described : null}                   @input={(e) => {                       let clean = re.replace(e.value, "[^0-9 ()\\-]", "")                       typed = clean                       e.target.value = clean                       tell()                   }}/>        </div>        <input type="hidden" name={name} value={e164}/>        <input type="hidden" name={`${name}_country`} value={cur.iso}/>        {hint != null && <p.ui-phone-input-hint id={`${id}-hint`}>{hint}</p>}        {error != null && <p.ui-phone-input-error id={`${id}-error`} role="alert">{error}</p>}    </div>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On PhoneInput: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.