feat(input-tel): new component LionInputTel

This commit is contained in:
Thijs Louisse 2022-03-15 23:39:25 +01:00
parent 8314b4b3ab
commit a882c94f11
62 changed files with 17096 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/input-tel': minor
---
New component "LionInputTel"

View file

@ -0,0 +1,254 @@
# Inputs >> Input Tel >> Features ||20
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { Unparseable } from '@lion/form-core';
import { localize } from '@lion/localize';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel/define';
import './src/h-region-code-table.js';
import '../../../docs/systems/form/assets/h-output.js';
```
## Regions: some context
Say we have the following telephone number from Madrid, Spain: `+34919930432`.
It contains a [country code](https://en.wikipedia.org/wiki/Country_code) (34), an [area code](https://en.wikipedia.org/wiki/Telephone_numbering_plan#Area_code) (91) and a [dial code](https://en.wikipedia.org/wiki/Mobile_dial_code) (+34 91).
Input Tel interprets phone numbers based on their [region code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2): a two character long region representation('ES' in the telephone number above).
The table below lists all possible regions worldwide. When [allowed regions](#allowed-regions) are not configured,
all of them will be supported as values of Input Tel.
```js story
export const regionCodesTable = () => html`<h-region-code-table></h-region-code-table>`;
```
### Active region
The active region (accessible via readonly accessor `.activeRegion`) determines how validation and formatting
should be applied. It is dependent on the following factors:
- [allowed regions](#allowed-regions): a list that determines what is allowed to become .activeRegion. If
[.allowedRegions has only one entry](#restrict-to-one-region), .activeRegion will always be this value.
- the modelValue or viewValue: once it contains sufficient info to derive its region code (and
the derived code is inside [allowed regions](#allowed-regions) if configured)
- active locale (and the derived locale is inside [allowed regions](#allowed-regions) if configured)
What follows from the list above is that .activeRegion can change dynamically, after a value
change in the text box by the user (or when locales or allowed regions would be changed by the
Application Developer).
### How active region is computed
The following heuristic will be applied:
1. check for **allowed regions**: if one region defined in .allowedRegions, use it.
2. check for **user input**: try to derive active region from user input
3. check for **locale**: try to get the region from locale (`html[lang]` attribute)
```js preview-story
export const heuristic = () => {
const initialAllowedRegions = ['CN', 'ES'];
const [inputTelRef, outputRef, selectRef] = [createRef(), createRef(), createRef()];
const setDerivedActiveRegionScenario = (
scenarioToSet,
inputTel = inputTelRef.value,
output = outputRef.value,
) => {
if (scenarioToSet === 'only-allowed-region') {
// activeRegion will be the top allowed region, which is 'NL'
inputTel.modelValue = undefined;
inputTel.allowedRegions = ['NL']; // activeRegion will always be the only option
output.innerText = '.activeRegion (NL) is only allowed region';
} else if (scenarioToSet === 'user-input') {
// activeRegion will be based on phone number => 'BE'
inputTel.allowedRegions = ['NL', 'BE', 'DE'];
inputTel.modelValue = '+3261234567'; // BE number
output.innerText = '.activeRegion (BE) is derived (since within allowedRegions)';
} else if (scenarioToSet === 'locale') {
localize.locale = 'en-GB';
// activeRegion will be `html[lang]`
inputTel.modelValue = undefined;
inputTel.allowedRegions = undefined;
output.innerText = `.activeRegion (${inputTel._langIso}) set to locale when inside allowed or all regions`;
} else {
output.innerText = '';
}
};
return html`
<select
aria-label="Set scenario"
@change="${({ target }) => setDerivedActiveRegionScenario(target.value)}"
>
<option value="">--- select scenario ---</option>
<option value="only-allowed-region">1. only allowed region</option>
<option value="user-input">2. user input</option>
<option value="locale">3. locale</option>
</select>
<output style="display:block; min-height: 1.5em;" id="myOutput" ${ref(outputRef)}></output>
<lion-input-tel
${ref(inputTelRef)}
@model-value-changed="${({ detail }) => {
if (detail.isTriggeredByUser && selectRef.value) {
selectRef.value.value = '';
}
}}"
name="phoneNumber"
label="Active region"
.allowedRegions="${initialAllowedRegions}"
></lion-input-tel>
<h-output
.show="${[
'activeRegion',
{
name: 'all or allowed regions',
processor: el => JSON.stringify(el._allowedOrAllRegions),
},
'modelValue',
]}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Allowed regions
`.allowedRegions` is an array of one or more region codes.
Once it is configured, validation and formatting will be restricted to those
values that are present in this list.
> Note that for [InputTelDropdown](../input-tel-dropdown/index.md), only allowed regions will
> be shown in the dropdown list.
```js preview-story
export const allowedRegions = () => html`
<lion-input-tel
label="Allowed regions 'NL', 'BE', 'DE'"
help-text="Type '+31'(NL), '+32'(BE) or '+49'(DE) and see how activeRegion changes"
.allowedRegions="${['NL', 'BE', 'DE']}"
.modelValue="${'+31612345678'}"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
### Restrict to one region
When one allowed region is configured, validation and formatting will be restricted to just that
region (that means that changes of the region via viewValue won't have effect).
```js preview-story
export const oneAllowedRegion = () => html`
<lion-input-tel
label="Only allowed region 'DE'"
help-text="Restricts validation / formatting to one region"
.allowedRegions="${['DE']}"
.modelValue="${'+31612345678'}"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'activeRegion', 'validationStates']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Format strategy
Determines what the formatter output should look like.
Formatting strategies as provided by awesome-phonenumber / google-libphonenumber.
Possible values:
| strategy | output |
| :------------ | ---------------------: |
| e164 | `+46707123456` |
| international | `+46 70 712 34 56` |
| national | `070-712 34 56` |
| significant | `707123456` |
| rfc3966 | `tel:+46-70-712-34-56` |
Also see:
- [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const formatStrategy = () => {
const inputTel = createRef();
return html`
<select @change="${({ target }) => (inputTel.value.formatStrategy = target.value)}">
<option value="e164">e164</option>
<option value="international">international</option>
<option value="national">national</option>
<option value="significant">significant</option>
<option value="rfc3966">rfc3966</option>
</select>
<lion-input-tel
${ref(inputTel)}
label="Format strategy"
help-text="Choose a strategy above"
.modelValue=${'+46707123456'}
format-strategy="national"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['modelValue', 'formatStrategy']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Live format
Type '6' in the example below to see how the phone number is formatted during typing.
See [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const liveFormat = () => html`
<lion-input-tel
label="Realtime format on user input"
help-text="Partial numbers are also formatted"
.modelValue=${new Unparseable('+31')}
format-strategy="international"
live-format
name="phoneNumber"
></lion-input-tel>
`;
```
## Active phone number type
The readonly acessor `.activePhoneNumberType` outputs the current phone number type, based on
the textbox value.
Possible types: `fixed-line`, `fixed-line-or-mobile`, `mobile`, `pager`, `personal-number`, `premium-rate`, `shared-cost`, `toll-free`, `uan`, `voip`, `unknown`
Also see:
- [awesome-phonenumber documentation](https://www.npmjs.com/package/awesome-phonenumber)
```js preview-story
export const activePhoneNumberType = () => html`
<lion-input-tel
label="Active phone number type"
.modelValue="${'+31612345678'}"
format-strategy="international"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${['activePhoneNumberType']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```

View file

@ -0,0 +1,3 @@
# Inputs >> Input Tel ||20
-> go to Overview

View file

@ -0,0 +1,59 @@
# Inputs >> Input Tel >> Overview ||10
Input field for entering phone numbers, including validation, formatting and mobile keyboard support.
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel/define';
import '../../../docs/systems/form/assets/h-output.js';
```
```js preview-story
export const main = () => {
return html`
<lion-input-tel
.modelValue="${'+639921343959'}"
live-format
label="Telephone number"
name="phoneNumber"
></lion-input-tel>
<h-output
.show="${[
'activeRegion',
{
name: 'all or allowed regions',
processor: el => JSON.stringify(el._allowedOrAllRegions),
},
'modelValue',
]}" 'modelValue']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
};
```
## Features
- Extends our [input](../input/overview.md)
- Shows a mobile telephone keypad on mobile (by having a native `<input inputmode="tel">` inside)
- Can be configured with a list of allowed region codes
- Will be preconfigured with region derived from locale
- Has the [e164 standard format](https://en.wikipedia.org/wiki/E.164) as modelValue
- Uses [awesome-phonenumber](https://www.npmjs.com/package/awesome-phonenumber) (a performant, concise version of [google-lib-phonenumber](https://www.npmjs.com/package/google-libphonenumber)):
- Formats phone numbers, based on region code
- Validates phone numbers, based on region code
- Lazy loads awesome-phonenumber, so that the first paint of this component will be brought to your screen as quick as possible
## Installation
```bash
npm i --save @lion/input-tel
```
```js
import { LionInputTel } from '@lion/input-tel';
// or
import '@lion/input-tel/define';
```

View file

@ -0,0 +1,107 @@
import { LitElement, css, html, repeat, ScopedStylesController } from '@lion/core';
import { regionMetaList } from '../../select-rich/src/regionMetaList.js';
export class HRegionCodeTable extends LitElement {
static properties = {
regionMeta: Object,
};
constructor() {
super();
/** @type {ScopedStylesController} */
this.scopedStylesController = new ScopedStylesController(this);
}
/**
* @param {CSSResult} scope
*/
static scopedStyles(scope) {
return css`
/* Custom input range styling comes here, be aware that this won't work for polyfilled browsers */
.${scope} .sr-only {
position: absolute;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
.${scope} table {
position: relative;
height: 300px;
}
.${scope} th {
border-left: none;
border-right: none;
position: sticky;
top: -1px;
}
.${scope} th .backdrop {
background-color: white;
opacity: 0.95;
filter: blur(4px);
position: absolute;
inset: -5px;
}
.${scope} th .content {
position: relative;
}
.${scope} td {
border-left: none;
border-right: none;
}
`;
}
// Render to light dom, so global table styling will be applied
createRenderRoot() {
return this;
}
render() {
const finalRegionMetaList = this.regionMetaList || regionMetaList;
return html`
<table role="table">
<caption class="sr-only">
Region codes
</caption>
<thead>
<tr>
<th align="left">
<span class="backdrop"></span><span class="content">country name</span>
</th>
<th align="right">
<span class="backdrop"></span><span class="content">region code</span>
</th>
<th align="right">
<span class="backdrop"></span><span class="content">country code</span>
</th>
</tr>
</thead>
<tbody>
${repeat(
finalRegionMetaList,
regionMeta => regionMeta.regionCode,
({ regionCode, countryCode, flagSymbol, nameForLocale }) =>
html` <tr>
<td align="left"><span aria-hidden="true">${flagSymbol}</span> ${nameForLocale}</td>
<td align="right">${regionCode}</td>
<td align="right">${countryCode}</td>
</tr>`,
)}
</tbody>
</table>
`;
}
}
customElements.define('h-region-code-table', HRegionCodeTable);

View file

@ -0,0 +1,3 @@
# Lion Input Tel
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)

View file

@ -0,0 +1 @@
import './lion-input-tel.js';

View file

@ -0,0 +1,3 @@
# Lion Input Tel Features
[=> See Source <=](../../../docs/components/inputs/input-tel/features.md)

View file

@ -0,0 +1,3 @@
# Lion Input Tel Overview
[=> See Source <=](../../../docs/components/inputs/input-tel/overview.md)

View file

@ -0,0 +1,3 @@
export { LionInputTel } from './src/LionInputTel.js';
export { PhoneUtilManager } from './src/PhoneUtilManager.js';
export { liveFormatPhoneNumber } from './src/preprocessors.js';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
import { LionInputTel } from './src/LionInputTel.js';
customElements.define('lion-input-tel', LionInputTel);

View file

@ -0,0 +1,63 @@
{
"name": "@lion/input-tel",
"version": "0.0.0",
"description": "Input field for entering phone numbers, including validation, formatting and mobile keyboard support.",
"license": "MIT",
"author": "ing-bank",
"homepage": "https://github.com/ing-bank/lion/",
"repository": {
"type": "git",
"url": "https://github.com/ing-bank/lion.git",
"directory": "packages/input-tel"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"custom-elements.json",
"docs",
"src",
"test",
"test-helpers",
"test-suites",
"translations",
"types"
],
"scripts": {
"custom-elements-manifest": "custom-elements-manifest analyze --litelement --exclude \"docs/**/*\" \"test-helpers/**/*\"",
"debug": "cd ../../ && npm run debug -- --group input-tel",
"debug:firefox": "cd ../../ && npm run debug:firefox -- --group input-tel",
"debug:webkit": "cd ../../ && npm run debug:webkit -- --group input-tel",
"publish-docs": "node ../../packages-node/publish-docs/src/cli.js --github-url https://github.com/ing-bank/lion/ --git-root-dir ../../",
"prepublishOnly": "npm run publish-docs && npm run custom-elements-manifest",
"test": "cd ../../ && npm run test:browser -- --group input-tel"
},
"sideEffects": [
"lion-input-tel.js"
],
"dependencies": {
"@lion/core": "0.21.1",
"@lion/form-core": "0.16.0",
"@lion/input": "0.16.0",
"@lion/localize": "0.23.0"
},
"keywords": [
"input",
"input-tel",
"lion",
"web-components"
],
"publishConfig": {
"access": "public"
},
"customElements": "custom-elements.json",
"customElementsManifest": "custom-elements.json",
"exports": {
".": "./index.js",
"./define": "./define.js",
"./test-suites": "./test-suites/index.js",
"./test-helpers": "./test-helpers/index.js",
"./docs/*": "./docs/*"
}
}

View file

@ -0,0 +1,317 @@
import { Unparseable } from '@lion/form-core';
import { LocalizeMixin, localize } from '@lion/localize';
import { LionInput } from '@lion/input';
import { PhoneUtilManager } from './PhoneUtilManager.js';
import { liveFormatPhoneNumber } from './preprocessors.js';
import { formatPhoneNumber } from './formatters.js';
import { parsePhoneNumber } from './parsers.js';
import { IsPhoneNumber } from './validators.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {import('../types').PhoneNumberType} PhoneNumberType
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
* @typedef {FormatOptions & {regionCode: RegionCode; formatStrategy: FormatStrategy}} FormatOptionsTel
*/
export class LionInputTel extends LocalizeMixin(LionInput) {
/**
* @configure LitElement
*/
static properties = {
allowedRegions: { type: Array },
formatStrategy: { type: String, attribute: 'format-strategy' },
activeRegion: { type: String },
_phoneUtil: { type: Object, state: true },
_needsLightDomRender: { type: Number, state: true },
_derivedRegionCode: { type: String, state: true },
};
/**
* Currently active region based on:
* 1. allowed regions: get the region from configured allowed regions (if one entry)
* 2. user input: try to derive active region from user input
* 3. locale: try to get the region from locale (`html[lang]` attribute)
* @readonly
* @property {RegionCode|undefined}activeRegion
*/
get activeRegion() {
return this.__activeRegion;
}
// @ts-ignore read only
// eslint-disable-next-line class-methods-use-this, no-empty-function
set activeRegion(v) {}
/**
* Type of phone number, derived from textbox value. Enum with values:
* -'fixed-line'
* -'fixed-line-or-mobile'
* -'mobile'
* -'pager'
* -'personal-number'
* -'premium-rate'
* -'shared-cost'
* -'toll-free'
* -'uan'
* -'voip'
* -'unknown'
* See https://www.npmjs.com/package/awesome-phonenumber
* @readonly
* @property {PhoneNumberType|undefined} activePhoneNumberType
*/
get activePhoneNumberType() {
let pn;
try {
pn = this._phoneUtil && this._phoneUtil(this.modelValue, this.activeRegion);
// eslint-disable-next-line no-empty
} catch (_) {}
return pn?.g?.type || 'unknown';
}
// @ts-ignore read only
// eslint-disable-next-line class-methods-use-this, no-empty-function
set activePhoneNumberType(v) {}
/**
* Protected setter for activeRegion, only meant for subclassers
* @protected
* @param {RegionCode|undefined} newValue
*/
_setActiveRegion(newValue) {
const oldValue = this.activeRegion;
this.__activeRegion = newValue;
this.requestUpdate('activeRegion', oldValue);
}
/**
* Used for rendering the region/country list
* @property _allowedOrAllRegions
* @type {RegionCode[]}
*/
get _allowedOrAllRegions() {
return (
(this.allowedRegions?.length
? this.allowedRegions
: this._phoneUtil?.getSupportedRegionCodes()) || []
);
}
/**
* @property _phoneUtilLoadComplete
* @protected
* @type {Promise<PhoneNumber>}
*/
// eslint-disable-next-line class-methods-use-this
get _phoneUtilLoadComplete() {
return PhoneUtilManager.loadComplete;
}
/**
* @lifecycle platform
*/
constructor() {
super();
/**
* Determines what the formatter output should look like.
* Formatting strategies as provided by google-libphonenumber
* See: https://www.npmjs.com/package/google-libphonenumber
* @type {FormatStrategy}
*/
this.formatStrategy = 'international';
/**
* The regions that should be considered when international phone numbers are detected.
* (when not configured, all regions worldwide will be considered)
* @type {RegionCode[]}
*/
this.allowedRegions = [];
/** @private */
this.__isPhoneNumberValidatorInstance = new IsPhoneNumber();
/** @configures ValidateMixin */
this.defaultValidators.push(this.__isPhoneNumberValidatorInstance);
// Expose awesome-phonenumber lib for Subclassers
/**
* @protected
* @type {PhoneNumber|null}
*/
this._phoneUtil = PhoneUtilManager.isLoaded
? /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber)
: null;
/**
* Helper that triggers a light dom render aligned with update loop.
* TODO: combine with render fn of SlotMixin
* @protected
* @type {number}
*/
this._needsLightDomRender = 0;
if (!PhoneUtilManager.isLoaded) {
PhoneUtilManager.loadComplete.then(() => {
this._onPhoneNumberUtilReady();
});
}
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
// This will trigger the right keyboard on mobile
this._inputNode.inputMode = 'tel';
}
/**
* @param {import('lit-element').PropertyValues } changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('activeRegion')) {
// Make sure new modelValue is computed, but prevent formattedValue from being set when focused
this.__isUpdatingRegionWhileFocused = this.focused;
this._calculateValues({ source: null });
this.__isUpdatingRegionWhileFocused = false;
this.__isPhoneNumberValidatorInstance.param = this.activeRegion;
/** @type {FormatOptionsTel} */
(this.formatOptions).regionCode = /** @type {RegionCode} */ (this.activeRegion);
}
if (changedProperties.has('formatStrategy')) {
this._calculateValues({ source: null });
/** @type {FormatOptionsTel} */
(this.formatOptions).formatStrategy = this.formatStrategy;
}
if (changedProperties.has('modelValue') || changedProperties.has('allowedRegions')) {
this.__calculateActiveRegion();
}
}
/**
* @configure LocalizeMixin
*/
onLocaleUpdated() {
super.onLocaleUpdated();
const localeSplitted = localize.locale.split('-');
/**
* @protected
* @type {RegionCode}
*/
this._langIso = /** @type {RegionCode} */ (
localeSplitted[localeSplitted.length - 1].toUpperCase()
);
this.__calculateActiveRegion();
}
/**
* @configure FormatMixin
* @param {string} modelValue
* @returns {string}
*/
formatter(modelValue) {
return formatPhoneNumber(modelValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
formatStrategy: this.formatStrategy,
});
}
/**
* @configure FormatMixin
* @param {string} viewValue a phone number without (or with) country code, like '06 12345678'
* @returns {string} a trimmed phone number with country code, like '+31612345678'
*/
parser(viewValue) {
return parsePhoneNumber(viewValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
});
}
/**
* @configure FormatMixin
* @param {string} viewValue
* @param {object} options
* @param {string} options.prevViewValue
* @param {number} options.currentCaretIndex
* @returns {{ viewValue: string; caretIndex: number; } | undefined }
*/
preprocessor(viewValue, { currentCaretIndex, prevViewValue }) {
return liveFormatPhoneNumber(viewValue, {
regionCode: /** @type {RegionCode} */ (this.activeRegion),
formatStrategy: this.formatStrategy,
currentCaretIndex,
prevViewValue,
});
}
/**
* Do not reflect back .formattedValue during typing (this normally wouldn't happen when
* FormatMixin calls _calculateValues based on user input, but for LionInputTel we need to
* call it on .activeRegion change)
* @enhance FormatMixin
* @returns {boolean}
*/
_reflectBackOn() {
return !this.__isUpdatingRegionWhileFocused && super._reflectBackOn();
}
/**
* @protected
*/
_onPhoneNumberUtilReady() {
// This should trigger a rerender in shadow dom
this._phoneUtil = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
// This should trigger a rerender in light dom
this._scheduleLightDomRender();
// Format when libPhoneNumber is loaded
this._calculateValues({ source: null });
this.__calculateActiveRegion();
}
/**
* This allows to hook into the update hook
* @protected
*/
_scheduleLightDomRender() {
this._needsLightDomRender += 1;
}
/**
* @private
*/
__calculateActiveRegion() {
// 1. Get the region from preconfigured allowed region (if one entry)
if (this.allowedRegions?.length === 1) {
this._setActiveRegion(this.allowedRegions[0]);
return;
}
// 2. Try to derive action region from user value
const value = !(this.modelValue instanceof Unparseable) ? this.modelValue : this.value;
const regionDerivedFromValue = value && this._phoneUtil && this._phoneUtil(value).g?.regionCode;
if (regionDerivedFromValue && this._allowedOrAllRegions.includes(regionDerivedFromValue)) {
this._setActiveRegion(regionDerivedFromValue);
return;
}
// 3. Try to get the region from locale
if (this._langIso && this._allowedOrAllRegions.includes(this._langIso)) {
this._setActiveRegion(this._langIso);
return;
}
// 4. Not derivable
this._setActiveRegion(undefined);
}
}

