feat(input-tel-dropdown): new component LionInputTelDropdown
Co-authored-by: David Vossen<David.Vossen@ing.com>
This commit is contained in:
parent
a882c94f11
commit
32b322c37e
17 changed files with 1034 additions and 0 deletions
5
.changeset/big-geese-run.md
Normal file
5
.changeset/big-geese-run.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/input-tel-dropdown': minor
|
||||
---
|
||||
|
||||
New component LionInpuTelDropdown
|
||||
28
docs/components/inputs/input-tel-dropdown/examples.md
Normal file
28
docs/components/inputs/input-tel-dropdown/examples.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
72
docs/components/inputs/input-tel-dropdown/features.md
Normal file
72
docs/components/inputs/input-tel-dropdown/features.md
Normal 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>
|
||||
`;
|
||||
```
|
||||
3
docs/components/inputs/input-tel-dropdown/index.md
Normal file
3
docs/components/inputs/input-tel-dropdown/index.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Inputs >> Input Tel Dropdown ||20
|
||||
|
||||
-> go to Overview
|
||||
36
docs/components/inputs/input-tel-dropdown/overview.md
Normal file
36
docs/components/inputs/input-tel-dropdown/overview.md
Normal 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';
|
||||
```
|
||||
|
|
@ -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);
|
||||
3
packages/input-tel-dropdown/README.md
Normal file
3
packages/input-tel-dropdown/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel
|
||||
|
||||
[=> See Source <=](../../docs/components/inputs/input-tel/overview.md)
|
||||
1
packages/input-tel-dropdown/define.js
Normal file
1
packages/input-tel-dropdown/define.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import './lion-input-tel-dropdown.js';
|
||||
3
packages/input-tel-dropdown/docs/features.md
Normal file
3
packages/input-tel-dropdown/docs/features.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Dropdown Features
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/features.md)
|
||||
3
packages/input-tel-dropdown/docs/overview.md
Normal file
3
packages/input-tel-dropdown/docs/overview.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Lion Input Tel Dropdown Overview
|
||||
|
||||
[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/overview.md)
|
||||
1
packages/input-tel-dropdown/index.js
Normal file
1
packages/input-tel-dropdown/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LionInputTelDropdown } from './src/LionInputTelDropdown.js';
|
||||
3
packages/input-tel-dropdown/lion-input-tel-dropdown.js
Normal file
3
packages/input-tel-dropdown/lion-input-tel-dropdown.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionInputTelDropdown } from './src/LionInputTelDropdown.js';
|
||||
|
||||
customElements.define('lion-input-tel-dropdown', LionInputTelDropdown);
|
||||
58
packages/input-tel-dropdown/package.json
Normal file
58
packages/input-tel-dropdown/package.json
Normal 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/*"
|
||||
}
|
||||
}
|
||||
375
packages/input-tel-dropdown/src/LionInputTelDropdown.js
Normal file
375
packages/input-tel-dropdown/src/LionInputTelDropdown.js
Normal 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}) ${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),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
44
packages/input-tel-dropdown/types/index.d.ts
vendored
Normal file
44
packages/input-tel-dropdown/types/index.d.ts
vendored
Normal 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[];
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue