lion/packages/ui/components/input-tel/LionInputTel.js
2022-10-31 16:55:07 +01:00

353 lines
10 KiB
JavaScript

import { Unparseable } from '@lion/ui/form-core.js';
import { LocalizeMixin, localize } from '@lion/ui/localize.js';
import { LionInput } from '@lion/ui/input.js';
import { PhoneUtilManager } from './PhoneUtilManager.js';
import { liveFormatPhoneNumber } from './preprocessors.js';
import { formatPhoneNumber } from './formatters.js';
import { parsePhoneNumber } from './parsers.js';
import { PhoneNumber } from './validators.js';
import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
/**
* @typedef {import('../types').RegionCode} RegionCode
* @typedef {import('awesome-phonenumber').PhoneNumberFormat} PhoneNumberFormat
* @typedef {import('awesome-phonenumber').PhoneNumberTypes} PhoneNumberTypes
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
* @typedef {FormatOptions & {regionCode: RegionCode; formatStrategy: PhoneNumberFormat; formatCountryCodeStyle: string;}} FormatOptionsTel
*/
export class LionInputTel extends LocalizeMixin(LionInput) {
/**
* @configure LitElement
*/
static properties = {
allowedRegions: { type: Array },
formatStrategy: { type: String, attribute: 'format-strategy' },
formatCountryCodeStyle: { type: String, attribute: 'format-country-code-style' },
activeRegion: { type: String },
_phoneUtil: { type: Object, state: true },
_needsLightDomRender: { type: Number, state: true },
};
static localizeNamespaces = [
{ 'lion-input-tel': localizeNamespaceLoader },
...super.localizeNamespaces,
];
/**
* 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 {PhoneNumberTypes|undefined} activePhoneNumberTypes
*/
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;
}
/**
* Set a default name for this field, so that validation feedback will be always
* accessible and linguistically correct
* @configure FormControlMixin
*/
// @ts-expect-error
// eslint-disable-next-line class-methods-use-this
get fieldName() {
return localize.msg('lion-input-tel:phoneNumber');
}
/**
* @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 {PhoneNumberFormat}
*/
this.formatStrategy = 'international';
/**
* Extra styling of the format strategy
* default | parentheses
* @type {string}
*/
this.formatCountryCodeStyle = 'default';
/**
* 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 PhoneNumber();
/** @configures ValidateMixin */
this.defaultValidators.push(this.__isPhoneNumberValidatorInstance);
// Expose awesome-phonenumber lib for Subclassers
/**
* @protected
* @type {AwesomePhoneNumber|null}
*/
this._phoneUtil = PhoneUtilManager.isLoaded
? /** @type {AwesomePhoneNumber} */ (PhoneUtilManager.PhoneUtil)
: 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('formatCountryCodeStyle')) {
this._calculateValues({ source: null });
/** @type {FormatOptionsTel} */
(this.formatOptions).formatCountryCodeStyle = this.formatCountryCodeStyle;
}
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,
formatCountryCodeStyle: this.formatCountryCodeStyle,
});
}
/**
* @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,
formatCountryCodeStyle: this.formatCountryCodeStyle,
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 {AwesomePhoneNumber} */ (PhoneUtilManager.PhoneUtil);
// 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 regex = /[+0-9]+/gi;
const value = !(this.modelValue instanceof Unparseable)
? this.modelValue
: this.value.match(regex)?.join('');
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);
}
}