View file

@ -0,0 +1,38 @@
/** @type {(value: any) => void} */
let resolveLoaded;
/**
* - Handles lazy loading of the (relatively large) google-libphonenumber library, allowing
* for quick first paints
* - Maintains one instance of phoneNumberUtil that can be shared across multiple places
* - Allows for easy mocking in unit tests
*/
export class PhoneUtilManager {
static async loadLibPhoneNumber() {
const PhoneNumber = (await import('../lib/awesome-phonenumber-esm.js')).default;
this.PhoneNumber = PhoneNumber;
resolveLoaded(undefined);
return PhoneNumber;
}
/**
* Check if google-libphonenumber has been loaded
*/
static get isLoaded() {
return Boolean(this.PhoneNumber);
}
}
/**
* Wait till google-libphonenumber has been loaded
* @example
* ```js
* await PhoneUtilManager.loadComplete;
* ```
*/
PhoneUtilManager.loadComplete = new Promise(resolve => {
resolveLoaded = resolve;
});
// initialize
PhoneUtilManager.loadLibPhoneNumber();

View file

@ -0,0 +1,57 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} modelValue
* @param {object} options
* @param {RegionCode} options.regionCode
* @param {FormatStrategy} [options.formatStrategy='international']
* @returns {string}
*/
export function formatPhoneNumber(modelValue, { regionCode, formatStrategy = 'international' }) {
// Do not format when not loaded
if (!PhoneUtilManager.isLoaded) {
return modelValue;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let pn;
try {
pn = new PhoneNumber(modelValue, regionCode); // phoneNumberUtil.parse(modelValue, regionCode);
// eslint-disable-next-line no-empty
} catch (_) {}
if (modelValue?.length >= 4 && modelValue?.length <= 16 && pn?.isValid()) {
let formattedValue;
switch (formatStrategy) {
case 'e164':
formattedValue = pn.getNumber('e164'); // -> '+46707123456' (default)
break;
case 'international':
formattedValue = pn.getNumber('international'); // -> '+46 70 712 34 56'
break;
case 'national':
formattedValue = pn.getNumber('national'); // -> '070-712 34 56'
break;
case 'rfc3966':
formattedValue = pn.getNumber('rfc3966'); // -> 'tel:+46-70-712-34-56'
break;
case 'significant':
formattedValue = pn.getNumber('significant'); // -> '707123456'
break;
default:
break;
}
return formattedValue;
}
return modelValue;
}

View file

@ -0,0 +1,33 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('awesome-phonenumber').default} PhoneNumber
*/
/**
* @param {string} viewValue
* @param {{regionCode:RegionCode;}} options
* @returns {string}
*/
export function parsePhoneNumber(viewValue, { regionCode }) {
// Do not format when not loaded
if (!PhoneUtilManager.isLoaded) {
return viewValue;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let pn;
try {
pn = PhoneNumber(viewValue, regionCode);
// eslint-disable-next-line no-empty
} catch (_) {}
if (pn) {
return pn.getNumber('e164');
}
return viewValue;
}

View file

@ -0,0 +1,51 @@
import { PhoneUtilManager } from './PhoneUtilManager.js';
import { formatPhoneNumber } from './formatters.js';
/**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} viewValue
* @param {object} options
* @param {RegionCode} options.regionCode
* @param {string} options.prevViewValue
* @param {number} options.currentCaretIndex
* @param {FormatStrategy} options.formatStrategy
* @returns {{viewValue:string; caretIndex:number;}|undefined}
*/
export function liveFormatPhoneNumber(
viewValue,
{ regionCode, formatStrategy, prevViewValue, currentCaretIndex },
) {
const diff = viewValue.length - prevViewValue.length;
// Do not format when not loaded
if (diff <= 0 || !PhoneUtilManager.isLoaded) {
return undefined;
}
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
const ayt = PhoneNumber.getAsYouType(regionCode);
for (const char of viewValue) {
if (char !== '') {
ayt.addChar(char);
}
}
const newViewValue = formatPhoneNumber(ayt.number(), { regionCode, formatStrategy });
/**
* Given following situation:
* - viewValue: `+316123`
* - currentCaretIndex: 2 (inbetween 3 and 1)
* - prevViewValue `+36123` (we inserted '1' at position 2)
* => we should get `+31 6123`, and new caretIndex should be 3, and not newViewValue.length
*/
const diffBetweenNewAndCurrent = newViewValue.length - viewValue.length;
const newCaretIndex = currentCaretIndex + diffBetweenNewAndCurrent;
return newViewValue ? { viewValue: newViewValue, caretIndex: newCaretIndex } : undefined;
}

View file

@ -0,0 +1,70 @@
import { Validator } from '@lion/form-core';
import { PhoneUtilManager } from './PhoneUtilManager.js';
/**
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
/**
* @param {string} modelValue
* @param {RegionCode} regionCode
* @returns {false|'invalid-country-code'|'unknown'|'too-long'|'too-short'}
*/
function hasFeedback(modelValue, regionCode) {
// eslint-disable-next-line prefer-destructuring
const PhoneNumber = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneNumber);
let invalidCountryCode = false;
if (regionCode && modelValue?.length >= 4 && modelValue?.length <= 16) {
let pn;
try {
pn = PhoneNumber(modelValue, regionCode);
invalidCountryCode = pn.g.regionCode !== regionCode;
if (invalidCountryCode) {
return 'invalid-country-code';
}
// eslint-disable-next-line no-empty
} catch (_) {}
// too-short/too-long info seems to be not there (we get 'is-possible'?)
const enumValue = !pn.isValid() ? pn.g.possibility : false;
if (enumValue === 'is-possible') {
return 'unknown';
}
return enumValue;
}
return 'unknown';
}
export class IsPhoneNumber extends Validator {
static validatorName = 'IsPhoneNumber';
static get async() {
// Will be run as async the first time if PhoneUtilManager hasn't loaded yet, sync afterwards
return !PhoneUtilManager.isLoaded;
}
/**
* @param {string} modelValue telephone number without country prefix
* @param {RegionCode} regionCode
*/
// eslint-disable-next-line class-methods-use-this
execute(modelValue, regionCode) {
if (!PhoneUtilManager.isLoaded) {
// Return a Promise once not loaded yet. Since async Validators are meant for things like
// loading server side data (in this case a lib), we continue as a sync Validator once loaded
return new Promise(resolve => {
PhoneUtilManager.loadComplete.then(() => {
resolve(hasFeedback(modelValue, regionCode));
});
});
}
return hasFeedback(modelValue, regionCode);
}
// TODO: add a file for loadDefaultMessages
static async getMessage() {
return 'Not a valid phone number';
}
}

