From 32b322c37e4bc5faeb2c2e258b543d6208e9a42b Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Tue, 15 Mar 2022 19:52:26 +0100 Subject: [PATCH] feat(input-tel-dropdown): new component LionInputTelDropdown Co-authored-by: David Vossen --- .changeset/big-geese-run.md | 5 + .../inputs/input-tel-dropdown/examples.md | 28 ++ .../inputs/input-tel-dropdown/features.md | 72 ++++ .../inputs/input-tel-dropdown/index.md | 3 + .../inputs/input-tel-dropdown/overview.md | 36 ++ .../src/intl-input-tel-dropdown.js | 124 ++++++ packages/input-tel-dropdown/README.md | 3 + packages/input-tel-dropdown/define.js | 1 + packages/input-tel-dropdown/docs/features.md | 3 + packages/input-tel-dropdown/docs/overview.md | 3 + packages/input-tel-dropdown/index.js | 1 + .../lion-input-tel-dropdown.js | 3 + packages/input-tel-dropdown/package.json | 58 +++ .../src/LionInputTelDropdown.js | 375 ++++++++++++++++++ .../test-suites/LionInputTelDropdown.suite.js | 226 +++++++++++ .../test/LionInputTelDropdown.test.js | 49 +++ packages/input-tel-dropdown/types/index.d.ts | 44 ++ 17 files changed, 1034 insertions(+) create mode 100644 .changeset/big-geese-run.md create mode 100644 docs/components/inputs/input-tel-dropdown/examples.md create mode 100644 docs/components/inputs/input-tel-dropdown/features.md create mode 100644 docs/components/inputs/input-tel-dropdown/index.md create mode 100644 docs/components/inputs/input-tel-dropdown/overview.md create mode 100644 docs/components/inputs/input-tel-dropdown/src/intl-input-tel-dropdown.js create mode 100644 packages/input-tel-dropdown/README.md create mode 100644 packages/input-tel-dropdown/define.js create mode 100644 packages/input-tel-dropdown/docs/features.md create mode 100644 packages/input-tel-dropdown/docs/overview.md create mode 100644 packages/input-tel-dropdown/index.js create mode 100644 packages/input-tel-dropdown/lion-input-tel-dropdown.js create mode 100644 packages/input-tel-dropdown/package.json create mode 100644 packages/input-tel-dropdown/src/LionInputTelDropdown.js create mode 100644 packages/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.js create mode 100644 packages/input-tel-dropdown/test/LionInputTelDropdown.test.js create mode 100644 packages/input-tel-dropdown/types/index.d.ts diff --git a/.changeset/big-geese-run.md b/.changeset/big-geese-run.md new file mode 100644 index 000000000..916c3afd8 --- /dev/null +++ b/.changeset/big-geese-run.md @@ -0,0 +1,5 @@ +--- +'@lion/input-tel-dropdown': minor +--- + +New component LionInpuTelDropdown diff --git a/docs/components/inputs/input-tel-dropdown/examples.md b/docs/components/inputs/input-tel-dropdown/examples.md new file mode 100644 index 000000000..60d258b4b --- /dev/null +++ b/docs/components/inputs/input-tel-dropdown/examples.md @@ -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` + +`; +``` diff --git a/docs/components/inputs/input-tel-dropdown/features.md b/docs/components/inputs/input-tel-dropdown/features.md new file mode 100644 index 000000000..42478e6d5 --- /dev/null +++ b/docs/components/inputs/input-tel-dropdown/features.md @@ -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` + + +`; +``` + +## Allowed regions + +When `.allowedRegions` is configured, only those regions/countries will be available in the dropdown +list. + +```js preview-story +export const allowedRegions = () => html` + + +`; +``` + +## 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` + + +`; +``` diff --git a/docs/components/inputs/input-tel-dropdown/index.md b/docs/components/inputs/input-tel-dropdown/index.md new file mode 100644 index 000000000..f2453e8bd --- /dev/null +++ b/docs/components/inputs/input-tel-dropdown/index.md @@ -0,0 +1,3 @@ +# Inputs >> Input Tel Dropdown ||20 + +-> go to Overview diff --git a/docs/components/inputs/input-tel-dropdown/overview.md b/docs/components/inputs/input-tel-dropdown/overview.md new file mode 100644 index 000000000..7a6d46dba --- /dev/null +++ b/docs/components/inputs/input-tel-dropdown/overview.md @@ -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` + + `; +}; +``` + +## 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'; +``` diff --git a/docs/components/inputs/input-tel-dropdown/src/intl-input-tel-dropdown.js b/docs/components/inputs/input-tel-dropdown/src/intl-input-tel-dropdown.js new file mode 100644 index 000000000..08bdefb21 --- /dev/null +++ b/docs/components/inputs/input-tel-dropdown/src/intl-input-tel-dropdown.js @@ -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` + + ${data?.regionMetaListPreferred?.length + ? html` ${repeat( + data.regionMetaListPreferred, + regionMeta => regionMeta.regionCode, + regionMeta => + html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `, + )}` + : ''} + ${repeat( + data.regionMetaList, + regionMeta => regionMeta.regionCode, + regionMeta => + html`${this.templates.dropdownOption(templateDataForDropdown, regionMeta)} `, + )} + + `; + }, + /** + * @param {TemplateDataForDropdownInputTel} templateDataForDropdown + * @param {RegionMeta} regionMeta + */ + // eslint-disable-next-line class-methods-use-this + dropdownOption: (templateDataForDropdown, regionMeta) => html` + + + `, + }; + + /** + * @configure ScopedElementsMixin + */ + static scopedElements = { + ...super.scopedElements, + 'intl-select-rich': IntlSelectRich, + 'intl-option': IntlOption, + 'intl-separator': IntlSeparator, + }; +} +customElements.define('intl-input-tel-dropdown', IntlInputTelDropdown); diff --git a/packages/input-tel-dropdown/README.md b/packages/input-tel-dropdown/README.md new file mode 100644 index 000000000..15477ff9c --- /dev/null +++ b/packages/input-tel-dropdown/README.md @@ -0,0 +1,3 @@ +# Lion Input Tel + +[=> See Source <=](../../docs/components/inputs/input-tel/overview.md) diff --git a/packages/input-tel-dropdown/define.js b/packages/input-tel-dropdown/define.js new file mode 100644 index 000000000..f7d4be162 --- /dev/null +++ b/packages/input-tel-dropdown/define.js @@ -0,0 +1 @@ +import './lion-input-tel-dropdown.js'; diff --git a/packages/input-tel-dropdown/docs/features.md b/packages/input-tel-dropdown/docs/features.md new file mode 100644 index 000000000..f6da18052 --- /dev/null +++ b/packages/input-tel-dropdown/docs/features.md @@ -0,0 +1,3 @@ +# Lion Input Tel Dropdown Features + +[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/features.md) diff --git a/packages/input-tel-dropdown/docs/overview.md b/packages/input-tel-dropdown/docs/overview.md new file mode 100644 index 000000000..b823a3e67 --- /dev/null +++ b/packages/input-tel-dropdown/docs/overview.md @@ -0,0 +1,3 @@ +# Lion Input Tel Dropdown Overview + +[=> See Source <=](../../../docs/components/inputs/input-tel-dropdown/overview.md) diff --git a/packages/input-tel-dropdown/index.js b/packages/input-tel-dropdown/index.js new file mode 100644 index 000000000..bcce24fb2 --- /dev/null +++ b/packages/input-tel-dropdown/index.js @@ -0,0 +1 @@ +export { LionInputTelDropdown } from './src/LionInputTelDropdown.js'; diff --git a/packages/input-tel-dropdown/lion-input-tel-dropdown.js b/packages/input-tel-dropdown/lion-input-tel-dropdown.js new file mode 100644 index 000000000..d696e2c09 --- /dev/null +++ b/packages/input-tel-dropdown/lion-input-tel-dropdown.js @@ -0,0 +1,3 @@ +import { LionInputTelDropdown } from './src/LionInputTelDropdown.js'; + +customElements.define('lion-input-tel-dropdown', LionInputTelDropdown); diff --git a/packages/input-tel-dropdown/package.json b/packages/input-tel-dropdown/package.json new file mode 100644 index 000000000..2fa856524 --- /dev/null +++ b/packages/input-tel-dropdown/package.json @@ -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/*" + } +} diff --git a/packages/input-tel-dropdown/src/LionInputTelDropdown.js b/packages/input-tel-dropdown/src/LionInputTelDropdown.js new file mode 100644 index 000000000..de9ec97d9 --- /dev/null +++ b/packages/input-tel-dropdown/src/LionInputTelDropdown.js @@ -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 ` + ${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 = []; + + /** @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), + }); + }); + } +} diff --git a/packages/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.js b/packages/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.js new file mode 100644 index 000000000..661e01671 --- /dev/null +++ b/packages/input-tel-dropdown/test-suites/LionInputTelDropdown.suite.js @@ -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} */ ( + _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']}" + > `); + 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']}"> `); + 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']}"> `); + 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']}"> `); + // @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'}"> `, + ); + // @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'}"> `, + ); + // @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']}"> `); + // @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'}"> `); + 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'}"> `); + 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', + ); + }); + }); + }); +} diff --git a/packages/input-tel-dropdown/test/LionInputTelDropdown.test.js b/packages/input-tel-dropdown/test/LionInputTelDropdown.test.js new file mode 100644 index 000000000..f6e1768ea --- /dev/null +++ b/packages/input-tel-dropdown/test/LionInputTelDropdown.test.js @@ -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` + + ${repeat( + data.regionMetaList, + regionMeta => regionMeta.regionCode, + regionMeta => + html` `, + )} + + `; + }, + }; +} + +// @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 }); diff --git a/packages/input-tel-dropdown/types/index.d.ts b/packages/input-tel-dropdown/types/index.d.ts new file mode 100644 index 000000000..18f4fd483 --- /dev/null +++ b/packages/input-tel-dropdown/types/index.d.ts @@ -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[]; + }; +};