mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add PhoneInput component
A type=tel input gets a country picker built on the Combobox, with flags, dial codes and search. It formats the number while typing, detects the country from a number with a dial code, and gives the full number in the international form. Raise the bundlewatch limits for tabler.js.
This commit is contained in:
1 parent
a0c090bde0
commit
653a9aa4f4
12 files changed
+1178
-4
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/docs": minor
|
||||
"@tabler/preview": minor
|
||||
---
|
||||
|
||||
Added Phone input component: a `type="tel"` field with a country picker built on the Combobox, with flags and dial codes.
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Tabler phone-countries.ts
|
||||
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// ISO 3166-1 alpha-2 code and international dial code of every country, as one
|
||||
// string to keep the bundle small. Countries of the North American numbering
|
||||
// plan share the dial code 1.
|
||||
const DIAL_CODES =
|
||||
'AD376 AE971 AF93 AG1 AI1 AL355 AM374 AO244 AR54 AS1 AT43 AU61 AW297 AX358 AZ994 BA387 BB1 BD880 BE32 BF226 BG359 BH973 BI257 BJ229 BL590 BM1 BN673 BO591 BQ599 BR55 BS1 BT975 BW267 BY375 BZ501 ' +
|
||||
'CA1 CC61 CD243 CF236 CG242 CH41 CI225 CK682 CL56 CM237 CN86 CO57 CR506 CU53 CV238 CW599 CX61 CY357 CZ420 DE49 DJ253 DK45 DM1 DO1 DZ213 EC593 EE372 EG20 EH212 ER291 ES34 ET251 FI358 FJ679 FK500 FM691 FO298 FR33 ' +
|
||||
'GA241 GB44 GD1 GE995 GF594 GG44 GH233 GI350 GL299 GM220 GN224 GP590 GQ240 GR30 GT502 GU1 GW245 GY592 HK852 HN504 HR385 HT509 HU36 ID62 IE353 IL972 IM44 IN91 IO246 IQ964 IR98 IS354 IT39 JE44 JM1 JO962 JP81 ' +
|
||||
'KE254 KG996 KH855 KI686 KM269 KN1 KP850 KR82 KW965 KY1 KZ7 LA856 LB961 LC1 LI423 LK94 LR231 LS266 LT370 LU352 LV371 LY218 MA212 MC377 MD373 ME382 MF590 MG261 MH692 MK389 ML223 MM95 MN976 MO853 MP1 MQ596 MR222 MS1 ' +
|
||||
'MT356 MU230 MV960 MW265 MX52 MY60 MZ258 NA264 NC687 NE227 NF672 NG234 NI505 NL31 NO47 NP977 NR674 NU683 NZ64 OM968 PA507 PE51 PF689 PG675 PH63 PK92 PL48 PM508 PR1 PS970 PT351 PW680 PY595 QA974 RE262 RO40 RS381 RU7 ' +
|
||||
'RW250 SA966 SB677 SC248 SD249 SE46 SG65 SH290 SI386 SJ47 SK421 SL232 SM378 SN221 SO252 SR597 SS211 ST239 SV503 SX1 SY963 SZ268 TC1 TD235 TG228 TH66 TJ992 TK690 TL670 TM993 TN216 TO676 TR90 TT1 TV688 TW886 TZ255 ' +
|
||||
'UA380 UG256 US1 UY598 UZ998 VA39 VC1 VE58 VG1 VI1 VN84 VU678 WF681 WS685 XK383 YE967 YT262 ZA27 ZM260 ZW263'
|
||||
|
||||
// The country picked when a number starts with a dial code that several countries share
|
||||
const MAIN_COUNTRY_OF_DIAL: Record<string, string> = {
|
||||
'1': 'us',
|
||||
'7': 'ru',
|
||||
'39': 'it',
|
||||
'44': 'gb',
|
||||
'47': 'no',
|
||||
'61': 'au',
|
||||
'212': 'ma',
|
||||
'262': 're',
|
||||
'358': 'fi',
|
||||
'590': 'gp',
|
||||
'599': 'cw',
|
||||
}
|
||||
|
||||
// How the national number is written, per country: `#` is a digit. Countries
|
||||
// that are not listed use the generic pattern.
|
||||
const FORMATS: [string, string][] = [
|
||||
['(###) ###-####', 'us ca pr'],
|
||||
['(###) ###-##-##', 'ru kz'],
|
||||
['(##) #####-####', 'br'],
|
||||
['##### ######', 'gb'],
|
||||
['### ########', 'de'],
|
||||
['## ## ## ## ##', 'fr'],
|
||||
['### ### ###', 'pl pt cz ec'],
|
||||
['#### ### ###', 'au ro sk'],
|
||||
['### ## ## ##', 'es'],
|
||||
['#### ## ## ##', 'be'],
|
||||
['### ### ####', 'it gr co ie fi ae sa za nz'],
|
||||
['## ########', 'nl'],
|
||||
['##-####-####', 'jp'],
|
||||
['###-####-####', 'kr'],
|
||||
['##### #####', 'in'],
|
||||
['## #### ####', 'mx'],
|
||||
['### #### ####', 'cn'],
|
||||
['### ### ## ##', 'tr ua ch'],
|
||||
['#### ######', 'at'],
|
||||
['### ## ###', 'no'],
|
||||
['## ## ## ##', 'dk'],
|
||||
['###-###-####', 'il'],
|
||||
['#### ####', 'sg hk'],
|
||||
['## ####-####', 'ar'],
|
||||
]
|
||||
|
||||
// The patterns above are the way the number is written inside the country, with the leading 0.
|
||||
// A number in the international form has no 0, so it is put back for these countries.
|
||||
const TRUNK_ZERO = new Set('gb fr de au nl jp kr be at ch tr ua ie fi ro sk il za nz ae sa'.split(' '))
|
||||
|
||||
const GENERIC_FORMAT = '### ### ### ###'
|
||||
|
||||
const formatOfCountry = (iso: string): string => FORMATS.find(([, countries]) => countries.split(' ').includes(iso))?.[0] ?? GENERIC_FORMAT
|
||||
|
||||
// Writes the digits into a pattern. A separator shows up only when a digit follows it,
|
||||
// and digits that do not fit the pattern are grouped by three after it.
|
||||
const formatNumber = (digits: string, pattern: string): string => {
|
||||
let result = ''
|
||||
let index = 0
|
||||
|
||||
for (const char of pattern) {
|
||||
if (index >= digits.length) {
|
||||
break
|
||||
}
|
||||
|
||||
result += char === '#' ? digits[index++] : char
|
||||
}
|
||||
|
||||
const rest = digits.slice(index)
|
||||
return rest ? `${result} ${rest.replace(/(\d{3})(?=\d)/g, '$1 ')}` : result
|
||||
}
|
||||
|
||||
type PhoneCountry = { iso: string; dial: string }
|
||||
|
||||
const PHONE_COUNTRIES: PhoneCountry[] = DIAL_CODES.split(' ').map((entry) => ({
|
||||
iso: entry.slice(0, 2).toLowerCase(),
|
||||
dial: entry.slice(2),
|
||||
}))
|
||||
|
||||
const countryOfDial = (dial: string): string | undefined => MAIN_COUNTRY_OF_DIAL[dial] ?? PHONE_COUNTRIES.find((country) => country.dial === dial)?.iso
|
||||
|
||||
export { countryOfDial, formatNumber, formatOfCountry, PHONE_COUNTRIES, TRUNK_ZERO }
|
||||
export type { PhoneCountry }
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Tabler phone-input.ts
|
||||
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import BaseComponent from './bootstrap/base-component'
|
||||
import EventHandler from './bootstrap/dom/event-handler'
|
||||
import { initAll } from './bootstrap/util/component-functions'
|
||||
import Combobox from './combobox'
|
||||
import { countryOfDial, formatNumber, formatOfCountry, PHONE_COUNTRIES, TRUNK_ZERO } from './phone-countries'
|
||||
import type { PhoneCountry } from './phone-countries'
|
||||
|
||||
type ComponentConfig = {
|
||||
autoDetect: boolean
|
||||
container: string | HTMLElement | null
|
||||
country: string
|
||||
countryLabel: string
|
||||
format: boolean
|
||||
hiddenInput: string | null
|
||||
locale: string | null
|
||||
onlyCountries: string[] | string | null
|
||||
preferredCountries: string[] | string
|
||||
searchPlaceholder: string
|
||||
}
|
||||
|
||||
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
const NAME = 'phoneInput'
|
||||
const DATA_KEY = `bs.${NAME}`
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
|
||||
const EVENT_COUNTRY_CHANGE = `countrychange${EVENT_KEY}`
|
||||
|
||||
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="phone-input"]'
|
||||
|
||||
const MAX_DIAL_LENGTH = 4
|
||||
const MIN_DIGITS = 4
|
||||
const MAX_DIGITS = 15
|
||||
|
||||
const Default: ComponentConfig = {
|
||||
autoDetect: true,
|
||||
container: null,
|
||||
country: 'us',
|
||||
countryLabel: 'Country',
|
||||
format: true,
|
||||
hiddenInput: null,
|
||||
locale: null,
|
||||
onlyCountries: null,
|
||||
preferredCountries: [],
|
||||
searchPlaceholder: 'Search…',
|
||||
}
|
||||
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
autoDetect: 'boolean',
|
||||
container: '(string|element|null)',
|
||||
country: 'string',
|
||||
countryLabel: 'string',
|
||||
format: 'boolean',
|
||||
hiddenInput: '(string|null)',
|
||||
locale: '(string|null)',
|
||||
onlyCountries: '(array|string|null)',
|
||||
preferredCountries: '(array|string)',
|
||||
searchPlaceholder: 'string',
|
||||
}
|
||||
|
||||
// A list of country codes as an array or as a string like "de,at,ch"
|
||||
const toCodes = (value: string[] | string): string[] => (Array.isArray(value) ? value : value.split(',')).map((code) => String(code).trim().toLowerCase()).filter(Boolean)
|
||||
|
||||
/**
|
||||
* Class definition
|
||||
*
|
||||
* A telephone `<input>` gets a country picker in front of it. The picker is a
|
||||
* Combobox built from a hidden `<select>`; the input itself keeps the national
|
||||
* number, and `getNumber()` (or the optional hidden input) gives the full
|
||||
* number in the international format.
|
||||
*/
|
||||
|
||||
class PhoneInput extends BaseComponent {
|
||||
declare _element: HTMLInputElement
|
||||
declare _config: ComponentConfig
|
||||
_countries: PhoneCountry[]
|
||||
_names: Map<string, string>
|
||||
_country: PhoneCountry
|
||||
_select: HTMLSelectElement
|
||||
_combobox: Combobox
|
||||
_wrapper: HTMLElement
|
||||
_hidden: HTMLInputElement | null
|
||||
|
||||
constructor(element: HTMLInputElement | string, config?: ComponentConfigInput) {
|
||||
super(element, config)
|
||||
|
||||
this._names = new Map()
|
||||
this._countries = this._listCountries()
|
||||
this._country = this._countries.find((country) => country.iso === this._config.country.toLowerCase()) ?? this._countries[0]
|
||||
this._hidden = null
|
||||
|
||||
this._select = this._createSelect()
|
||||
this._wrapper = this._wrap()
|
||||
this._combobox = this._createCombobox()
|
||||
|
||||
if (this._config.hiddenInput) {
|
||||
this._hidden = Object.assign(document.createElement('input'), { type: 'hidden', name: this._config.hiddenInput })
|
||||
this._wrapper.append(this._hidden)
|
||||
}
|
||||
|
||||
this._select.addEventListener('change', this._onCountryChange)
|
||||
this._element.addEventListener('input', this._onInput)
|
||||
|
||||
this._onInput()
|
||||
}
|
||||
|
||||
// Getters
|
||||
static get Default(): ComponentConfig {
|
||||
return Default
|
||||
}
|
||||
|
||||
static get DefaultType(): Record<keyof ComponentConfig, string> {
|
||||
return DefaultType
|
||||
}
|
||||
|
||||
static get NAME(): string {
|
||||
return NAME
|
||||
}
|
||||
|
||||
// Public
|
||||
// The full number in the international format, e.g. "+48600100200", or an empty string
|
||||
getNumber(): string {
|
||||
const digits = this._nationalDigits()
|
||||
return digits ? `+${this._country.dial}${digits}` : ''
|
||||
}
|
||||
|
||||
// Lower-case ISO code of the chosen country
|
||||
getCountry(): string {
|
||||
return this._country.iso
|
||||
}
|
||||
|
||||
getDialCode(): string {
|
||||
return this._country.dial
|
||||
}
|
||||
|
||||
setCountry(iso: string): void {
|
||||
const country = this._countries.find((item) => item.iso === iso.toLowerCase())
|
||||
|
||||
if (country) {
|
||||
this._applyCountry(country)
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts a number with a dial code, e.g. "+48 600 100 200"
|
||||
setNumber(number: string): void {
|
||||
this._element.value = number
|
||||
this._onInput()
|
||||
}
|
||||
|
||||
// A quick length check (4 to 15 digits in total), not a full numbering plan check
|
||||
isValid(): boolean {
|
||||
const digits = this._nationalDigits()
|
||||
return digits.length >= MIN_DIGITS && this._country.dial.length + digits.length <= MAX_DIGITS
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._select.removeEventListener('change', this._onCountryChange)
|
||||
this._element.removeEventListener('input', this._onInput)
|
||||
this._combobox.dispose()
|
||||
this._hidden?.remove()
|
||||
this._wrapper.replaceWith(this._element)
|
||||
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
// Private
|
||||
_listCountries(): PhoneCountry[] {
|
||||
const only = this._config.onlyCountries === null ? null : toCodes(this._config.onlyCountries)
|
||||
const preferred = toCodes(this._config.preferredCountries)
|
||||
const locale = this._config.locale || document.documentElement.lang || 'en'
|
||||
let display: Intl.DisplayNames | null = null
|
||||
|
||||
try {
|
||||
display = new Intl.DisplayNames([locale], { type: 'region' })
|
||||
} catch {
|
||||
display = null
|
||||
}
|
||||
|
||||
const countries = PHONE_COUNTRIES.filter((country) => !only || only.includes(country.iso))
|
||||
|
||||
for (const { iso } of countries) {
|
||||
let name = iso.toUpperCase()
|
||||
|
||||
try {
|
||||
name = display?.of(name) ?? name
|
||||
} catch {
|
||||
// keep the code when the browser does not know the region
|
||||
}
|
||||
|
||||
this._names.set(iso, name)
|
||||
}
|
||||
|
||||
const rank = (iso: string): number => {
|
||||
const index = preferred.indexOf(iso)
|
||||
return index === -1 ? Number.POSITIVE_INFINITY : index
|
||||
}
|
||||
|
||||
return countries.sort((a, b) => {
|
||||
const [rankA, rankB] = [rank(a.iso), rank(b.iso)]
|
||||
return rankA === rankB ? this._names.get(a.iso)!.localeCompare(this._names.get(b.iso)!, locale) : rankA - rankB
|
||||
})
|
||||
}
|
||||
|
||||
_createSelect(): HTMLSelectElement {
|
||||
const input = this._element
|
||||
const size = input.classList.contains('form-control-sm') ? ' form-select-sm' : input.classList.contains('form-control-lg') ? ' form-select-lg' : ''
|
||||
const select = document.createElement('select')
|
||||
|
||||
select.className = `form-select${size}`
|
||||
select.disabled = input.disabled
|
||||
select.setAttribute('aria-label', this._config.countryLabel)
|
||||
|
||||
for (const { iso, dial } of this._countries) {
|
||||
const option = Object.assign(document.createElement('option'), { value: iso, textContent: this._names.get(iso), selected: iso === this._country.iso })
|
||||
|
||||
option.dataset.hint = `+${dial}`
|
||||
option.dataset.display = `+${dial}`
|
||||
option.dataset.customProperties = `<span class='flag flag-xs flag-country-${iso}'></span>`
|
||||
select.append(option)
|
||||
}
|
||||
|
||||
return select
|
||||
}
|
||||
|
||||
_wrap(): HTMLElement {
|
||||
const wrapper = Object.assign(document.createElement('div'), { className: 'phone-input' })
|
||||
const group = Object.assign(document.createElement('div'), { className: 'input-group phone-input-group' })
|
||||
|
||||
this._element.replaceWith(wrapper)
|
||||
group.append(this._element)
|
||||
wrapper.append(this._select, group)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
_createCombobox(): Combobox {
|
||||
const combobox = new Combobox(this._select, { container: this._config.container, searchPlaceholder: this._config.searchPlaceholder })
|
||||
const group = this._element.parentElement!
|
||||
|
||||
group.prepend(combobox._toggle)
|
||||
|
||||
if (combobox._menu.parentElement === this._wrapper) {
|
||||
combobox._toggle.after(combobox._menu)
|
||||
}
|
||||
|
||||
combobox._menu.classList.add('phone-input-menu')
|
||||
|
||||
return combobox
|
||||
}
|
||||
|
||||
_nationalDigits(): string {
|
||||
return this._element.value.replace(/\D/g, '').replace(/^0+/, '')
|
||||
}
|
||||
|
||||
_pickCountry(dial: string): PhoneCountry | undefined {
|
||||
const candidates = this._countries.filter((country) => country.dial === dial)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return candidates.find((country) => country === this._country) ?? candidates.find((country) => country.iso === countryOfDial(dial)) ?? candidates[0]
|
||||
}
|
||||
|
||||
_applyCountry(country: PhoneCountry): void {
|
||||
const changed = country !== this._country
|
||||
|
||||
this._country = country
|
||||
this._select.value = country.iso
|
||||
this._combobox.sync()
|
||||
this._formatValue()
|
||||
this._updateHidden()
|
||||
|
||||
if (changed) {
|
||||
EventHandler.trigger(this._element, EVENT_COUNTRY_CHANGE, { country: country.iso, dialCode: country.dial })
|
||||
}
|
||||
}
|
||||
|
||||
_updateHidden(): void {
|
||||
if (this._hidden) {
|
||||
this._hidden.value = this.getNumber()
|
||||
}
|
||||
}
|
||||
|
||||
_onCountryChange = (): void => {
|
||||
const country = this._countries.find((item) => item.iso === this._select.value)
|
||||
|
||||
if (country) {
|
||||
this._applyCountry(country)
|
||||
// the combobox puts the focus back on its own field right after this event
|
||||
queueMicrotask(() => this._element.focus())
|
||||
}
|
||||
}
|
||||
|
||||
_onInput = (): void => {
|
||||
const value = this._element.value
|
||||
|
||||
if (this._config.autoDetect && value.trimStart().startsWith('+')) {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
for (let length = 1; length <= Math.min(MAX_DIAL_LENGTH, digits.length); length++) {
|
||||
const country = this._pickCountry(digits.slice(0, length))
|
||||
|
||||
if (country) {
|
||||
const rest = digits.slice(length)
|
||||
|
||||
this._applyCountry(country)
|
||||
this._element.value = this._config.format && rest && TRUNK_ZERO.has(country.iso) && !rest.startsWith('0') ? `0${rest}` : rest
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (!this._config.format) {
|
||||
const cleaned = value.replace(/[^\d\s\-().]/g, '')
|
||||
|
||||
if (cleaned !== value) {
|
||||
this._element.value = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
this._formatValue()
|
||||
this._updateHidden()
|
||||
}
|
||||
|
||||
// Writes the digits in the way people write numbers in the chosen country and keeps the cursor next to the same digit.
|
||||
_formatValue(): void {
|
||||
if (!this._config.format) {
|
||||
return
|
||||
}
|
||||
|
||||
const input = this._element
|
||||
const value = input.value
|
||||
|
||||
// a dial code that is still being typed stays as it is
|
||||
if (value.trimStart().startsWith('+')) {
|
||||
return
|
||||
}
|
||||
|
||||
const caret = input.selectionStart ?? value.length
|
||||
const digitsBefore = value.slice(0, caret).replace(/\D/g, '').length
|
||||
const formatted = formatNumber(value.replace(/\D/g, ''), formatOfCountry(this._country.iso))
|
||||
|
||||
if (formatted === value) {
|
||||
return
|
||||
}
|
||||
|
||||
input.value = formatted
|
||||
|
||||
if (document.activeElement === input) {
|
||||
let position = 0
|
||||
|
||||
for (let seen = 0; position < formatted.length && seen < digitsBefore; position++) {
|
||||
seen += /\d/.test(formatted[position]) ? 1 : 0
|
||||
}
|
||||
|
||||
input.setSelectionRange(position, position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data API implementation
|
||||
*/
|
||||
|
||||
// js-docs-start phone-input-init
|
||||
initAll(SELECTOR_DATA_TOGGLE, PhoneInput)
|
||||
// js-docs-end phone-input-init
|
||||
|
||||
export default PhoneInput
|
||||
@@ -14,6 +14,7 @@ import './src/tab'
|
||||
import './src/toast'
|
||||
import './src/sortable'
|
||||
import './src/otp-input'
|
||||
import './src/phone-input'
|
||||
import './src/sparkline'
|
||||
import './src/strength'
|
||||
|
||||
@@ -29,6 +30,7 @@ export { default as CountUp } from './src/countup'
|
||||
export { default as Datepicker } from './src/datepicker'
|
||||
export { default as InputMask } from './src/input-mask'
|
||||
export { default as OtpInput } from './src/otp-input'
|
||||
export { default as PhoneInput } from './src/phone-input'
|
||||
export { default as Sortable } from './src/sortable'
|
||||
export { default as Sparkline } from './src/sparkline'
|
||||
export { default as Strength } from './src/strength'
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
|
||||
import { clearFixture, getFixture } from '../helpers/fixture'
|
||||
import PhoneInput from '../../src/phone-input'
|
||||
|
||||
describe('PhoneInput', () => {
|
||||
let fixtureEl: HTMLElement
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureEl = getFixture()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearFixture()
|
||||
})
|
||||
|
||||
const setup = (attributes = '', config: Record<string, unknown> = {}): { input: HTMLInputElement; phone: PhoneInput } => {
|
||||
fixtureEl.innerHTML = `<label for="phone">Phone</label><input type="tel" id="phone" class="form-control" ${attributes} />`
|
||||
const input = fixtureEl.querySelector<HTMLInputElement>('#phone')!
|
||||
return { input, phone: new PhoneInput(input, config) }
|
||||
}
|
||||
|
||||
const type = (input: HTMLInputElement, value: string): void => {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
const toggle = (): HTMLElement => fixtureEl.querySelector<HTMLElement>('.combobox-toggle')!
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should put a country picker in front of the input', () => {
|
||||
const { input } = setup('data-bs-country="pl"')
|
||||
|
||||
expect(input.parentElement!.classList.contains('phone-input-group')).toBe(true)
|
||||
expect(input.previousElementSibling!.classList.contains('dropdown-menu')).toBe(true)
|
||||
expect(toggle().nextElementSibling!.classList.contains('phone-input-menu')).toBe(true)
|
||||
expect(toggle().textContent).toContain('+48')
|
||||
expect(toggle().querySelector('.flag-country-pl')).not.toBeNull()
|
||||
expect(toggle().getAttribute('aria-label')).toBe('Country')
|
||||
})
|
||||
|
||||
it('should fall back to the first country when the given one is unknown', () => {
|
||||
const { phone } = setup('data-bs-country="zz"')
|
||||
expect(phone.getCountry()).not.toBe('zz')
|
||||
})
|
||||
|
||||
it('should list preferred countries first and only the allowed ones', () => {
|
||||
const { phone } = setup('', { preferredCountries: ['pl', 'de'], onlyCountries: ['pl', 'de', 'fr', 'us'], country: 'pl' })
|
||||
const values = [...fixtureEl.querySelectorAll<HTMLElement>('.dropdown-item')].map((item) => item.dataset.bsValue)
|
||||
|
||||
expect(values).toEqual(['pl', 'de', 'fr', 'us'])
|
||||
expect(phone.getCountry()).toBe('pl')
|
||||
})
|
||||
|
||||
it('should read the country lists from a comma separated string', () => {
|
||||
const { phone } = setup('data-bs-only-countries="pl, de,fr" data-bs-preferred-countries="fr" data-bs-country="fr"')
|
||||
const values = [...fixtureEl.querySelectorAll<HTMLElement>('.dropdown-item')].map((item) => item.dataset.bsValue)
|
||||
|
||||
expect(values).toEqual(['fr', 'de', 'pl'])
|
||||
expect(phone.getCountry()).toBe('fr')
|
||||
})
|
||||
|
||||
it('should copy the input size to the picker', () => {
|
||||
fixtureEl.innerHTML = '<input type="tel" class="form-control form-control-sm" />'
|
||||
new PhoneInput(fixtureEl.querySelector('input')!)
|
||||
expect(toggle().classList.contains('form-select-sm')).toBe(true)
|
||||
})
|
||||
|
||||
it('should disable the picker with the input', () => {
|
||||
setup('disabled')
|
||||
expect(toggle().hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numbers', () => {
|
||||
it('should build the international number from the national digits', () => {
|
||||
const { input, phone } = setup('data-bs-country="pl"')
|
||||
|
||||
type(input, '600 100 200')
|
||||
expect(phone.getNumber()).toBe('+48600100200')
|
||||
})
|
||||
|
||||
it('should drop leading zeros of the national number', () => {
|
||||
const { input, phone } = setup('data-bs-country="gb"')
|
||||
|
||||
type(input, '07911 123456')
|
||||
expect(phone.getNumber()).toBe('+447911123456')
|
||||
})
|
||||
|
||||
it('should return an empty string for an empty input', () => {
|
||||
expect(setup().phone.getNumber()).toBe('')
|
||||
})
|
||||
|
||||
it('should remove characters that cannot be in a number', () => {
|
||||
const { input } = setup('data-bs-format="false"')
|
||||
|
||||
type(input, 'ab12c-3')
|
||||
expect(input.value).toBe('12-3')
|
||||
})
|
||||
|
||||
it('should validate the length', () => {
|
||||
const { input, phone } = setup('data-bs-country="pl"')
|
||||
|
||||
type(input, '12')
|
||||
expect(phone.isValid()).toBe(false)
|
||||
|
||||
type(input, '600100200')
|
||||
expect(phone.isValid()).toBe(true)
|
||||
|
||||
type(input, '1234567890123456')
|
||||
expect(phone.isValid()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting', () => {
|
||||
it('should write the number the way it is written in the country', () => {
|
||||
const { input, phone } = setup('data-bs-country="pl"')
|
||||
|
||||
type(input, '600100200')
|
||||
expect(input.value).toBe('600 100 200')
|
||||
|
||||
phone.setCountry('us')
|
||||
expect(input.value).toBe('(600) 100-200')
|
||||
|
||||
type(input, '2025550100')
|
||||
expect(input.value).toBe('(202) 555-0100')
|
||||
expect(phone.getNumber()).toBe('+12025550100')
|
||||
})
|
||||
|
||||
it('should show a separator only when a digit follows it', () => {
|
||||
const { input } = setup('data-bs-country="us"')
|
||||
|
||||
type(input, '20')
|
||||
expect(input.value).toBe('(20')
|
||||
|
||||
type(input, '2025')
|
||||
expect(input.value).toBe('(202) 5')
|
||||
})
|
||||
|
||||
it('should group the digits that do not fit the pattern', () => {
|
||||
const { input } = setup('data-bs-country="pl"')
|
||||
|
||||
type(input, '60010020012345')
|
||||
expect(input.value).toBe('600 100 200 123 45')
|
||||
})
|
||||
|
||||
it('should use a generic pattern for a country without one', () => {
|
||||
const { input } = setup('data-bs-country="is"')
|
||||
|
||||
type(input, '5551234')
|
||||
expect(input.value).toBe('555 123 4')
|
||||
})
|
||||
|
||||
it('should keep the cursor next to the same digit', () => {
|
||||
const { input } = setup('data-bs-country="pl"')
|
||||
|
||||
input.focus()
|
||||
type(input, '600 100 200')
|
||||
input.value = '600 1900 200'
|
||||
input.setSelectionRange(6, 6)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(input.value).toBe('600 190 020 0')
|
||||
expect(input.selectionStart).toBe(6)
|
||||
})
|
||||
|
||||
it('should put the leading zero back for a number in the international form', () => {
|
||||
const { input, phone } = setup('data-bs-country="us"')
|
||||
|
||||
type(input, '+44 7911 123456')
|
||||
expect(phone.getCountry()).toBe('gb')
|
||||
expect(input.value).toBe('07911 123456')
|
||||
expect(phone.getNumber()).toBe('+447911123456')
|
||||
|
||||
type(input, '+33 6 11 22 33 44')
|
||||
expect(input.value).toBe('06 11 22 33 44')
|
||||
expect(phone.getNumber()).toBe('+33611223344')
|
||||
})
|
||||
|
||||
it('should not format when format is off', () => {
|
||||
const { input } = setup('data-bs-country="pl" data-bs-format="false"')
|
||||
|
||||
type(input, '600100200')
|
||||
expect(input.value).toBe('600100200')
|
||||
})
|
||||
})
|
||||
|
||||
describe('country detection', () => {
|
||||
it('should switch the country when a number with a dial code is typed', () => {
|
||||
const { input, phone } = setup('data-bs-country="us"')
|
||||
const change = vi.fn()
|
||||
input.addEventListener('countrychange.bs.phoneInput', change)
|
||||
|
||||
type(input, '+48 600 100 200')
|
||||
|
||||
expect(phone.getCountry()).toBe('pl')
|
||||
expect(input.value).toBe('600 100 200')
|
||||
expect(toggle().textContent).toContain('+48')
|
||||
expect(change).toHaveBeenCalledOnce()
|
||||
expect(phone.getNumber()).toBe('+48600100200')
|
||||
})
|
||||
|
||||
it('should pick the main country of a shared dial code', () => {
|
||||
const { input, phone } = setup('data-bs-country="pl"')
|
||||
|
||||
type(input, '+1 202 555 0100')
|
||||
expect(phone.getCountry()).toBe('us')
|
||||
|
||||
type(input, '+44 7911 123456')
|
||||
expect(phone.getCountry()).toBe('gb')
|
||||
})
|
||||
|
||||
it('should keep the current country when it shares the dial code', () => {
|
||||
const { input, phone } = setup('data-bs-country="ca"')
|
||||
|
||||
type(input, '+1 613 555 0100')
|
||||
expect(phone.getCountry()).toBe('ca')
|
||||
})
|
||||
|
||||
it('should not detect the country when autoDetect is off', () => {
|
||||
const { input, phone } = setup('data-bs-country="pl" data-bs-auto-detect="false"')
|
||||
|
||||
type(input, '+1 202')
|
||||
expect(phone.getCountry()).toBe('pl')
|
||||
})
|
||||
|
||||
it('should wait for a complete dial code', () => {
|
||||
const { input, phone } = setup('data-bs-country="us"')
|
||||
|
||||
type(input, '+3')
|
||||
expect(phone.getCountry()).toBe('us')
|
||||
expect(input.value).toBe('+3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('country picker', () => {
|
||||
it('should follow a choice made in the menu and focus the input', async () => {
|
||||
const { input, phone } = setup('data-bs-country="us"')
|
||||
const change = vi.fn()
|
||||
input.addEventListener('countrychange.bs.phoneInput', change)
|
||||
|
||||
toggle().click()
|
||||
fixtureEl.querySelector<HTMLElement>('.dropdown-item[data-bs-value="de"]')!.click()
|
||||
|
||||
expect(phone.getCountry()).toBe('de')
|
||||
expect(phone.getDialCode()).toBe('49')
|
||||
expect(toggle().textContent).toContain('+49')
|
||||
expect(change).toHaveBeenCalledOnce()
|
||||
await Promise.resolve()
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('should set the country from code', () => {
|
||||
const { phone } = setup()
|
||||
|
||||
phone.setCountry('FR')
|
||||
expect(phone.getCountry()).toBe('fr')
|
||||
expect(toggle().textContent).toContain('+33')
|
||||
|
||||
phone.setCountry('nope')
|
||||
expect(phone.getCountry()).toBe('fr')
|
||||
})
|
||||
|
||||
it('should set the whole number', () => {
|
||||
const { input, phone } = setup()
|
||||
|
||||
phone.setNumber('+49 30 1234567')
|
||||
expect(phone.getCountry()).toBe('de')
|
||||
expect(input.value).toBe('030 1234567')
|
||||
expect(phone.getNumber()).toBe('+49301234567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hidden input', () => {
|
||||
it('should keep the full number in a hidden input', () => {
|
||||
const { input } = setup('data-bs-country="pl" data-bs-hidden-input="phone_full"')
|
||||
const hidden = fixtureEl.querySelector<HTMLInputElement>('input[name="phone_full"]')!
|
||||
|
||||
type(input, '600 100 200')
|
||||
expect(hidden.type).toBe('hidden')
|
||||
expect(hidden.value).toBe('+48600100200')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should put the input back where it was', () => {
|
||||
const { input, phone } = setup()
|
||||
phone.dispose()
|
||||
|
||||
expect(input.parentElement).toBe(fixtureEl)
|
||||
expect(fixtureEl.querySelector('.phone-input')).toBeNull()
|
||||
expect(fixtureEl.querySelector('.combobox-toggle')).toBeNull()
|
||||
expect(PhoneInput.getInstance(input)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
+4
-4
@@ -212,19 +212,19 @@
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.js",
|
||||
"maxSize": "65 kB"
|
||||
"maxSize": "69 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.min.js",
|
||||
"maxSize": "43 kB"
|
||||
"maxSize": "46 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.esm.js",
|
||||
"maxSize": "65 kB"
|
||||
"maxSize": "69 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler.esm.min.js",
|
||||
"maxSize": "43 kB"
|
||||
"maxSize": "46 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/js/tabler-theme.js",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
@forward 'ui/forms/form-check';
|
||||
@forward 'ui/forms/form-otp';
|
||||
@forward 'ui/forms/combobox';
|
||||
@forward 'ui/forms/phone-input';
|
||||
@forward 'ui/forms/validation';
|
||||
@forward 'ui/forms/strength';
|
||||
@forward 'ui/legend';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@use '../../config' as *;
|
||||
|
||||
.phone-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.phone-input-group {
|
||||
// A fixed width, so the field does not move when the dial code gets longer (+1 to +351)
|
||||
> .combobox-toggle {
|
||||
flex: 0 0 8.5rem;
|
||||
width: 8.5rem;
|
||||
|
||||
&.form-select-sm {
|
||||
flex-basis: 8rem;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
&.form-select-lg {
|
||||
flex-basis: 9rem;
|
||||
width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.is-valid) ~ .valid-feedback,
|
||||
&:has(.is-invalid) ~ .invalid-feedback {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.phone-input-menu {
|
||||
min-width: 18rem;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: Phone input
|
||||
seoTitle: Phone number input with country picker
|
||||
order: 15
|
||||
summary: A telephone field with a country picker in front of it, showing flags and dial codes, with search, number formatting and international number output.
|
||||
description: Add a phone number input with a searchable country picker, flags and dial codes. Formats the number as you type, detects the country from a pasted number and gives the full international number.
|
||||
related: [/ui/forms/combobox, /ui/forms/elements, /ui/forms/validation]
|
||||
source: core/scss/ui/forms/_phone-input.scss
|
||||
css-plugins: [flags]
|
||||
classnames:
|
||||
component:
|
||||
- class: phone-input
|
||||
desc: Wrapper the script puts around the input; holds the hidden country select
|
||||
- class: phone-input-group
|
||||
desc: The input group that holds the country picker and the input
|
||||
- class: phone-input-menu
|
||||
desc: The country menu; wide enough for a country name and its dial code
|
||||
---
|
||||
|
||||
import Example from '@components/Example.astro';
|
||||
import CodeDocs from '@components/CodeDocs.astro';
|
||||
|
||||
## Overview
|
||||
|
||||
A phone input turns a normal `<input type="tel">` into a field with a country picker in front of it. The picker is a [combobox](/ui/forms/combobox), so it has search, keyboard support and a flag next to every country. The input stays a plain text field. It holds the national number, and the script gives you the full number in the international format.
|
||||
|
||||
The country flags come from the [flags](/ui/plugins/flags) plugin, so include `tabler-flags.css` on the page. Country names come from the browser, in the language of the page.
|
||||
|
||||
Add `data-bs-toggle="phone-input"` to the input and pick the start country with `data-bs-country`.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-basic">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-basic" placeholder="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
## Usage
|
||||
|
||||
### Preferred countries
|
||||
|
||||
List the countries your users come from first. The rest follow in alphabetical order. Pass two-letter country codes separated by commas.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-preferred">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-preferred" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="de" data-bs-preferred-countries="de,at,ch,pl" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Only some countries
|
||||
|
||||
Limit the list with `data-bs-only-countries`.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-only">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-only" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="fr" data-bs-only-countries="fr,be,ch,lu,mc" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Number with a dial code
|
||||
|
||||
A number that starts with `+` selects its country and the dial code is removed from the input. This makes pasting an international number work. Set `data-bs-auto-detect="false"` to turn it off.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-detect">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-detect" value="+44 7911 123456" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Formatting
|
||||
|
||||
The number is written the way people write it in the chosen country, while the user types. A US number becomes `(202) 555-0100`, a Polish one `600 100 200`. The cursor stays next to the same digit when you edit the middle of the number, and a number that does not fit the pattern continues in groups of three. About 40 countries have their own pattern; the others use a generic one.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-format">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-format" value="2025550100" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
Set `data-bs-format="false"` to keep what the user typed. Only digits, spaces, dashes, dots and brackets are allowed then.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-no-format">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-no-format" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="pl" data-bs-format="false" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Full number in a form
|
||||
|
||||
The input holds only the national number. To submit the full number, set `data-bs-hidden-input` to a field name. The script adds a hidden input with that name and keeps the number in the international format, for example `+48600100200`, in it.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-hidden">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-hidden" placeholder="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" data-bs-hidden-input="phone" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Sizes
|
||||
|
||||
The picker follows `.form-control-sm` and `.form-control-lg` of the input.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100 mb-3">
|
||||
<label class="form-label" for="phone-size-sm">Small</label>
|
||||
<input type="tel" class="form-control form-control-sm" id="phone-size-sm" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
<div class="w-100 mb-3">
|
||||
<label class="form-label" for="phone-size-default">Default</label>
|
||||
<input type="tel" class="form-control" id="phone-size-default" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
<div class="w-100 mb-3">
|
||||
<label class="form-label" for="phone-size-lg">Large</label>
|
||||
<input type="tel" class="form-control form-control-lg" id="phone-size-lg" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Validation
|
||||
|
||||
Add `.is-valid` or `.is-invalid` to the input. The feedback shows below the whole field.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="phone-valid">Valid</label>
|
||||
<input type="tel" class="form-control is-valid" id="phone-valid" value="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
<div class="valid-feedback">Looks good.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="phone-invalid">Invalid</label>
|
||||
<input type="tel" class="form-control is-invalid" id="phone-invalid" value="12" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
<div class="invalid-feedback">Enter a full phone number.</div>
|
||||
</div>
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
### Disabled
|
||||
|
||||
Add `disabled` to the input. The picker is disabled too.
|
||||
|
||||
<Example column overflow="visible">
|
||||
<div class="w-100">
|
||||
<label class="form-label" for="phone-disabled">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-disabled" value="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" disabled />
|
||||
</div>
|
||||
</Example>
|
||||
|
||||
## JavaScript
|
||||
|
||||
### Initialization
|
||||
|
||||
The script starts on every `[data-bs-toggle="phone-input"]` on page load.
|
||||
|
||||
<CodeDocs name="phone-input-init" file="core/js/src/phone-input.ts" />
|
||||
|
||||
### Options
|
||||
|
||||
Pass options as `data-bs-*` attributes on the input, or as an object when creating the instance.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `country` | string | `'us'` | Two-letter code of the country shown first. |
|
||||
| `preferredCountries` | array \| string | `[]` | Codes of the countries listed first. |
|
||||
| `onlyCountries` | array \| string \| null | `null` | Limits the list to these countries. |
|
||||
| `autoDetect` | boolean | `true` | Selects the country of a number that starts with `+`. |
|
||||
| `format` | boolean | `true` | Writes the number in the format of the chosen country while typing. |
|
||||
| `hiddenInput` | string \| null | `null` | Name of a hidden input that gets the full number. |
|
||||
| `locale` | string \| null | `null` | Language of the country names. Uses the `lang` of the page when empty. |
|
||||
| `countryLabel` | string | `'Country'` | Accessible name of the picker. |
|
||||
| `searchPlaceholder` | string | `'Search…'` | Placeholder of the search field in the menu. |
|
||||
| `container` | string \| element | `null` | Where to append the menu, for example `'body'`. |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `getNumber()` | The full number, for example `+48600100200`, or an empty string. Leading zeros of the national number are dropped. |
|
||||
| `getCountry()` | The two-letter code of the chosen country, in lower case. |
|
||||
| `getDialCode()` | The dial code of the chosen country, without `+`. |
|
||||
| `setCountry(code)` | Selects a country. An unknown code is ignored. |
|
||||
| `setNumber(number)` | Sets the number. A dial code at the start selects the country. |
|
||||
| `isValid()` | A quick length check: 4 to 15 digits in total. It does not know the numbering plan of each country. |
|
||||
| `dispose()` | Puts the input back where it was and removes the picker. |
|
||||
| `getInstance(element)` | Static. Returns the instance bound to the element, or `null`. |
|
||||
| `getOrCreateInstance(element, config)` | Static. Returns the existing instance, or creates one. |
|
||||
|
||||
### Events
|
||||
|
||||
Events fire on the input.
|
||||
|
||||
| Event | Description |
|
||||
| --- | --- |
|
||||
| `countrychange.bs.phoneInput` | Fires when the country changes, from the menu, from a number with a dial code or from `setCountry()`. `event.country` and `event.dialCode` hold the new values. |
|
||||
|
||||
```javascript
|
||||
input.addEventListener('countrychange.bs.phoneInput', (event) => {
|
||||
console.log(event.country, event.dialCode)
|
||||
})
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Give the input a visible `<label>` with `for`, like any other field. The picker has its own name from the `countryLabel` option; translate it when the page is not in English.
|
||||
- The picker is a button with a listbox menu, so it works with the keyboard as described on the [combobox](/ui/forms/combobox) page. After a country is picked, the focus goes to the input.
|
||||
- Use `type="tel"` on the input so phones show the number keyboard. Add `autocomplete="tel-national"` when the browser should fill only the national part.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import CardSubtitle from '@ui/CardSubtitle.astro'
|
||||
import DocsLink from '@ui/DocsLink.astro'
|
||||
|
||||
const sizes: [string, string][] = [
|
||||
['sm', 'Small'],
|
||||
['', 'Default'],
|
||||
['lg', 'Large'],
|
||||
]
|
||||
---
|
||||
|
||||
<DefaultLayout title="Phone input" pageMenu="form.phone-input" description="A phone input adds a country picker with flags and dial codes in front of a telephone field.">
|
||||
<DocsLink slot="page-header-actions" path="/ui/forms/phone-input" />
|
||||
|
||||
<div class="row row-cards row-cols-1 row-cols-md-2 row-cols-lg-3">
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Basic</CardTitle>
|
||||
<CardSubtitle>Add <code>data-bs-toggle="phone-input"</code> to a <code>type="tel"</code> input and pick the start country with <code>data-bs-country</code>.</CardSubtitle>
|
||||
<label class="form-label" for="phone-basic">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-basic" placeholder="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Preferred countries</CardTitle>
|
||||
<CardSubtitle>List the countries your users come from first with <code>data-bs-preferred-countries</code>.</CardSubtitle>
|
||||
<label class="form-label" for="phone-preferred">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-preferred" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="de" data-bs-preferred-countries="de,at,ch,pl" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Only some countries</CardTitle>
|
||||
<CardSubtitle>Limit the list with <code>data-bs-only-countries</code>.</CardSubtitle>
|
||||
<label class="form-label" for="phone-only">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-only" placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="fr" data-bs-only-countries="fr,be,ch,lu,mc" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Number with a dial code</CardTitle>
|
||||
<CardSubtitle>A number that starts with <code>+</code> selects its country. Paste one to try it.</CardSubtitle>
|
||||
<label class="form-label" for="phone-detect">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-detect" value="+44 7911 123456" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Full number in a form</CardTitle>
|
||||
<CardSubtitle>Set <code>data-bs-hidden-input="phone"</code> to submit the number as <code>+48600100200</code>.</CardSubtitle>
|
||||
<label class="form-label" for="phone-hidden">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-hidden" placeholder="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" data-bs-hidden-input="phone" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Sizes</CardTitle>
|
||||
<CardSubtitle>The picker follows <code>.form-control-sm</code> and <code>.form-control-lg</code> of the input.</CardSubtitle>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
{
|
||||
sizes.map(([size, label]) => (
|
||||
<div>
|
||||
<label class="form-label" for={`phone-size-${size || 'default'}`}>
|
||||
{label}
|
||||
</label>
|
||||
<input type="tel" class={`form-control${size ? ` form-control-${size}` : ''}`} id={`phone-size-${size || 'default'}`} placeholder="Phone number" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Validation</CardTitle>
|
||||
<CardSubtitle>Add <code>.is-valid</code> or <code>.is-invalid</code> to the input.</CardSubtitle>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="phone-valid">Valid</label>
|
||||
<input type="tel" class="form-control is-valid" id="phone-valid" value="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
<div class="valid-feedback">Looks good.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="phone-invalid">Invalid</label>
|
||||
<input type="tel" class="form-control is-invalid" id="phone-invalid" value="12" data-bs-toggle="phone-input" data-bs-country="pl" />
|
||||
<div class="invalid-feedback">Enter a full phone number.</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Formatting</CardTitle>
|
||||
<CardSubtitle>The number is written in the format of the country while typing. Set <code>data-bs-format="false"</code> to turn it off.</CardSubtitle>
|
||||
<label class="form-label" for="phone-format">Formatted</label>
|
||||
<input type="tel" class="form-control mb-3" id="phone-format" value="2025550100" data-bs-toggle="phone-input" data-bs-country="us" />
|
||||
<label class="form-label" for="phone-no-format">Not formatted</label>
|
||||
<input type="tel" class="form-control" id="phone-no-format" value="2025550100" data-bs-toggle="phone-input" data-bs-country="us" data-bs-format="false" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<Card class="h-100">
|
||||
<CardBody>
|
||||
<CardTitle>Disabled</CardTitle>
|
||||
<CardSubtitle>Add <code>disabled</code> to the input. The picker is disabled too.</CardSubtitle>
|
||||
<label class="form-label" for="phone-disabled">Phone number</label>
|
||||
<input type="tel" class="form-control" id="phone-disabled" value="600 100 200" data-bs-toggle="phone-input" data-bs-country="pl" disabled />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
@@ -388,6 +388,10 @@
|
||||
"title": "Combobox",
|
||||
"url": "/ui/forms/combobox"
|
||||
},
|
||||
{
|
||||
"title": "Phone input",
|
||||
"url": "/ui/forms/phone-input"
|
||||
},
|
||||
{
|
||||
"title": "CSS variables",
|
||||
"url": "/ui/forms/css-variables"
|
||||
|
||||
@@ -287,6 +287,11 @@
|
||||
"url": "combobox.html",
|
||||
"title": "Combobox",
|
||||
"badge": "New"
|
||||
},
|
||||
"phone-input": {
|
||||
"url": "phone-input.html",
|
||||
"title": "Phone input",
|
||||
"badge": "New"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user