View file

@ -0,0 +1 @@
export { mockPhoneUtilManager, restorePhoneUtilManager } from './mockPhoneUtilManager.js';

View file

@ -0,0 +1,25 @@
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
const originalLoadComplete = PhoneUtilManager.loadComplete;
const originalIsLoaded = PhoneUtilManager.isLoaded;
export function mockPhoneUtilManager() {
/** @type {(value: any) => void} */
let resolveLoaded;
let isLoaded = false;
PhoneUtilManager.loadComplete = new Promise(resolve => {
resolveLoaded = () => {
isLoaded = true;
resolve(undefined);
};
});
Object.defineProperty(PhoneUtilManager, 'isLoaded', { get: () => isLoaded });
// @ts-ignore
return { resolveLoaded };
}
export function restorePhoneUtilManager() {
PhoneUtilManager.loadComplete = originalLoadComplete;
Object.defineProperty(PhoneUtilManager, 'isLoaded', { get: () => originalIsLoaded });
}

View file

@ -0,0 +1,384 @@
import {
expect,
fixture as _fixture,
fixtureSync as _fixtureSync,
html,
defineCE,
unsafeStatic,
aTimeout,
} from '@open-wc/testing';
import sinon from 'sinon';
import { mimicUserInput } from '@lion/form-core/test-helpers';
import { localize } from '@lion/localize';
import { LionInputTel } from '../src/LionInputTel.js';
import { IsPhoneNumber } from '../src/validators.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
import {
mockPhoneUtilManager,
restorePhoneUtilManager,
} from '../test-helpers/mockPhoneUtilManager.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('../types').RegionCode} RegionCode
*/
const fixture = /** @type {(arg: string | TemplateResult) => Promise<LionInputTel>} */ (_fixture);
const fixtureSync = /** @type {(arg: string | TemplateResult) => LionInputTel} */ (_fixtureSync);
// const isPhoneNumberUtilLoadComplete = el => el._phoneUtilLoadComplete;
const getRegionCodeBasedOnLocale = () => {
const localeSplitted = localize.locale.split('-');
return /** @type {RegionCode} */ (localeSplitted[localeSplitted.length - 1]).toUpperCase();
};
/**
* @param {{ klass:LionInputTel }} config
*/
// @ts-ignore
export function runInputTelSuite({ klass = LionInputTel } = {}) {
// @ts-ignore
const tagName = defineCE(/** @type {* & HTMLElement} */ (class extends klass {}));
const tag = unsafeStatic(tagName);
describe('LionInputTel', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
describe('Region codes', () => {
describe('Readonly accessor `.activeRegion`', () => {
// 1. **allowed regions**: try to get the region from preconfigured allowed region (first entry)
it('takes .allowedRegions[0] when only one allowed region configured', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['DE']}" .modelValue="${'+31612345678'}" ></${tag}> `,
);
await el.updateComplete;
expect(el.activeRegion).to.equal('DE');
});
it('returns undefined when multiple .allowedRegions, but no modelValue match', async () => {
// involve locale, so we are sure it does not fall back on locale
const currentCode = getRegionCodeBasedOnLocale();
const allowedRegions = ['BE', 'DE', 'CN'];
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${allowedRegions.filter(
ar => ar !== currentCode,
)}"></${tag}> `,
);
expect(el.activeRegion).to.equal(undefined);
});
// 2. **user input**: try to derive active region from user input
it('deducts it from modelValue when provided', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+31612345678'}"></${tag}> `);
// Region code for country code '31' is 'NL'
expect(el.activeRegion).to.equal('NL');
});
it('.modelValue takes precedence over .allowedRegions when both preconfigured and .modelValue updated', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'DE',
'BE',
'NL',
]}" .modelValue="${'+31612345678'}" ></${tag}> `,
);
expect(el.activeRegion).to.equal('NL');
});
// 3. **locale**: try to get the region from locale (`html[lang]` attribute)
it('automatically bases it on current locale when nothing preconfigured', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
const currentCode = getRegionCodeBasedOnLocale();
expect(el.activeRegion).to.equal(currentCode);
});
it('returns undefined when locale not within allowed regions', async () => {
const currentCode = getRegionCodeBasedOnLocale();
const allowedRegions = ['NL', 'BE', 'DE'];
const el = await fixture(
html` <${tag} .allowedRegions="${allowedRegions.filter(
ar => ar !== currentCode,
)}"></${tag}> `,
);
expect(el.activeRegion).to.equal(undefined);
});
});
it('can preconfigure the region code via prop', async () => {
const currentCode = getRegionCodeBasedOnLocale();
const newCode = currentCode === 'DE' ? 'NL' : 'DE';
const el = await fixture(html` <${tag} .allowedRegions="${[newCode]}"></${tag}> `);
expect(el.activeRegion).to.equal(newCode);
});
it.skip('reformats when region code is changed on the fly', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'+31612345678'}"></${tag}> `,
);
await el.updateComplete;
expect(el.formattedValue).to.equal('+31 6 12345678');
el.allowedRegions = ['NL'];
await el.updateComplete;
expect(el.formattedValue).to.equal('612345678');
});
});
describe('Readonly accessor `.activePhoneNumberType`', () => {
const types = [
{ type: 'fixed-line', number: '030 1234567', allowedRegions: ['NL'] },
{ type: 'mobile', number: '06 12345678', allowedRegions: ['NL'] },
// { type: 'fixed-line-or-mobile', number: '030 1234567' },
// { type: 'pager', number: '06 12345678' },
// { type: 'personal-number', number: '06 12345678' },
// { type: 'premium-rate', number: '06 12345678' },
// { type: 'shared-cost', : '06 12345678' },
// { type: 'toll-free', number: '06 12345678' },
// { type: 'uan', number: '06 12345678' },
// { type: 'voip', number: '06 12345678' },
// { type: 'unknown', number: '06 12345678' },
];
for (const { type, number, allowedRegions } of types) {
it(`returns "${type}" for ${type} numbers`, async () => {
const el = await fixture(html` <${tag} .allowedRegions="${allowedRegions}"></${tag}> `);
mimicUserInput(el, number);
await aTimeout(0);
expect(el.activePhoneNumberType).to.equal(type);
});
}
});
describe('User interaction', () => {
it('sets inputmode to "tel" for mobile keyboard', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
// @ts-expect-error [allow-protected] inside tests
expect(el._inputNode.inputMode).to.equal('tel');
});
it('formats according to locale', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('does not reflect back formattedValue after activeRegion change when input still focused', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+639608920056'}"></${tag}> `);
expect(el.activeRegion).to.equal('PH');
el.focus();
mimicUserInput(el, '+31612345678');
await el.updateComplete;
await el.updateComplete;
expect(el.activeRegion).to.equal('NL');
expect(el.formattedValue).to.equal('+31 6 12345678');
expect(el.value).to.equal('+31612345678');
});
});
// https://www.npmjs.com/package/google-libphonenumber
// https://en.wikipedia.org/wiki/E.164
describe('Values', () => {
it('stores a modelValue in E164 format', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.modelValue).to.equal('+31612345678');
});
it('stores a serializedValue in E164 format', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.serializedValue).to.equal('+31612345678');
});
it('stores a formattedValue according to format strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="national" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('06 12345678');
});
describe('Format strategies', () => {
it('supports "national" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="national" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('06 12345678');
});
it('supports "international" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="international" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('supports "e164" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="e164" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('+31612345678');
});
it('supports "rfc3966" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="rfc3966" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('tel:+31-6-12345678');
});
it('supports "significant" strategy', async () => {
const el = await fixture(
html` <${tag} format-strategy="significant" .allowedRegions="${['NL']}"></${tag}> `,
);
mimicUserInput(el, '612345678');
await aTimeout(0);
expect(el.formattedValue).to.equal('612345678');
});
});
// TODO: this should be allowed for in FormatMixin =>
// in _onModelValueChanged we can add a hook '_checkModelValueFormat'. This needs to be
// called whenever .modelValue is supplied by devleloper (not when being internal result
// of parser call).
// Alternatively, we could be forgiving by attempting to treat it as a view value and
// correct the format (although strictness will be preferred...)
it.skip('does not allow modelValues in non E164 format', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
expect(el.modelValue).to.equal(undefined);
});
});
describe('Validation', () => {
it('applies IsPhoneNumber as default validator', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
expect(el.defaultValidators.find(v => v instanceof IsPhoneNumber)).to.be.not.undefined;
});
it('configures IsPhoneNumber with regionCode before first validation', async () => {
const el = fixtureSync(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'612345678'}"></${tag}> `,
);
const spy = sinon.spy(el, 'validate');
const validatorInstance = /** @type {IsPhoneNumber} */ (
el.defaultValidators.find(v => v instanceof IsPhoneNumber)
);
await el.updateComplete;
expect(validatorInstance.param).to.equal('NL');
expect(spy).to.have.been.called;
spy.restore();
});
it('updates IsPhoneNumber param on regionCode change', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${['NL']}" .modelValue="${'612345678'}"></${tag}> `,
);
const validatorInstance = /** @type {IsPhoneNumber} */ (
el.defaultValidators.find(v => v instanceof IsPhoneNumber)
);
// @ts-expect-error allow protected in tests
el._setActiveRegion('DE');
await el.updateComplete;
expect(validatorInstance.param).to.equal('DE');
});
});
describe('User interaction', () => {
it('sets inputmode to "tel" for mobile keyboard', async () => {
const el = await fixture(html` <${tag}></${tag}> `);
// @ts-expect-error [allow-protected] inside tests
expect(el._inputNode.inputMode).to.equal('tel');
});
it('formats according to locale', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
await PhoneUtilManager.loadComplete;
await el.updateComplete;
el.modelValue = '612345678';
expect(el.formattedValue).to.equal('+31 6 12345678');
});
});
describe('Live format', () => {
it('calls .preprocessor on keyup', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL']}"></${tag}> `);
mimicUserInput(el, '+316');
await aTimeout(0);
expect(el.value).to.equal('+31 6');
});
});
describe('Accessibility', () => {
describe('Audit', () => {
it('passes a11y audit', async () => {
const el = await fixture(html`<${tag} label="tel" .modelValue=${'0123456789'}></${tag}>`);
await expect(el).to.be.accessible();
});
it('passes a11y audit when readonly', async () => {
const el = await fixture(
html`<${tag} label="tel" readonly .modelValue=${'0123456789'}></${tag}>`,
);
await expect(el).to.be.accessible();
});
it('passes a11y audit when disabled', async () => {
const el = await fixture(
html`<${tag} label="tel" disabled .modelValue=${'0123456789'}></${tag}>`,
);
await expect(el).to.be.accessible();
});
});
});
describe('Lazy loading awesome-phonenumber', () => {
/** @type {(value:any) => void} */
let resolveLoaded;
beforeEach(() => {
({ resolveLoaded } = mockPhoneUtilManager());
});
afterEach(() => {
restorePhoneUtilManager();
});
it('reformats once lib has been loaded', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'612345678'}" .allowedRegions="${['NL']}"></${tag}> `,
);
expect(el.formattedValue).to.equal('612345678');
resolveLoaded(undefined);
await aTimeout(0);
expect(el.formattedValue).to.equal('+31 6 12345678');
});
it('validates once lib has been loaded', async () => {
const el = await fixture(
html` <${tag} .modelValue="${'+31612345678'}" .allowedRegions="${['DE']}"></${tag}> `,
);
expect(el.hasFeedbackFor).to.eql([]);
resolveLoaded(undefined);
await aTimeout(0);
expect(el.hasFeedbackFor).to.eql(['error']);
});
});
});
}

