feat(input-tel-dropdown): new component LionInputTelDropdown

Co-authored-by: David Vossen<David.Vossen@ing.com>
This commit is contained in:
Thijs Louisse 2022-03-15 19:52:26 +01:00
parent a882c94f11
commit 32b322c37e
17 changed files with 1034 additions and 0 deletions

View file

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

View file

@ -0,0 +1,28 @@
# Inputs >> Input Tel Dropdown >> Examples ||30
```js script
import { html } from '@mdjs/mdjs-preview';
import '@lion/select-rich/define';
import './src/intl-input-tel-dropdown.js';
```
## Input Tel International
A visually advanced Subclasser implementation of `LionInputTelDropdown`.
Inspired by:
- [intl-tel-input](https://intl-tel-input.com/)
- [react-phone-input-2](https://github.com/bl00mber/react-phone-input-2)
```js preview-story
export const IntlInputTelDropdown = () => html`
<intl-input-tel-dropdown
.preferredRegions="${['NL', 'PH']}"
.modelValue=${'+639608920056'}
label="Telephone number"
help-text="Advanced dropdown and styling"
name="phoneNumber"
></intl-input-tel-dropdown>
`;
```

View file

@ -0,0 +1,72 @@
# Inputs >> Input Tel Dropdown >> Features ||20
```js script
import { html } from '@mdjs/mdjs-preview';
import { ref, createRef } from '@lion/core';
import { loadDefaultFeedbackMessages } from '@lion/validate-messages';
import { PhoneUtilManager } from '@lion/input-tel';
import '@lion/input-tel-dropdown/define';
import '../../../docs/systems/form/assets/h-output.js';
```
## Input Tel Dropdown
When `.allowedRegions` is not configured, all regions/countries will be available in the dropdown
list. Once a region is chosen, its country/dial code will be adjusted with that of the new locale.
```js preview-story
export const InputTelDropdown = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="Shows all regions by default"
name="phoneNumber"
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Allowed regions
When `.allowedRegions` is configured, only those regions/countries will be available in the dropdown
list.
```js preview-story
export const allowedRegions = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="With region code 'NL'"
.modelValue=${'+31612345678'}
name="phoneNumber"
.allowedRegions=${['NL', 'DE', 'GB']}
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```
## Preferred regions
When `.preferredRegions` is configured, they will show up on top of the dropdown list to
enhance user experience.
```js preview-story
export const preferredRegionCodes = () => html`
<lion-input-tel-dropdown
label="Select region via dropdown"
help-text="Preferred regions show on top"
.modelValue=${'+31612345678'}
name="phoneNumber"
.allowedRegions=${['NL', 'DE', 'GB', 'BE', 'US', 'CA']}
.preferredRegions=${['NL', 'DE']}
></lion-input-tel-dropdown>
<h-output
.show="${['modelValue', 'activeRegion']}"
.readyPromise="${PhoneUtilManager.loadComplete}"
></h-output>
`;
```

View file

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

View file

@ -0,0 +1,36 @@
# Inputs >> Input Tel Dropdown >> Overview ||10
Extension of Input Tel that prefixes a dropdown list that shows all possible regions / countries.
```js script
import { html } from '@mdjs/mdjs-preview';
import '@lion/input-tel-dropdown/define';
```
```js preview-story
export const main = () => {
return html`
<lion-input-tel-dropdown label="Telephone number" name="phoneNumber"></lion-input-tel-dropdown>
`;
};
```
## Features
- Extends our [input-tel](../input-tel/overview.md)
- Shows dropdown list with all possible regions
- Shows only allowed regions in dropdown list when .allowedRegions is configured
- Highlights regions on top of dropdown list when .preferredRegions is configured
- Generates template meta data for advanced
## Installation
```bash
npm i --save @lion/input-tel-dropdown
```
```js
import { LionInputTelDropdown } from '@lion/input-tel-dropdown';
// or
import '@lion/input-tel-dropdown/define';
```

View file

@ -0,0 +1,124 @@
import { html, css, ScopedElementsMixin, ref, repeat } from '@lion/core';
import { LionInputTelDropdown } from '@lion/input-tel-dropdown';
import {
IntlSelectRich,
IntlOption,
IntlSeparator,
} from '../../select-rich/src/intl-select-rich.js';
/**
* @typedef {import('@lion/input-tel-dropdown/types').TemplateDataForDropdownInputTel}TemplateDataForDropdownInputTel
*/
// Example implementation for https://intl-tel-input.com/
export class IntlInputTelDropdown extends ScopedElementsMixin(LionInputTelDropdown) {
/**
* @configure LitElement
* @enhance LionInputTelDropdown
*/
static styles = [
super.styles,
css`
:host,
::slotted(*) {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.42857143;
color: #333;
}
:host {
max-width: 300px;
}
.input-group__container {
width: 100%;
height: 34px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
-webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
-o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
.input-group__input {
padding: 6px;
box-sizing: border-box;
}
.input-group__input ::slotted(input) {
border: none;
outline: none;
}
:host([focused]) .input-group__container {
border-color: #66afe9;
outline: 0;
-webkit-box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%);
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%);
}
`,
];
static templates = {
...(super.templates || {}),
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
*/
dropdown: templateDataForDropdown => {
const { refs, data } = templateDataForDropdown;
// TODO: once spread directive available, use it per ref (like ref(refs?.dropdown?.ref))
return html`
<intl-select-rich
${ref(refs?.dropdown?.ref)}
label="${refs?.dropdown?.labels?.country}"
label-sr-only
@model-value-changed="${refs?.dropdown?.listeners['model-value-changed']}"
style="${refs?.dropdown?.props?.style}"
>
${data?.regionMetaListPreferred?.length
? html` ${repeat(
data.regionMetaListPreferred,
regionMeta => regionMeta.regionCode,
regionMeta =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `,
)}<intl-separator></intl-separator>`
: ''}
${repeat(
data.regionMetaList,
regionMeta => regionMeta.regionCode,
regionMeta =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `,
)}
</intl-select-rich>
`;
},
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
* @param {RegionMeta} regionMeta
*/
// eslint-disable-next-line class-methods-use-this
dropdownOption: (templateDataForDropdown, regionMeta) => html`
<intl-option .choiceValue="${regionMeta.regionCode}" .regionMeta="${regionMeta}">
</intl-option>
`,
};
/**
* @configure ScopedElementsMixin
*/
static scopedElements = {
...super.scopedElements,
'intl-select-rich': IntlSelectRich,
'intl-option': IntlOption,
'intl-separator': IntlSeparator,
};
}
customElements.define('intl-input-tel-dropdown', IntlInputTelDropdown);

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-dropdown.js';

View file

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

View file

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

View file

@ -0,0 +1 @@
export { LionInputTelDropdown } from './src/LionInputTelDropdown.js';

View file

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

View file

@ -0,0 +1,58 @@
{
"name": "@lion/input-tel-dropdown",
"version": "0.0.0",
"description": "Input field for entering phone numbers with the help of a dropdown region list",
"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-dropdown"
},
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"custom-elements.json",
"docs",
"src",
"test",
"types"
],
"scripts": {
"custom-elements-manifest": "custom-elements-manifest analyze --litelement --exclude \"docs/**/*\" \"test-helpers/**/*\"",
"debug": "cd ../../ && npm run debug -- --group input-tel-dropdown",
"debug:firefox": "cd ../../ && npm run debug:firefox -- --group input-tel-dropdown",
"debug:webkit": "cd ../../ && npm run debug:webkit -- --group input-tel-dropdown",
"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-dropdown"
},
"sideEffects": [
"lion-input-tel-dropdown.js"
],
"dependencies": {
"@lion/core": "0.21.1",
"@lion/input-tel": "0.0.0",
"@lion/localize": "0.23.0"
},
"keywords": [
"input",
"input-tel-dropdown",
"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",
"./docs/*": "./docs/*"
}
}

View file

@ -0,0 +1,375 @@
// @ts-expect-error ref, createRef are exported (?)
import { render, html, css, ref, createRef } from '@lion/core';
import { LionInputTel } from '@lion/input-tel';
/**
* 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').FormatStrategy} FormatStrategy
* @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('@lion/input-tel/lib/awesome-phonenumber-esm').default} PhoneNumber
* @typedef {import('@lion/select-rich').LionSelectRich} LionSelectRich
* @typedef {import('@lion/overlays').OverlayController} OverlayController
* @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputTel
*/
// eslint-disable-next-line prefer-destructuring
/**
* @param {string} char
*/
function getRegionalIndicatorSymbol(char) {
return String.fromCodePoint(0x1f1e6 - 65 + char.toUpperCase().charCodeAt(0));
}
/**
* 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 `<select>` element is used for this, so that it's as lightweight as
* possible. Also, it doesn't need to be a `FormControl`, because it's purely a helper element
* to provide better UX: the modelValue (the text field) contains all needed info, since it's in
* `e164` format that contains all info (both region code and national phone number).
*/
export class LionInputTelDropdown extends LionInputTel {
/**
* @configure LitElement
* @type {any}
*/
static properties = { preferredRegions: { type: Array } };
refs = {
/** @type {DropdownRef} */
dropdown: /** @type {DropdownRef} */ (createRef()),
};
/**
* This method provides a TemplateData object to be fed to pure template functions, a.k.a.
* Pure Templates. The goal is to totally decouple presentation from logic here, so that
* Subclassers can override all content without having to loose private info contained
* within the template function that was overridden.
*
* Subclassers would need to make sure all the contents of the TemplateData object are implemented
* by making sure they are coupled to the right 'ref' ([data-ref=dropdown] in this example),
* with the help of lit's spread operator directive.
* To enhance this process, the TemplateData object is completely typed. Ideally, this would be
* enhanced by providing linters that make sure all of their required members are implemented by
* a Subclasser.
* When a Subclasser wants to add more data, this can be done via:
* @example
* ```js
* get _templateDataDropdown() {
* return {
* ...super._templateDataDropdown,
* myExtraData: { x: 1, y: 2 },
* }
* }
* ```
* @overridable
* @type {TemplateDataForDropdownInputTel}
*/
get _templateDataDropdown() {
const refs = {
dropdown: {
ref: this.refs.dropdown,
props: {
style: `height: 100%;`,
},
listeners: {
change: this._onDropdownValueChange,
'model-value-changed': this._onDropdownValueChange,
},
labels: {
// TODO: localize this
selectCountry: 'Select country',
},
},
};
return {
refs,
data: {
activeRegion: this.activeRegion,
regionMetaList: this.__regionMetaList,
regionMetaListPreferred: this.__regionMetaListPreferred,
},
};
}
static templates = {
dropdown: (/** @type {TemplateDataForDropdownInputTel} */ templateDataForDropdown) => {
const { refs, data } = templateDataForDropdown;
const renderOption = (/** @type {RegionMeta} */ regionMeta) =>
html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `;
// TODO: once spread directive available, use it per ref
return html`
<select
${ref(refs?.dropdown?.ref)}
aria-label="${refs?.dropdown?.labels?.selectCountry}"
@change="${refs?.dropdown?.listeners?.change}"
style="${refs?.dropdown?.props?.style}"
>
${data?.regionMetaListPreferred?.length
? html`
${data.regionMetaListPreferred.map(renderOption)}
<option disabled>---------------</option>
${data?.regionMetaList?.map(renderOption)}
`
: html` ${data?.regionMetaList?.map(renderOption)}`}
</select>
`;
},
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
* @param {RegionMeta} contextData
*/
// eslint-disable-next-line class-methods-use-this
dropdownOption: (templateDataForDropdown, { regionCode, countryCode, flagSymbol }) => html`
<option value="${regionCode}">${regionCode} (+${countryCode}) &nbsp; ${flagSymbol}</option>
`,
};
/**
* @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 = [];
/** @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();
}
}
/**
* @protected
* @param {OnDropdownChangeEvent} event
*/
_onDropdownValueChange(event) {
const isInitializing = event.detail?.initialize || !this._phoneUtil;
if (isInitializing) {
return;
}
const prevActiveRegion = this.activeRegion;
this._setActiveRegion(
/** @type {RegionCode} */ (event.target.value || event.target.modelValue),
);
// 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 (countryCode && !this.modelValue) {
// When textbox is empty, prefill it with country code
this.modelValue = `+${countryCode}`;
} else if (prevCountryCode && countryCode) {
// When textbox is not empty, replace country code
this.modelValue = this._callParser(
this.value.replace(`+${prevCountryCode}`, `+${countryCode}`),
);
}
}
// Put focus on text box
const overlayController = event.target._overlayCtrl;
if (overlayController?.isShown) {
setTimeout(() => {
this._inputNode.focus();
});
} else {
// For native select
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;
}
if ('modelValue' in dropdownElement) {
/** @type {* & FormatHost} */ (dropdownElement).modelValue = regionCode;
} else {
/** @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-expect-error Intl.DisplayNames platform api not yet typed
const namesForRegion = new Intl.DisplayNames([regionCode.toLowerCase()], {
type: 'region',
});
const countryCode =
this._phoneUtil && this._phoneUtil.getCountryCodeForRegionCode(regionCode);
const flagSymbol =
getRegionalIndicatorSymbol(regionCode[0]) + getRegionalIndicatorSymbol(regionCode[1]);
const destinationList = this.preferredRegions.includes(regionCode)
? this.__regionMetaListPreferred
: this.__regionMetaList;
destinationList.push({
regionCode,
countryCode,
flagSymbol,
nameForLocale: this.__namesForLocale.of(regionCode),
nameForRegion: namesForRegion.of(regionCode),
});
});
}
}

View file

@ -0,0 +1,226 @@
import {
expect,
fixture as _fixture,
fixtureSync as _fixtureSync,
html,
defineCE,
unsafeStatic,
aTimeout,
} from '@open-wc/testing';
import sinon from 'sinon';
// @ts-ignore
import { PhoneUtilManager } from '@lion/input-tel';
// @ts-ignore
import { mockPhoneUtilManager, restorePhoneUtilManager } from '@lion/input-tel/test-helpers';
import { LionInputTelDropdown } from '../src/LionInputTelDropdown.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
*/
const fixture = /** @type {(arg: string | TemplateResult) => Promise<LionInputTelDropdown>} */ (
_fixture
);
const fixtureSync = /** @type {(arg: string | TemplateResult) => LionInputTelDropdown} */ (
_fixtureSync
);
/**
* @param {DropdownElement} dropdownEl
* @returns {string}
*/
function getDropdownValue(dropdownEl) {
if ('modelValue' in dropdownEl) {
return dropdownEl.modelValue;
}
return dropdownEl.value;
}
/**
* @param {DropdownElement} dropdownEl
* @param {string} value
*/
function mimicUserChangingDropdown(dropdownEl, value) {
if ('modelValue' in dropdownEl) {
// eslint-disable-next-line no-param-reassign
dropdownEl.modelValue = value;
dropdownEl.dispatchEvent(
new CustomEvent('model-value-changed', { detail: { isTriggeredByUser: true } }),
);
} else {
// eslint-disable-next-line no-param-reassign
dropdownEl.value = value;
dropdownEl.dispatchEvent(new Event('change'));
}
}
/**
* @param {{ klass:LionInputTelDropdown }} config
*/
// @ts-ignore
export function runInputTelDropdownSuite({ klass = LionInputTelDropdown } = {}) {
// @ts-ignore
const tagName = defineCE(/** @type {* & HTMLElement} */ (class extends klass {}));
const tag = unsafeStatic(tagName);
describe('LionInputTelDropdown', () => {
beforeEach(async () => {
// Wait till PhoneUtilManager has been loaded
await PhoneUtilManager.loadComplete;
});
describe('Dropdown display', () => {
it('calls `templates.dropdown` with TemplateDataForDropdownInputTel object', async () => {
const el = fixtureSync(html` <${tag}
.modelValue="${'+31612345678'}"
.allowedRegions="${['NL', 'PH']}"
.preferredRegions="${['PH']}"
></${tag}> `);
const spy = sinon.spy(
/** @type {typeof LionInputTelDropdown} */ (el.constructor).templates,
'dropdown',
);
await el.updateComplete;
const dropdownNode = el.refs.dropdown.value;
const templateDataForDropdown = /** @type {TemplateDataForDropdownInputTel} */ (
spy.args[0][0]
);
expect(templateDataForDropdown).to.eql(
/** @type {TemplateDataForDropdownInputTel} */ ({
data: {
activeRegion: 'NL',
regionMetaList: [
{
countryCode: 31,
flagSymbol: '🇳🇱',
nameForLocale: 'Netherlands',
nameForRegion: 'Nederland',
regionCode: 'NL',
},
],
regionMetaListPreferred: [
{
countryCode: 63,
flagSymbol: '🇵🇭',
nameForLocale: 'Philippines',
nameForRegion: 'Philippines',
regionCode: 'PH',
},
],
},
refs: {
dropdown: {
labels: { selectCountry: 'Select country' },
listeners: {
// @ts-expect-error [allow-protected]
change: el._onDropdownValueChange,
// @ts-expect-error [allow-protected]
'model-value-changed': el._onDropdownValueChange,
},
props: { style: 'height: 100%;' },
ref: { value: dropdownNode },
},
},
}),
);
});
it('syncs dropdown value initially from activeRegion', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal(
'DE',
);
});
it('renders to prefix slot in light dom', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
const prefixSlot = /** @type {HTMLElement} */ (
/** @type {HTMLElement} */ (el.refs.dropdown.value).parentElement
);
expect(prefixSlot.getAttribute('slot')).to.equal('prefix');
expect(prefixSlot.slot).to.equal('prefix');
expect(prefixSlot.parentElement).to.equal(el);
});
it('rerenders light dom when PhoneUtil loaded', async () => {
const { resolveLoaded } = mockPhoneUtilManager();
const el = await fixture(html` <${tag} .allowedRegions="${['DE']}"></${tag}> `);
// @ts-ignore
const spy = sinon.spy(el, '_scheduleLightDomRender');
resolveLoaded(undefined);
await aTimeout(0);
expect(spy).to.have.been.calledOnce;
restorePhoneUtilManager();
});
});
describe('On dropdown value change', () => {
it('changes the currently active country code in the textbox', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'NL',
'BE',
]}" .modelValue="${'+31612345678'}"></${tag}> `,
);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
expect(el.activeRegion).to.equal('BE');
expect(el.modelValue).to.equal('+32612345678');
await el.updateComplete;
expect(el.value).to.equal('+32612345678');
});
it('focuses the textbox right after selection', async () => {
const el = await fixture(
html` <${tag} .allowedRegions="${[
'NL',
'BE',
]}" .modelValue="${'+31612345678'}"></${tag}> `,
);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
// @ts-expect-error
expect(el._inputNode).to.equal(document.activeElement);
});
it('prefills country code when text box is empty', async () => {
const el = await fixture(html` <${tag} .allowedRegions="${['NL', 'BE']}"></${tag}> `);
// @ts-ignore
mimicUserChangingDropdown(el.refs.dropdown.value, 'BE');
await el.updateComplete;
await el.updateComplete;
expect(el.value).to.equal('+32');
});
});
describe('On activeRegion change', () => {
it('updates dropdown value ', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+31612345678'}"></${tag}> `);
expect(el.activeRegion).to.equal('NL');
// @ts-expect-error [allow protected]
el._setActiveRegion('BE');
await el.updateComplete;
expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal(
'BE',
);
});
});
describe('When refs.dropdown is a FormControl (LionSelectRich/LionCombobox)', () => {
it('updates dropdown value ', async () => {
const el = await fixture(html` <${tag} .modelValue="${'+31612345678'}"></${tag}> `);
expect(el.activeRegion).to.equal('NL');
// @ts-expect-error [allow protected]
el._setActiveRegion('BE');
await el.updateComplete;
expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal(
'BE',
);
});
});
});
}

