PhoneInput
A country code select and a number box, sent together in international form (+351912345678) with the country's ISO code.
Demo
Pick the country, then type the national number.
No number yet.
That number is too short.
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.qwemain.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>}Related
CheckboxCardsAny number of choices as cards with a title, a line of detail and a check, each a real checkbox a form sends.5 views · 6 downloadsComboboxA text box that filters a list of options as you type, or asks for them (a server, a map service), with the arrow keys, Enter and Escape.5 views · 6 downloadsDependentSelectTwo drop-downs where the second's options depend on the first, both real selects a form sends.5 views · 7 downloadsEmailInputAn email box that offers the address probably meant when the domain has a slip (gmial.com, outlook.con), one click to take it.5 views · 8 downloadsProfileFormAvatar, name, username, bio and website in the usual layout: a plain form posting to your route, shown again with errors.5 views · 8 downloadsRadioCardsOne choice of several as cards with a title, a line of detail and a dot, each a real radio a form sends.5 views · 7 downloads
Thread
Thread 0 posts
On PhoneInput: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.