View file

@ -0,0 +1 @@
export { runInputTelSuite } from './LionInputTel.suite.js';

View file

@ -0,0 +1,3 @@
import { runInputTelSuite } from '../test-suites/LionInputTel.suite.js';
runInputTelSuite();

View file

@ -0,0 +1,28 @@
import { expect } from '@open-wc/testing';
import { formatPhoneNumber } from '../src/formatters.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('formatPhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('formats a phone number according to provided formatStrategy', () => {
expect(formatPhoneNumber('0707123456', { regionCode: 'SE', formatStrategy: 'e164' })).to.equal(
'+46707123456',
);
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'international' }),
).to.equal('+46 70 712 34 56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'national' }),
).to.equal('070-712 34 56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'rfc3966' }),
).to.equal('tel:+46-70-712-34-56');
expect(
formatPhoneNumber('+46707123456', { regionCode: 'SE', formatStrategy: 'significant' }),
).to.equal('707123456');
});
});

View file

@ -0,0 +1,16 @@
import { expect } from '@open-wc/testing';
import { parsePhoneNumber } from '../src/parsers.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('parsePhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('parses a a view value to e164 standard', () => {
expect(parsePhoneNumber('0707123456', { regionCode: 'SE' })).to.equal('+46707123456');
expect(parsePhoneNumber('0707123456', { regionCode: 'NL' })).to.equal('+31707123456');
expect(parsePhoneNumber('0707123456', { regionCode: 'DE' })).to.equal('+49707123456');
});
});