View file

@ -0,0 +1,49 @@
import { runInputTelSuite } from '@lion/input-tel/test-suites';
// @ts-ignore
import { ref, repeat, html } from '@lion/core';
import '@lion/select-rich/define';
import { LionInputTelDropdown } from '../src/LionInputTelDropdown.js';
import { runInputTelDropdownSuite } from '../test-suites/LionInputTelDropdown.suite.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
* @typedef {import('../types').RegionMeta} RegionMeta
*/
class WithFormControlInputTelDropdown extends LionInputTelDropdown {
static templates = {
...(super.templates || {}),
/**
* @param {TemplateDataForDropdownInputTel} templateDataForDropdown
*/
dropdown: templateDataForDropdown => {
const { refs, data } = templateDataForDropdown;
// TODO: once spread directive available, use it per ref (like ref(refs?.dropdown?.ref))
return html`
<lion-select-rich
${ref(refs?.dropdown?.ref)}
label="${refs?.dropdown?.labels?.country}"
label-sr-only
@model-value-changed="${refs?.dropdown?.listeners['model-value-changed']}"
style="${refs?.dropdown?.props?.style}"
>
${repeat(
data.regionMetaList,
regionMeta => regionMeta.regionCode,
regionMeta =>
html` <lion-option .choiceValue="${regionMeta.regionCode}"> </lion-option> `,
)}
</lion-select-rich>
`;
},
};
}
// @ts-expect-error
runInputTelSuite({ klass: LionInputTelDropdown });
runInputTelDropdownSuite();
// @ts-expect-error
// Runs it for LionSelectRich, which uses .modelValue/@model-value-changed instead of .value/@change
runInputTelDropdownSuite({ klass: WithFormControlInputTelDropdown });

