@sarvspl/phone-input 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/package.json +41 -0
- package/src/index.d.ts +35 -0
- package/src/phone-input.js +297 -0
- package/src/validate.js +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @sarvspl/phone-input
|
|
2
|
+
|
|
3
|
+
Mobile number input as a Web Component: `<phone-input>`. Works in React, Vue, Angular, Svelte, and plain HTML.
|
|
4
|
+
|
|
5
|
+
- Detects the visitor's country from their IP and selects it automatically. Falls back to the browser locale.
|
|
6
|
+
- Searchable country picker with flags, keyboard navigation, and pinned favourites.
|
|
7
|
+
- Validates **mobile** numbers per country (landlines are rejected) using `libphonenumber-js`.
|
|
8
|
+
- Formats as you type. Pasting `+44 7400 123456` switches the country to the UK.
|
|
9
|
+
- Works inside a `<form>`: submits E.164 (`+919876543210`), supports `required`, and blocks submit when the number is invalid.
|
|
10
|
+
- Themed with CSS variables and `::part()`.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i @sarvspl/phone-input
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Plain HTML / any bundler
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<script type="module">import '@sarvspl/phone-input';</script>
|
|
24
|
+
|
|
25
|
+
<phone-input name="phone" label="Mobile number" preferred-countries="IN,US,GB" required></phone-input>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### React
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
import '@sarvspl/phone-input';
|
|
32
|
+
import { useEffect, useRef } from 'react';
|
|
33
|
+
|
|
34
|
+
export function Phone({ onChange }) {
|
|
35
|
+
const ref = useRef(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const el = ref.current;
|
|
38
|
+
const h = (e) => onChange(e.detail); // { valid, e164, country, dialCode, national, international }
|
|
39
|
+
el.addEventListener('phone-change', h);
|
|
40
|
+
return () => el.removeEventListener('phone-change', h);
|
|
41
|
+
}, [onChange]);
|
|
42
|
+
return <phone-input ref={ref} label="Mobile number" preferred-countries="IN,US" />;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Vue
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup>import '@sarvspl/phone-input';</script>
|
|
50
|
+
<template>
|
|
51
|
+
<phone-input label="Mobile" @phone-change="e => phone = e.detail" />
|
|
52
|
+
</template>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
In `vite.config.js`, set `vue({ template: { compilerOptions: { isCustomElement: t => t === 'phone-input' } } })`.
|
|
56
|
+
|
|
57
|
+
### Angular
|
|
58
|
+
|
|
59
|
+
Import `'@sarvspl/phone-input'` in `main.ts`. Add `CUSTOM_ELEMENTS_SCHEMA` to the component's `schemas`. Then use `(phone-change)="onPhone($event.detail)"`.
|
|
60
|
+
|
|
61
|
+
### Validation only (no UI, works in Node)
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { validatePhone } from '@sarvspl/phone-input/validate';
|
|
65
|
+
validatePhone('98765 43210', 'IN'); // { valid: true, e164: '+919876543210', ... }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Attributes
|
|
69
|
+
|
|
70
|
+
| Attribute | Description |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `label` | Label text shown above the field. |
|
|
73
|
+
| `name` | Form field name. The submitted value is E.164 when valid. |
|
|
74
|
+
| `value` | Initial value, e.g. `+919876543210`. |
|
|
75
|
+
| `default-country` | Used when IP detection fails or is off. Default `US`. |
|
|
76
|
+
| `no-detect` | Turns off IP detection. |
|
|
77
|
+
| `geo-url` | Custom IP lookup URL. It must return JSON with `country`, `country_code`, or `countryCode`. Default `https://get.geojs.io/v1/ip/country.json`. |
|
|
78
|
+
| `preferred-countries` | Comma list pinned at the top, e.g. `IN,US,GB`. |
|
|
79
|
+
| `only-countries` | Comma list. Shows only these countries. |
|
|
80
|
+
| `placeholder` | Overrides the per-country example number. |
|
|
81
|
+
| `error-text` | Custom error. `{country}` is replaced with the country name. |
|
|
82
|
+
| `locale` | Language for country names, e.g. `hi`, `ar`, `fr`. |
|
|
83
|
+
| `flag-url` | Flag image template. Default `https://flagcdn.com/{code}.svg`. |
|
|
84
|
+
| `required`, `disabled` | Standard form behaviour. |
|
|
85
|
+
|
|
86
|
+
## Properties, methods, events
|
|
87
|
+
|
|
88
|
+
- `el.value`: E.164 string when valid, otherwise the raw text. You can also set it.
|
|
89
|
+
- `el.country`: ISO code (`'IN'`). You can also set it.
|
|
90
|
+
- `el.valid`, `el.details`, `el.open()`, `el.close()`, `el.checkValidity()`, `el.reportValidity()`.
|
|
91
|
+
- Event `phone-change`: `detail = { valid, e164, country, dialCode, national, international }`.
|
|
92
|
+
- Event `country-change`: `detail = { country, dialCode }`.
|
|
93
|
+
|
|
94
|
+
## Styling
|
|
95
|
+
|
|
96
|
+
```css
|
|
97
|
+
phone-input {
|
|
98
|
+
--pi-primary: #6366f1; /* focus ring, selected item */
|
|
99
|
+
--pi-bg: #fff;
|
|
100
|
+
--pi-text: #0f172a;
|
|
101
|
+
--pi-muted: #64748b;
|
|
102
|
+
--pi-border: #e2e8f0;
|
|
103
|
+
--pi-hover: #f1f5f9;
|
|
104
|
+
--pi-error: #ef4444;
|
|
105
|
+
--pi-success: #10b981;
|
|
106
|
+
--pi-radius: 14px; /* use 999px for a pill shape */
|
|
107
|
+
--pi-height: 52px;
|
|
108
|
+
--pi-font: inherit;
|
|
109
|
+
--pi-font-size: 16px;
|
|
110
|
+
--pi-shadow: 0 20px 45px -12px rgba(15,23,42,.28);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Deeper overrides */
|
|
114
|
+
phone-input::part(field) { }
|
|
115
|
+
phone-input::part(country-button) { }
|
|
116
|
+
phone-input::part(input) { }
|
|
117
|
+
phone-input::part(dropdown) { }
|
|
118
|
+
phone-input::part(option) { }
|
|
119
|
+
phone-input::part(message) { }
|
|
120
|
+
phone-input::part(label) { }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm test # validation checks
|
|
127
|
+
npm run demo # live demo at http://localhost:5173
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Notes
|
|
131
|
+
|
|
132
|
+
- The default IP service (geojs.io) is free and needs no key. The component makes one request per page, shared by all instances. For production, set `geo-url` to your own endpoint.
|
|
133
|
+
- If the user has already picked a country or started typing, a late IP result does not override their choice.
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sarvspl/phone-input",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mobile number input web component: IP country auto-detect, searchable country picker, per-country mobile validation. Works in React, Vue, Angular, plain HTML.",
|
|
5
|
+
"main": "./src/phone-input.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test.js",
|
|
8
|
+
"demo": "vite demo"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"phone",
|
|
12
|
+
"mobile",
|
|
13
|
+
"input",
|
|
14
|
+
"web-component",
|
|
15
|
+
"country",
|
|
16
|
+
"validation"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"libphonenumber-js": "^1.13.14"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"vite": "^8.3.1"
|
|
26
|
+
},
|
|
27
|
+
"types": "./src/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./src/index.d.ts",
|
|
31
|
+
"default": "./src/phone-input.js"
|
|
32
|
+
},
|
|
33
|
+
"./validate": "./src/validate.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": [
|
|
39
|
+
"./src/phone-input.js"
|
|
40
|
+
]
|
|
41
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface PhoneDetails {
|
|
2
|
+
valid: boolean;
|
|
3
|
+
/** "+919876543210" when valid, else "" */
|
|
4
|
+
e164: string;
|
|
5
|
+
/** ISO 3166-1 alpha-2, e.g. "IN" */
|
|
6
|
+
country: string;
|
|
7
|
+
dialCode: string;
|
|
8
|
+
national: string;
|
|
9
|
+
international: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export declare function validatePhone(input: string, country?: string): PhoneDetails;
|
|
13
|
+
export declare function detectCountry(url?: string): Promise<string>;
|
|
14
|
+
|
|
15
|
+
export declare class PhoneInput extends HTMLElement {
|
|
16
|
+
value: string;
|
|
17
|
+
country: string;
|
|
18
|
+
readonly valid: boolean;
|
|
19
|
+
readonly details: PhoneDetails;
|
|
20
|
+
readonly form: HTMLFormElement | null;
|
|
21
|
+
open(): void;
|
|
22
|
+
close(): void;
|
|
23
|
+
checkValidity(): boolean;
|
|
24
|
+
reportValidity(): boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
declare global {
|
|
28
|
+
interface HTMLElementTagNameMap {
|
|
29
|
+
'phone-input': PhoneInput;
|
|
30
|
+
}
|
|
31
|
+
interface HTMLElementEventMap {
|
|
32
|
+
'phone-change': CustomEvent<PhoneDetails>;
|
|
33
|
+
'country-change': CustomEvent<{ country: string; dialCode: string }>;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { getCountries, getCountryCallingCode, getExampleNumber, AsYouType } from 'libphonenumber-js/mobile';
|
|
2
|
+
import examples from 'libphonenumber-js/mobile/examples';
|
|
3
|
+
import { validatePhone } from './validate.js';
|
|
4
|
+
|
|
5
|
+
export { validatePhone };
|
|
6
|
+
|
|
7
|
+
const GEO_URL = 'https://get.geojs.io/v1/ip/country.json';
|
|
8
|
+
const geoCache = {};
|
|
9
|
+
|
|
10
|
+
// IP -> ISO country code. One request per URL per page, falls back to browser locale.
|
|
11
|
+
export function detectCountry(url = GEO_URL) {
|
|
12
|
+
return (geoCache[url] ??= fetch(url)
|
|
13
|
+
.then((r) => r.json())
|
|
14
|
+
.then((j) => String(j.country_code || j.countryCode || j.country || '').toUpperCase())
|
|
15
|
+
.catch(() => '')
|
|
16
|
+
.then((c) => c || (navigator.language.split('-')[1] || '').toUpperCase()));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CSS = `
|
|
20
|
+
:host{
|
|
21
|
+
--pi-primary:#6366f1; --pi-bg:#fff; --pi-text:#0f172a; --pi-muted:#64748b;
|
|
22
|
+
--pi-border:#e2e8f0; --pi-hover:#f1f5f9; --pi-error:#ef4444; --pi-success:#10b981;
|
|
23
|
+
--pi-radius:14px; --pi-height:52px; --pi-font:inherit; --pi-font-size:16px;
|
|
24
|
+
--pi-shadow:0 20px 45px -12px rgba(15,23,42,.28);
|
|
25
|
+
display:block; position:relative; font-family:var(--pi-font); color:var(--pi-text);
|
|
26
|
+
}
|
|
27
|
+
:host([disabled]){opacity:.55;pointer-events:none}
|
|
28
|
+
*{box-sizing:border-box}
|
|
29
|
+
[hidden]{display:none!important}
|
|
30
|
+
label{display:block;font-size:14px;font-weight:600;margin-bottom:8px}
|
|
31
|
+
.field{display:flex;align-items:center;height:var(--pi-height);background:var(--pi-bg);
|
|
32
|
+
border:1.5px solid var(--pi-border);border-radius:var(--pi-radius);transition:border-color .2s,box-shadow .2s}
|
|
33
|
+
.field:focus-within{border-color:var(--pi-primary);box-shadow:0 0 0 4px color-mix(in srgb,var(--pi-primary) 18%,transparent)}
|
|
34
|
+
.valid .field{border-color:var(--pi-success)}
|
|
35
|
+
.invalid .field{border-color:var(--pi-error);animation:shake .35s}
|
|
36
|
+
.invalid .field:focus-within{box-shadow:0 0 0 4px color-mix(in srgb,var(--pi-error) 18%,transparent)}
|
|
37
|
+
.country{display:flex;align-items:center;gap:8px;height:100%;padding:0 12px 0 14px;background:none;border:0;
|
|
38
|
+
border-right:1.5px solid var(--pi-border);border-radius:var(--pi-radius) 0 0 var(--pi-radius);
|
|
39
|
+
color:inherit;font:inherit;font-size:var(--pi-font-size);cursor:pointer;transition:background .15s}
|
|
40
|
+
.country:hover,.country[aria-expanded=true]{background:var(--pi-hover)}
|
|
41
|
+
.country:focus-visible{outline:2px solid var(--pi-primary);outline-offset:-2px}
|
|
42
|
+
.flag{width:24px;height:18px;object-fit:cover;border-radius:4px;box-shadow:0 0 0 1px rgba(0,0,0,.08);flex:none}
|
|
43
|
+
.chev{width:14px;height:14px;color:var(--pi-muted);transition:transform .2s}
|
|
44
|
+
[aria-expanded=true] .chev{transform:rotate(180deg)}
|
|
45
|
+
.tel{flex:1;min-width:0;height:100%;border:0;outline:0;background:none;padding:0 14px;
|
|
46
|
+
font:inherit;font-size:var(--pi-font-size);color:inherit;letter-spacing:.03em}
|
|
47
|
+
.tel::placeholder{color:var(--pi-muted);opacity:.55}
|
|
48
|
+
.ok{width:20px;height:20px;margin-right:14px;color:var(--pi-success);opacity:0;transform:scale(.4);transition:.25s cubic-bezier(.34,1.56,.64,1)}
|
|
49
|
+
.valid .ok{opacity:1;transform:none}
|
|
50
|
+
.dropdown{position:absolute;z-index:1000;left:0;right:0;margin-top:6px;padding:8px;background:var(--pi-bg);
|
|
51
|
+
border:1px solid var(--pi-border);border-radius:var(--pi-radius);box-shadow:var(--pi-shadow);animation:pop .16s ease-out}
|
|
52
|
+
.search{width:100%;height:40px;padding:0 12px;border:1.5px solid var(--pi-border);border-radius:calc(var(--pi-radius) - 4px);
|
|
53
|
+
background:var(--pi-hover);color:inherit;font:inherit;font-size:14px;outline:0}
|
|
54
|
+
.search:focus{border-color:var(--pi-primary);background:var(--pi-bg)}
|
|
55
|
+
ul{list-style:none;margin:8px 0 0;padding:0;max-height:260px;overflow-y:auto;overscroll-behavior:contain}
|
|
56
|
+
ul:not(:has(li[role=option]:not([hidden])))::after{content:"No countries found";display:block;padding:14px;text-align:center;color:var(--pi-muted);font-size:14px}
|
|
57
|
+
li[role=option]{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;cursor:pointer;font-size:14px}
|
|
58
|
+
li.active{background:var(--pi-hover)}
|
|
59
|
+
li[aria-selected=true]{background:color-mix(in srgb,var(--pi-primary) 12%,transparent);color:var(--pi-primary);font-weight:600}
|
|
60
|
+
.name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
61
|
+
li .dial{color:var(--pi-muted);font-variant-numeric:tabular-nums}
|
|
62
|
+
.sep{height:1px;background:var(--pi-border);margin:6px 4px}
|
|
63
|
+
.msg{min-height:20px;margin-top:6px;font-size:13px;color:var(--pi-error)}
|
|
64
|
+
@keyframes shake{25%{transform:translateX(-4px)}50%{transform:translateX(4px)}75%{transform:translateX(-2px)}}
|
|
65
|
+
@keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const HTML = `
|
|
69
|
+
<label part="label" for="tel" hidden></label>
|
|
70
|
+
<div class="root">
|
|
71
|
+
<div class="field" part="field">
|
|
72
|
+
<button type="button" class="country" part="country-button" aria-haspopup="listbox" aria-expanded="false">
|
|
73
|
+
<img class="flag" part="flag" alt="">
|
|
74
|
+
<span class="dial" part="dial-code"></span>
|
|
75
|
+
<svg class="chev" viewBox="0 0 20 20" fill="currentColor"><path d="M5.3 7.3a1 1 0 0 1 1.4 0L10 10.6l3.3-3.3a1 1 0 1 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 0-1.4z"/></svg>
|
|
76
|
+
</button>
|
|
77
|
+
<input id="tel" class="tel" part="input" type="tel" inputmode="tel" autocomplete="tel-national">
|
|
78
|
+
<svg class="ok" part="valid-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m8 12 3 3 5-6"/></svg>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="dropdown" part="dropdown" hidden>
|
|
81
|
+
<input class="search" part="search" type="search" autocomplete="off" aria-label="Search country">
|
|
82
|
+
<ul role="listbox" part="list"></ul>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="msg" part="message" aria-live="polite"></div>
|
|
85
|
+
</div>`;
|
|
86
|
+
|
|
87
|
+
class PhoneInput extends HTMLElement {
|
|
88
|
+
static formAssociated = true;
|
|
89
|
+
static observedAttributes = ['disabled', 'placeholder', 'label', 'default-country'];
|
|
90
|
+
|
|
91
|
+
#internals = this.attachInternals();
|
|
92
|
+
#country = '';
|
|
93
|
+
#touched = false;
|
|
94
|
+
#locked = false; // user picked a country or typed; ignore late IP result
|
|
95
|
+
#result = validatePhone('', '');
|
|
96
|
+
#names;
|
|
97
|
+
#onDocClick = (e) => { if (!e.composedPath().includes(this)) this.close(); };
|
|
98
|
+
|
|
99
|
+
constructor() {
|
|
100
|
+
super();
|
|
101
|
+
const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
102
|
+
root.innerHTML = `<style>${CSS}</style>${HTML}`;
|
|
103
|
+
const $ = (s) => root.querySelector(s);
|
|
104
|
+
Object.assign(this, { $root: $('.root'), $btn: $('.country'), $flag: $('.flag'), $dial: $('.country .dial'),
|
|
105
|
+
$input: $('.tel'), $drop: $('.dropdown'), $search: $('.search'), $list: $('ul'), $msg: $('.msg'), $label: $('label') });
|
|
106
|
+
|
|
107
|
+
this.$btn.onclick = () => (this.$drop.hidden ? this.open() : this.close());
|
|
108
|
+
this.$input.addEventListener('input', (e) => this.#onInput(e));
|
|
109
|
+
this.$input.addEventListener('blur', () => { this.#touched = true; this.#update(); });
|
|
110
|
+
this.$search.addEventListener('input', () => this.#filter());
|
|
111
|
+
this.$search.addEventListener('keydown', (e) => this.#onSearchKey(e));
|
|
112
|
+
this.$list.addEventListener('click', (e) => {
|
|
113
|
+
const li = e.target.closest('li[data-c]');
|
|
114
|
+
if (li) this.#pick(li.dataset.c);
|
|
115
|
+
});
|
|
116
|
+
this.$drop.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.close(); this.$btn.focus(); } });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
connectedCallback() {
|
|
120
|
+
document.addEventListener('click', this.#onDocClick);
|
|
121
|
+
if (this.#country) return;
|
|
122
|
+
this.#renderList();
|
|
123
|
+
const initial = this.getAttribute('value');
|
|
124
|
+
const fallback = (this.getAttribute('default-country') || 'US').toUpperCase();
|
|
125
|
+
this.#setCountry(fallback);
|
|
126
|
+
if (initial) return (this.value = initial);
|
|
127
|
+
if (!this.hasAttribute('no-detect')) {
|
|
128
|
+
detectCountry(this.getAttribute('geo-url') || undefined).then((c) => {
|
|
129
|
+
if (!this.#locked && c && c !== this.#country && this.#isSupported(c)) this.#setCountry(c);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
disconnectedCallback() { document.removeEventListener('click', this.#onDocClick); }
|
|
135
|
+
|
|
136
|
+
attributeChangedCallback(name, _, v) {
|
|
137
|
+
if (name === 'disabled') this.$input.disabled = this.$btn.disabled = v !== null;
|
|
138
|
+
if (name === 'label') { this.$label.textContent = v || ''; this.$label.hidden = !v; }
|
|
139
|
+
if (name === 'placeholder' && this.#country) this.#setCountry(this.#country);
|
|
140
|
+
if (name === 'default-country' && v && this.#country && !this.#locked) this.#setCountry(v.toUpperCase());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- public API ----
|
|
144
|
+
get value() { return this.#result.e164 || this.$input.value; }
|
|
145
|
+
set value(v) {
|
|
146
|
+
this.$input.value = String(v ?? '');
|
|
147
|
+
this.#locked ||= !!v;
|
|
148
|
+
this.#onInput({});
|
|
149
|
+
}
|
|
150
|
+
get country() { return this.#country; }
|
|
151
|
+
set country(c) { if (this.#isSupported(c?.toUpperCase())) { this.#locked = true; this.#setCountry(c.toUpperCase()); } }
|
|
152
|
+
get valid() { return this.#result.valid; }
|
|
153
|
+
get details() { return { ...this.#result }; }
|
|
154
|
+
get form() { return this.#internals.form; }
|
|
155
|
+
get name() { return this.getAttribute('name'); }
|
|
156
|
+
checkValidity() { return this.#internals.checkValidity(); }
|
|
157
|
+
reportValidity() { this.#touched = true; this.#update(); return this.#internals.reportValidity(); }
|
|
158
|
+
|
|
159
|
+
open() {
|
|
160
|
+
this.$drop.hidden = false;
|
|
161
|
+
this.$btn.setAttribute('aria-expanded', 'true');
|
|
162
|
+
this.$search.value = '';
|
|
163
|
+
this.#filter();
|
|
164
|
+
const sel = this.$list.querySelector('li[aria-selected=true]');
|
|
165
|
+
this.#setActive(sel);
|
|
166
|
+
sel?.scrollIntoView({ block: 'center' });
|
|
167
|
+
this.$search.focus();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
close() {
|
|
171
|
+
if (this.$drop.hidden) return;
|
|
172
|
+
this.$drop.hidden = true;
|
|
173
|
+
this.$btn.setAttribute('aria-expanded', 'false');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
formResetCallback() { this.$input.value = ''; this.#touched = false; this.#update(); }
|
|
177
|
+
formDisabledCallback(d) { this.$input.disabled = this.$btn.disabled = d; }
|
|
178
|
+
|
|
179
|
+
// ---- internals ----
|
|
180
|
+
#isSupported(c) { return !!c && getCountries().includes(c); }
|
|
181
|
+
#flagUrl(c) { return (this.getAttribute('flag-url') || 'https://flagcdn.com/{code}.svg').replace('{code}', c.toLowerCase()); }
|
|
182
|
+
|
|
183
|
+
#renderList() {
|
|
184
|
+
this.#names = new Intl.DisplayNames([this.getAttribute('locale') || navigator.language, 'en'], { type: 'region' });
|
|
185
|
+
const all = getCountries()
|
|
186
|
+
.map((c) => ({ c, name: this.#names.of(c) || c, dial: getCountryCallingCode(c) }))
|
|
187
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
188
|
+
const only = this.#codes('only-countries');
|
|
189
|
+
const list = only.length ? all.filter((x) => only.includes(x.c)) : all;
|
|
190
|
+
const pref = this.#codes('preferred-countries');
|
|
191
|
+
const item = ({ c, name, dial }) =>
|
|
192
|
+
`<li role="option" part="option" data-c="${c}" data-q="${`${name} ${c} +${dial}`.toLowerCase()}">` +
|
|
193
|
+
`<img class="flag" src="${this.#flagUrl(c)}" alt="" loading="lazy"><span class="name">${name}</span><span class="dial">+${dial}</span></li>`;
|
|
194
|
+
const top = pref.map((c) => list.find((x) => x.c === c)).filter(Boolean);
|
|
195
|
+
this.$list.innerHTML = top.map(item).join('') + (top.length ? '<li class="sep" role="separator"></li>' : '') + list.map(item).join('');
|
|
196
|
+
this.$list.querySelectorAll('img').forEach((img) => (img.onerror = () => (img.style.visibility = 'hidden')));
|
|
197
|
+
this.$flag.onerror = () => (this.$flag.style.visibility = 'hidden');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#codes(attr) {
|
|
201
|
+
return (this.getAttribute(attr) || '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#setCountry(c) {
|
|
205
|
+
if (!this.#isSupported(c)) c = 'US';
|
|
206
|
+
const changed = c !== this.#country;
|
|
207
|
+
this.#country = c;
|
|
208
|
+
this.$flag.style.visibility = '';
|
|
209
|
+
this.$flag.src = this.#flagUrl(c);
|
|
210
|
+
this.$dial.textContent = '+' + getCountryCallingCode(c);
|
|
211
|
+
this.$btn.setAttribute('aria-label', `Country: ${this.#names.of(c)} (+${getCountryCallingCode(c)})`);
|
|
212
|
+
this.$input.placeholder = this.getAttribute('placeholder') ?? getExampleNumber(c, examples)?.formatNational() ?? '';
|
|
213
|
+
this.$list.querySelectorAll('li[data-c]').forEach((li) => li.setAttribute('aria-selected', li.dataset.c === c));
|
|
214
|
+
if (changed) this.dispatchEvent(new CustomEvent('country-change', { detail: { country: c, dialCode: '+' + getCountryCallingCode(c) }, bubbles: true, composed: true }));
|
|
215
|
+
this.#update();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#pick(c) {
|
|
219
|
+
this.#locked = true;
|
|
220
|
+
this.#setCountry(c);
|
|
221
|
+
this.close();
|
|
222
|
+
this.$input.focus();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#onInput(e) {
|
|
226
|
+
const el = this.$input;
|
|
227
|
+
let v = el.value.replace(/[^\d+()\-.\s]/g, '');
|
|
228
|
+
if (e.inputType) this.#locked = true;
|
|
229
|
+
// Typed/pasted "+44 7400..." -> switch country, keep national part.
|
|
230
|
+
if (v.trim().startsWith('+')) {
|
|
231
|
+
const t = new AsYouType();
|
|
232
|
+
t.input(v);
|
|
233
|
+
const c = t.getCountry();
|
|
234
|
+
if (c && this.#isSupported(c)) {
|
|
235
|
+
this.#setCountry(c);
|
|
236
|
+
v = t.getNationalNumber();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Don't reformat on delete, else "(212)" re-adds the ")" the user just removed.
|
|
240
|
+
// ponytail: formats only when caret is at end; mid-string edits stay raw until next append.
|
|
241
|
+
const atEnd = el.selectionStart == null || el.selectionStart >= el.value.length;
|
|
242
|
+
if (!e.inputType?.startsWith('delete') && atEnd && !v.startsWith('+')) v = new AsYouType(this.#country).input(v);
|
|
243
|
+
if (el.value !== v) el.value = v;
|
|
244
|
+
this.#update();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#update() {
|
|
248
|
+
const raw = this.$input.value.trim();
|
|
249
|
+
const r = (this.#result = validatePhone(raw, this.#country));
|
|
250
|
+
const state = !raw ? '' : r.valid ? 'valid' : this.#touched ? 'invalid' : '';
|
|
251
|
+
this.$root.className = 'root ' + state;
|
|
252
|
+
this.$input.setAttribute('aria-invalid', state === 'invalid');
|
|
253
|
+
const name = this.#names?.of(this.#country) || this.#country;
|
|
254
|
+
this.$msg.textContent = state === 'invalid' ? (this.getAttribute('error-text') || `Enter a valid ${name} mobile number`).replace('{country}', name) : '';
|
|
255
|
+
|
|
256
|
+
this.#internals.setFormValue(r.valid ? r.e164 : raw);
|
|
257
|
+
if (this.hasAttribute('required') && !raw) this.#internals.setValidity({ valueMissing: true }, 'Mobile number is required', this.$input);
|
|
258
|
+
else if (raw && !r.valid) this.#internals.setValidity({ typeMismatch: true }, this.$msg.textContent || 'Invalid mobile number', this.$input);
|
|
259
|
+
else this.#internals.setValidity({});
|
|
260
|
+
|
|
261
|
+
this.dispatchEvent(new CustomEvent('phone-change', { detail: { ...r, country: r.country || this.#country }, bubbles: true, composed: true }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#visible() { return [...this.$list.querySelectorAll('li[data-c]:not([hidden])')]; }
|
|
265
|
+
|
|
266
|
+
#setActive(li) {
|
|
267
|
+
this.$list.querySelector('li.active')?.classList.remove('active');
|
|
268
|
+
li?.classList.add('active');
|
|
269
|
+
li?.scrollIntoView({ block: 'nearest' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#filter() {
|
|
273
|
+
const q = this.$search.value.trim().toLowerCase();
|
|
274
|
+
this.$list.querySelectorAll('li').forEach((li) => {
|
|
275
|
+
li.hidden = li.dataset.q ? !li.dataset.q.includes(q) : !!q;
|
|
276
|
+
});
|
|
277
|
+
if (q) this.#setActive(this.#visible()[0]);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#onSearchKey(e) {
|
|
281
|
+
const items = this.#visible();
|
|
282
|
+
const i = items.indexOf(this.$list.querySelector('li.active'));
|
|
283
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
const n = e.key === 'ArrowDown' ? Math.min(i + 1, items.length - 1) : Math.max(i - 1, 0);
|
|
286
|
+
this.#setActive(items[n]);
|
|
287
|
+
} else if (e.key === 'Enter') {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
if (items[i]) this.#pick(items[i].dataset.c);
|
|
290
|
+
} else if (e.key === 'Tab') {
|
|
291
|
+
this.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!customElements.get('phone-input')) customElements.define('phone-input', PhoneInput);
|
|
297
|
+
export { PhoneInput };
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// 'mobile' metadata: isValid() is true only for mobile numbers, not landlines.
|
|
2
|
+
import { parsePhoneNumberFromString } from 'libphonenumber-js/mobile';
|
|
3
|
+
|
|
4
|
+
export function validatePhone(input, country) {
|
|
5
|
+
const p = parsePhoneNumberFromString(String(input || ''), country || undefined);
|
|
6
|
+
const valid = !!p && p.isValid();
|
|
7
|
+
return {
|
|
8
|
+
valid,
|
|
9
|
+
e164: valid ? p.number : '',
|
|
10
|
+
country: p?.country || country || '',
|
|
11
|
+
dialCode: p ? '+' + p.countryCallingCode : '',
|
|
12
|
+
national: p ? p.formatNational() : String(input || ''),
|
|
13
|
+
international: p ? p.formatInternational() : '',
|
|
14
|
+
};
|
|
15
|
+
}
|