View file

@ -0,0 +1,32 @@
import { expect } from '@open-wc/testing';
import { liveFormatPhoneNumber } from '../src/preprocessors.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
describe('liveFormatPhoneNumber', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('live formats an incomplete view value', () => {
expect(
liveFormatPhoneNumber('+316123', {
regionCode: 'NL',
formatStrategy: 'international',
prevViewValue: '+36123',
currentCaretIndex: 2,
}),
).to.eql({ viewValue: '+31 6 123', caretIndex: 4 });
});
it('live formats a complete view value', () => {
expect(
liveFormatPhoneNumber('+31612345678', {
regionCode: 'NL',
formatStrategy: 'international',
prevViewValue: '+3161234578',
currentCaretIndex: 10,
}),
).to.eql({ caretIndex: 12, viewValue: '+31 6 12345678' });
});
});

View file

@ -0,0 +1,94 @@
import sinon from 'sinon';
import { expect, aTimeout } from '@open-wc/testing';
import { IsPhoneNumber } from '../src/validators.js';
import { PhoneUtilManager } from '../src/PhoneUtilManager.js';
import {
mockPhoneUtilManager,
restorePhoneUtilManager,
} from '../test-helpers/mockPhoneUtilManager.js';
/**
* @typedef {* & import('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
*/
// For enum output, see: https://www.npmjs.com/package/awesome-phonenumber
describe('IsPhoneNumber validation', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
it('is invalid when no input is provided', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('', 'NL')).to.equal('unknown');
});
it('is invalid when non digits are entered, returns "unknown"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('foo', 'NL')).to.equal('unknown');
});
it('is invalid when wrong country code is entered, returns "invalid-country-code"', () => {
const validator = new IsPhoneNumber();
// 32 is BE region code
expect(validator.execute('+32612345678', 'NL')).to.equal('invalid-country-code');
});
// TODO: find out why awesome-phonenumber does not detect too-short/too-long
it.skip('is invalid when number is too short, returns "too-short"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+3161234567', 'NL')).to.equal('too-short');
});
// TODO: find out why awesome-phonenumber does not detect too-short/too-long
it.skip('is invalid when number is too long, returns "too-long"', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+316123456789', 'NL')).to.equal('too-long');
});
it('is valid when a phone number is entered', () => {
const validator = new IsPhoneNumber();
expect(validator.execute('+31612345678', 'NL')).to.be.false;
});
it('handles validation via awesome-phonenumber', () => {
const validator = new IsPhoneNumber();
const spy = sinon.spy(PhoneUtilManager, 'PhoneNumber');
validator.execute('0123456789', 'NL');
expect(spy).to.have.been.calledOnce;
expect(spy.lastCall.args[1]).to.equal('NL');
validator.execute('0123456789', 'DE');
expect(spy.lastCall.args[1]).to.equal('DE');
spy.restore();
});
describe('Lazy loading PhoneUtilManager', () => {
/** @type {(value:any) => void} */
let resolveLoaded;
beforeEach(() => {
({ resolveLoaded } = mockPhoneUtilManager());
});
afterEach(() => {
restorePhoneUtilManager();
});
it('behaves asynchronously when lib is still loading', () => {
expect(IsPhoneNumber.async).to.be.true;
resolveLoaded(undefined);
expect(IsPhoneNumber.async).to.be.false;
});
it('waits for the lib to be loaded before execution completes when still in async mode', async () => {
const validator = new IsPhoneNumber();
const spy = sinon.spy(PhoneUtilManager, 'PhoneNumber');
const validationResult = validator.execute('061234', 'NL');
expect(validationResult).to.be.instanceOf(Promise);
expect(spy).to.not.have.been.called;
resolveLoaded(undefined);
await aTimeout(0);
expect(spy).to.have.been.calledOnce;
spy.restore();
});
});
});