View file

@ -0,0 +1,44 @@
import { RegionCode } from '@lion/input-tel/types/types';
import { LionSelectRich } from '@lion/select-rich';
import { LionCombobox } from '@lion/combobox';
type RefTemplateData = {
ref?: { value?: HTMLElement };
props?: { [key: string]: any };
listeners?: { [key: string]: any };
labels?: { [key: string]: any };
};
export type RegionMeta = {
countryCode: number;
regionCode: RegionCode;
nameForRegion: string;
nameForLocale: string;
flagSymbol: string;
};
export type OnDropdownChangeEvent = Event & {
target: { value?: string; modelValue?: string; _overlayCtrl?: OverlayController };
detail?: { initialize: boolean };
};
export type DropdownRef = { value: HTMLSelectElement | LionSelectRich | LionCombobox | undefined };
export type TemplateDataForDropdownInputTel = {
refs: {
dropdown: RefTemplateData & {
ref: DropdownRef;
props: { style: string };
listeners: {
change: (event: OnDropdownChangeEvent) => void;
'model-value-changed': (event: OnDropdownChangeEvent) => void;
};
labels: { selectCountry: string };
};
};
data: {
activeRegion: string | undefined;
regionMetaList: RegionMeta[];
regionMetaListPreferred: RegionMeta[];
};
};