import { html, render, css } from 'lit'; import { ref, createRef } from 'lit/directives/ref.js'; import { LionInputTel } from '@lion/ui/input-tel.js'; import { localize } from '@lion/ui/localize.js'; import { getFlagSymbol } from './getFlagSymbol.js'; /** * Note: one could consider to implement LionInputTelDropdown as a * [combobox](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox). * However, the country dropdown does not directly set the textbox value, it only determines * its region code. Therefore it does not comply to this criterium: * "A combobox is an input widget with an associated popup that enables users to select a value for * the combobox from a collection of possible values. In some implementations, * the popup presents allowed values, while in other implementations, the popup presents suggested * values, and users may either select one of the suggestions or type a value". * We therefore decided to consider the dropdown a helper mechanism that does not set, but * contributes to and helps format and validate the actual value. */ /** * @typedef {import('lit/directives/ref.js').Ref} Ref * @typedef {import('@lion/core').RenderOptions} RenderOptions * @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatHost} FormatHost * @typedef {import('@lion/input-tel/types').RegionCode} RegionCode * @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel * @typedef {import('../types').OnDropdownChangeEvent} OnDropdownChangeEvent * @typedef {import('../types').DropdownRef} DropdownRef * @typedef {import('../types').RegionMeta} RegionMeta * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber * @typedef {import('@lion/select-rich').LionSelectRich} LionSelectRich * @typedef {import('@lion/overlays').OverlayController} OverlayController * @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputTel */ /** * LionInputTelDropdown renders a dropdown like element next to the text field, inside the * prefix slot. This could be a LionSelect, a LionSelectRich or a native select. * By default, the native ` ${data?.regionMetaListPreferred?.length ? html` ${data.regionMetaListPreferred.map(renderOption)} ${data?.regionMetaList?.map(renderOption)} ` : html` ${data?.regionMetaList?.map(renderOption)}`} `; }, /** * @param {TemplateDataForDropdownInputTel} templateDataForDropdown * @param {RegionMeta} contextData */ // eslint-disable-next-line class-methods-use-this dropdownOption: (templateDataForDropdown, { regionCode, countryCode, flagSymbol }) => html` `, }; /** * @configure LitElement * @enhance LionInputTel */ static styles = [ super.styles, css` /** * We need to align the height of the dropdown with the height of the text field. * We target the HTMLDivElement 'this.__dropdownRenderParent' here. Its child, * [data-ref=dropdown], recieves a 100% height as well via inline styles (since we * can't target from shadow styles). */ ::slotted([slot='prefix']) { height: 100%; } `, ]; /** * @configure SlotMixin */ get slots() { return { ...super.slots, prefix: () => this.__dropdownRenderParent, }; } /** * @configure LocalizeMixin */ onLocaleUpdated() { super.onLocaleUpdated(); // @ts-expect-error relatively new platform api this.__namesForLocale = new Intl.DisplayNames([this._langIso], { type: 'region', }); this.__createRegionMeta(); this._scheduleLightDomRender(); } /** * @enhance LionInputTel */ _onPhoneNumberUtilReady() { super._onPhoneNumberUtilReady(); this.__createRegionMeta(); } /** * @lifecycle platform */ constructor() { super(); /** * Regions that will be shown on top of the dropdown * @type {string[]} */ this.preferredRegions = []; /** * Group label for all countries, when preferredCountries are shown * @protected */ this._allCountriesLabel = ''; /** * Group label for preferred countries, when preferredCountries are shown * @protected */ this._preferredCountriesLabel = ''; /** @type {HTMLDivElement} */ this.__dropdownRenderParent = document.createElement('div'); /** * Contains everything needed for rendering region options: * region code, country code, display name according to locale, display name * @type {RegionMeta[]} */ this.__regionMetaList = []; /** * A filtered `this.__regionMetaList`, containing all regions provided in `preferredRegions` * @type {RegionMeta[]} */ this.__regionMetaListPreferred = []; /** @type {EventListener} */ this._onDropdownValueChange = this._onDropdownValueChange.bind(this); /** @type {EventListener} */ this.__syncRegionWithDropdown = this.__syncRegionWithDropdown.bind(this); } /** * @lifecycle LitElement * @param {import('lit-element').PropertyValues } changedProperties */ willUpdate(changedProperties) { super.willUpdate(changedProperties); if (changedProperties.has('allowedRegions')) { this.__createRegionMeta(); } } /** * @param {import('lit-element').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('_needsLightDomRender')) { this.__renderDropdown(); } if (changedProperties.has('activeRegion')) { this.__syncRegionWithDropdown(); } if (changedProperties.has('disabled') || changedProperties.has('readOnly')) { if (this.disabled || this.readOnly) { this.refs.dropdown?.value?.setAttribute('disabled', ''); } else { this.refs.dropdown?.value?.removeAttribute('disabled'); } } if (changedProperties.has('_phoneUtil')) { this._initModelValueBasedOnDropdown(); } } _initModelValueBasedOnDropdown() { if (!this._initialModelValue && !this.dirty && this._phoneUtil) { const countryCode = this._phoneUtil.getCountryCodeForRegionCode(this.activeRegion); if (this.formatCountryCodeStyle === 'parentheses') { this.__initializedRegionCode = `(+${countryCode})`; } else { this.__initializedRegionCode = `+${countryCode}`; } this.modelValue = this.__initializedRegionCode; this._initialModelValue = this.__initializedRegionCode; this.initInteractionState(); } } /** * Used for Required validation and computation of interaction states. * We need to override this, because we prefill the input with the region code (like +31), but for proper UX, * we don't consider this as having interaction state `prefilled` * @param {string} modelValue * @return {boolean} * @protected */ _isEmpty(modelValue = this.modelValue) { // the activeRegion is not synced on time, so it can't be used in this check return super._isEmpty(modelValue) || this.value === this.__initializedRegionCode; } /** * @protected * @param {OnDropdownChangeEvent} event */ _onDropdownValueChange(event) { const isInitializing = event.detail?.initialize || !this._phoneUtil; const dropdownValue = /** @type {RegionCode} */ (event.target.modelValue || event.target.value); if (isInitializing || this.activeRegion === dropdownValue) { return; } const prevActiveRegion = this.activeRegion; this._setActiveRegion(dropdownValue); // Change region code in text box // From: https://bl00mber.github.io/react-phone-input-2.html if (prevActiveRegion !== this.activeRegion && !this.focused && this._phoneUtil) { const prevCountryCode = this._phoneUtil.getCountryCodeForRegionCode(prevActiveRegion); const countryCode = this._phoneUtil.getCountryCodeForRegionCode(this.activeRegion); if (this.value.includes(`+${prevCountryCode}`)) { this.modelValue = this._callParser( this.value.replace(`+${prevCountryCode}`, `+${countryCode}`), ); } else { // In case of dropdown has +31, and input has only +3 const valueObj = this.value.split(' '); if (this.formatCountryCodeStyle === 'parentheses' && !this.value.includes('(')) { this.modelValue = this._callParser(this.value.replace(valueObj[0], `(+${countryCode})`)); } else { this.modelValue = this._callParser(this.value.replace(valueObj[0], `+${countryCode}`)); } } } // Put focus on text box const overlayController = event.target._overlayCtrl; if (overlayController?.isShown) { setTimeout(() => { this._inputNode.focus(); }); } } /** * Abstract away rendering to light dom, so that we can rerender when needed * @private */ __renderDropdown() { const ctor = /** @type {typeof LionInputTelDropdown} */ (this.constructor); // If the user locally overrode the templates, get those on the instance const templates = this.templates || ctor.templates; render( templates.dropdown(this._templateDataDropdown), this.__dropdownRenderParent, /** @type {RenderOptions} */ ({ scopeName: this.localName, eventContext: this, }), ); this.__syncRegionWithDropdown(); } /** * @private */ __syncRegionWithDropdown(regionCode = this.activeRegion) { const dropdownElement = this.refs.dropdown?.value; if (!dropdownElement || !regionCode) { return; } const inputCountryCode = this._phoneUtil?.getCountryCodeForRegionCode(regionCode); if ('modelValue' in dropdownElement) { const dropdownCountryCode = this._phoneUtil?.getCountryCodeForRegionCode( dropdownElement.modelValue, ); if (dropdownCountryCode === inputCountryCode) { return; } /** @type {* & FormatHost} */ (dropdownElement).modelValue = regionCode; } else { const dropdownCountryCode = this._phoneUtil?.getCountryCodeForRegionCode( dropdownElement.value, ); if (dropdownCountryCode === inputCountryCode) { return; } /** @type {HTMLSelectElement} */ (dropdownElement).value = regionCode; } } /** * Prepares data for options, like "Greece (Ελλάδα)", where "Greece" is `nameForLocale` and * "Ελλάδα" `nameForRegion`. * This should be run on change of: * - allowedRegions * - _phoneUtil loaded * - locale * @private */ __createRegionMeta() { if (!this._allowedOrAllRegions?.length || !this.__namesForLocale) { return; } this.__regionMetaList = []; this.__regionMetaListPreferred = []; this._allowedOrAllRegions.forEach(regionCode => { // @ts-ignore relatively new platform api const namesForRegion = new Intl.DisplayNames([regionCode.toLowerCase()], { type: 'region', }); const countryCode = this._phoneUtil && this._phoneUtil.getCountryCodeForRegionCode(regionCode); const flagSymbol = getFlagSymbol(regionCode); const destinationList = this.preferredRegions.includes(regionCode) ? this.__regionMetaListPreferred : this.__regionMetaList; destinationList.push({ regionCode, countryCode, flagSymbol, nameForLocale: this.__namesForLocale?.of(regionCode), nameForRegion: namesForRegion.of(regionCode), }); }); } }