View file

@ -0,0 +1,5 @@
import bg from './bg.js';
export default {
...bg,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Избор на държава',
phoneNumber: 'Телефонен номер',
};

View file

@ -0,0 +1,5 @@
import cs from './cs.js';
export default {
...cs,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Vybrat zemi',
phoneNumber: 'Telefonní číslo',
};

View file

@ -0,0 +1,5 @@
import de from './de.js';
export default {
...de,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Land auswählen',
phoneNumber: 'Telefonnummer',
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Select country',
phoneNumber: 'Phone number',
};

View file

@ -0,0 +1,5 @@
import es from './es.js';
export default {
...es,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Seleccione país',
phoneNumber: 'Número de teléfono',
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Sélectionnez un pays',
phoneNumber: 'Numéro de téléphone',
};

View file

@ -0,0 +1,5 @@
import hu from './hu.js';
export default {
...hu,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Ország kiválasztása',
phoneNumber: 'Telefonszám',
};

View file

@ -0,0 +1,5 @@
import it from './it.js';
export default {
...it,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Selezionare il paese',
phoneNumber: 'Numero di telefono',
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Selecteer land',
phoneNumber: 'Telefoonnummer',
};

View file

@ -0,0 +1,5 @@
import pl from './pl.js';
export default {
...pl,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Wybierz kraj',
phoneNumber: 'Numer telefonu',
};

View file

@ -0,0 +1,5 @@
import ro from './ro.js';
export default {
...ro,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Selectare țară',
phoneNumber: 'Număr de telefon',
};

View file

@ -0,0 +1,5 @@
import ru from './ru.js';
export default {
...ru,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Выбрать страну',
phoneNumber: 'Номер телефона',
};

View file

@ -0,0 +1,5 @@
import sk from './sk.js';
export default {
...sk,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Zvoliť krajinu',
phoneNumber: 'Telefónne číslo',
};

View file

@ -0,0 +1,5 @@
import uk from './uk.js';
export default {
...uk,
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: 'Вибрати країну',
phoneNumber: 'Номер телефону',
};

View file

@ -0,0 +1,4 @@
export default {
selectCountry: '选择国家/地区',
phoneNumber: '电话号码',
};

290
packages/input-tel/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,290 @@
/*
* Phone number types as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type PhoneNumberType =
| 'fixed-line'
| 'fixed-line-or-mobile'
| 'mobile'
| 'pager'
| 'personal-number'
| 'premium-rate'
| 'shared-cost'
| 'toll-free'
| 'uan'
| 'voip'
| 'unknown';
/*
* Phone number possibilities as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type PhoneNumberPossibility =
| 'is-possible'
| 'invalid-country-code'
| 'too-long'
| 'too-short'
| 'unknown';
/*
* Phone number formats / formatting strategies as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type FormatStrategy = 'e164' | 'international' | 'national' | 'rfc3966' | 'significant';
/**
* Supported countries/regions as provided via
* `libphonenumber.PhoneNumberUtil.getInstance().getSupportedRegions()`
*/
export type RegionCode =
| 'AC'
| 'AD'
| 'AE'
| 'AF'
| 'AG'
| 'AI'
| 'AL'
| 'AM'
| 'AO'
| 'AR'
| 'AS'
| 'AT'
| 'AU'
| 'AW'
| 'AX'
| 'AZ'
| 'BA'
| 'BB'
| 'BD'
| 'BE'
| 'BF'
| 'BG'
| 'BH'
| 'BI'
| 'BJ'
| 'BL'
| 'BM'
| 'BN'
| 'BO'
| 'BQ'
| 'BR'
| 'BS'
| 'BT'
| 'BW'
| 'BY'
| 'BZ'
| 'CA'
| 'CC'
| 'CD'
| 'CF'
| 'CG'
| 'CH'
| 'CI'
| 'CK'
| 'CL'
| 'CM'
| 'CN'
| 'CO'
| 'CR'
| 'CU'
| 'CV'
| 'CW'
| 'CX'
| 'CY'
| 'CZ'
| 'DE'
| 'DJ'
| 'DK'
| 'DM'
| 'DO'
| 'DZ'
| 'EC'
| 'EE'
| 'EG'
| 'EH'
| 'ER'
| 'ES'
| 'ET'
| 'FI'
| 'FJ'
| 'FK'
| 'FM'
| 'FO'
| 'FR'
| 'GA'
| 'GB'
| 'GD'
| 'GE'
| 'GF'
| 'GG'
| 'GH'
| 'GI'
| 'GL'
| 'GM'
| 'GN'
| 'GP'
| 'GQ'
| 'GR'
| 'GT'
| 'GU'
| 'GW'
| 'GY'
| 'HK'
| 'HN'
| 'HR'
| 'HT'
| 'HU'
| 'ID'
| 'IE'
| 'IL'
| 'IM'
| 'IN'
| 'IO'
| 'IQ'
| 'IR'
| 'IS'
| 'IT'
| 'JE'
| 'JM'
| 'JO'
| 'JP'
| 'KE'
| 'KG'
| 'KH'
| 'KI'
| 'KM'
| 'KN'
| 'KP'
| 'KR'
| 'KW'
| 'KY'
| 'KZ'
| 'LA'
| 'LB'
| 'LC'
| 'LI'
| 'LK'
| 'LR'
| 'LS'
| 'LT'
| 'LU'
| 'LV'
| 'LY'
| 'MA'
| 'MC'
| 'MD'
| 'ME'
| 'MF'
| 'MG'
| 'MH'
| 'MK'
| 'ML'
| 'MM'
| 'MN'
| 'MO'
| 'MP'
| 'MQ'
| 'MR'
| 'MS'
| 'MT'
| 'MU'
| 'MV'
| 'MW'
| 'MX'
| 'MY'
| 'MZ'
| 'NA'
| 'NC'
| 'NE'
| 'NF'
| 'NG'
| 'NI'
| 'NL'
| 'NO'
| 'NP'
| 'NR'
| 'NU'
| 'NZ'
| 'OM'
| 'PA'
| 'PE'
| 'PF'
| 'PG'
| 'PH'
| 'PK'
| 'PL'
| 'PM'
| 'PR'
| 'PS'
| 'PT'
| 'PW'
| 'PY'
| 'QA'
| 'RE'
| 'RO'
| 'RS'
| 'RU'
| 'RW'
| 'SA'
| 'SB'
| 'SC'
| 'SD'
| 'SE'
| 'SG'
| 'SH'
| 'SI'
| 'SJ'
| 'SK'
| 'SL'
| 'SM'
| 'SN'
| 'SO'
| 'SR'
| 'SS'
| 'ST'
| 'SV'
| 'SX'
| 'SY'
| 'SZ'
| 'TA'
| 'TC'
| 'TD'
| 'TG'
| 'TH'
| 'TJ'
| 'TK'
| 'TL'
| 'TM'
| 'TN'
| 'TO'
| 'TR'
| 'TT'
| 'TV'
| 'TW'
| 'TZ'
| 'UA'
| 'UG'
| 'US'
| 'UY'
| 'UZ'
| 'VA'
| 'VC'
| 'VE'
| 'VG'
| 'VI'
| 'VN'
| 'VU'
| 'WF'
| 'WS'
| 'XK'
| 'YE'
| 'YT'
| 'ZA'
| 'ZM'
| 'ZW';