diff --git a/.changeset/funny-terms-grin.md b/.changeset/funny-terms-grin.md new file mode 100644 index 000000000..75bbe2a41 --- /dev/null +++ b/.changeset/funny-terms-grin.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +adds the lion-input-amount-dropdown component diff --git a/docs/components/input-amount-dropdown/index.md b/docs/components/input-amount-dropdown/index.md new file mode 100644 index 000000000..7d27c7295 --- /dev/null +++ b/docs/components/input-amount-dropdown/index.md @@ -0,0 +1,13 @@ +--- +parts: + - Input Amount Dropdown +title: Input Amount Dropdown +eleventyNavigation: + key: Input Amount Dropdown + order: 20 + title: Input Amount Dropdown +--- + +# Input Amount Dropdown + +-> go to Overview diff --git a/docs/components/input-amount-dropdown/use-cases.md b/docs/components/input-amount-dropdown/use-cases.md new file mode 100644 index 000000000..69b02d553 --- /dev/null +++ b/docs/components/input-amount-dropdown/use-cases.md @@ -0,0 +1,103 @@ +--- +parts: + - Input Amount Dropdown + - Use Cases +title: 'Input Amount Dropdown: Use Cases' +eleventyNavigation: + key: 'Input Amount Dropdown: Use Cases' + order: 20 + parent: Input Amount Dropdown + title: Use Cases +--- + +# Input Amount Dropdown: Use Cases + +```js script +import { html } from '@mdjs/mdjs-preview'; +import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js'; +import { LionInputAmountDropdown } from '@lion/ui/input-amount-dropdown.js'; +import '@lion/ui/define/lion-input-amount-dropdown.js'; +``` + +## Input Amount Dropdown + +When `.allowedCurrencies` is not configured, all currencies will be available in the dropdown +list. + +```js preview-story +export const InputAmountDropdown = () => { + loadDefaultFeedbackMessages(); + return html` + + `; +}; +``` + +## Allowed currencies + +When `.allowedCurrencies` is configured, only those currencies will be available in the dropdown +list. + +```js preview-story +export const allowedCurrencies = () => { + loadDefaultFeedbackMessages(); + return html` + + `; +}; +``` + +## Preferred currencies + +When `.preferredCurrencies` is configured, they will show up on top of the dropdown list to enhance user experience. + +```js preview-story +export const preferredCurrencies = () => { + loadDefaultFeedbackMessages(); + return html` + + `; +}; +``` + +## Suffix or prefix + +Subclassers can decide the dropdown location via `_dropdownSlot`, this can be set to either `suffix` or `prefix`. + +```js preview-story +class DemoAmountDropdown extends LionInputAmountDropdown { + constructor() { + super(); + + this._dropdownSlot = 'suffix'; + } +} + +customElements.define('demo-amount-dropdown', DemoAmountDropdown); + +export const suffixSlot = () => { + loadDefaultFeedbackMessages(); + return html` + + `; +}; +``` diff --git a/packages/ui/components/input-amount-dropdown/src/LionInputAmountDropdown.js b/packages/ui/components/input-amount-dropdown/src/LionInputAmountDropdown.js new file mode 100644 index 000000000..481d0896d --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/LionInputAmountDropdown.js @@ -0,0 +1,557 @@ +import { html, css } from 'lit'; +import { ref, createRef } from 'lit/directives/ref.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { LionInputAmount } from '@lion/ui/input-amount.js'; +import { currencyUtil } from './currencyUtil.js'; +import { parseAmount } from './parsers.js'; +import { formatAmount } from './formatters.js'; +import { deserializer, serializer } from './serializers.js'; +import { CurrencyAndAmount } from './validators.js'; +import { localizeNamespaceLoader } from './localizeNamespaceLoader.js'; + +/** + * Note: one could consider to implement LionInputAmountDropdown as a + * [combobox](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox). + * However, the currency 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('lit').RenderOptions} RenderOptions + * @typedef {import('../../form-core/types/FormatMixinTypes.js').FormatHost} FormatHost + * @typedef {import('../../input-tel/types/index.js').RegionCode} RegionCode + * @typedef {import('../types/index.js').TemplateDataForDropdownInputAmount} TemplateDataForDropdownInputAmount + * @typedef {import('../types/index.js').OnDropdownChangeEvent} OnDropdownChangeEvent + * @typedef {import('../types/index.js').DropdownRef} DropdownRef + * @typedef {import('../types/index.js').RegionMeta} RegionMeta + * @typedef {import('../types/index.js').CurrencyCode} CurrencyCode + * @typedef {import('../../select-rich/src/LionSelectRich.js').LionSelectRich} LionSelectRich + * @typedef {import('../../overlays/src/OverlayController.js').OverlayController} OverlayController + * @typedef {import('../../form-core/types/FormatMixinTypes.js').FormatOptions} FormatOptions + * @typedef {FormatOptions & {locale?:string;currency:string|undefined}} AmountFormatOptions + * @typedef {TemplateDataForDropdownInputAmount & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputAmount + */ + +/** + * LionInputAmountDropdown renders a dropdown like element next to the text field, inside the + * prefix, or suffix, 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 {TemplateDataForDropdownInputAmount} templateDataForDropdown + * @param {RegionMeta} contextData + */ + // eslint-disable-next-line class-methods-use-this + dropdownOption: ( + templateDataForDropdown, + { currencyCode, nameForLocale, currencySymbol }, + ) => html` + + `, + }; + + /** + * @configure LitElement + * @enhance LionInputAmountDropdown + */ + 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 (render wrapper from SlotMixin) here. Its child, + * [data-ref=dropdown], receives a 100% height as well via inline styles (since we + * can't target from shadow styles). + */ + ::slotted([slot='prefix']), + ::slotted([slot='suffix']) { + height: 100%; + } + + /** + * visually hiding the 'after' slot, leaving it as sr-only (screen-reader only) + * source: https://www.scottohara.me/blog/2017/04/14/inclusively-hidden.html + */ + ::slotted([slot='after']:not(:focus):not(:active)) { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + `, + ]; + + /** + * @configure SlotMixin + */ + get slots() { + return { + ...super.slots, + [this._dropdownSlot]: () => { + const ctor = /** @type {typeof LionInputAmountDropdown} */ (this.constructor); + const { templates } = ctor; + + return { + template: templates.dropdown(this._templateDataDropdown), + renderAsDirectHostChild: true, + }; + }, + }; + } + + /** + * @configure LocalizeMixin + */ + onLocaleUpdated() { + super.onLocaleUpdated(); + + const localeSplitted = this._localizeManager.locale.split('-'); + /** + * @protected + * @type {RegionCode} + */ + this._langIso = /** @type {RegionCode} */ ( + localeSplitted[localeSplitted.length - 1].toUpperCase() + ); + + this.__namesForLocale = new Intl.DisplayNames([this._langIso], { + type: 'currency', + }); + + this.__calculateActiveCurrency(); + this.__createCurrencyMeta(); + } + + /** + * @lifecycle platform + */ + constructor() { + super(); + + this.parser = parseAmount; + + /** + * @param {import("../types/index.js").AmountDropdownModelValue} modelValue + * @param {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} [givenOptions] Locale Options + */ + this.formatter = (modelValue, givenOptions) => formatAmount(modelValue, givenOptions, this); + this.serializer = serializer; + this.deserializer = deserializer; + + this.defaultValidators = [new CurrencyAndAmount()]; + + /** + * Slot position to render the dropdown in + * @type {string} + */ + this.__dropdownSlot = 'prefix'; + + /** + * Regions that will be shown on top of the dropdown + * @type {CurrencyCode[]} + */ + this.preferredCurrencies = []; + + /** + * Regions that are allowed to be selected in the dropdown. + * @type {CurrencyCode[]} + */ + this.allowedCurrencies = []; + + /** + * Group label for all countries, when preferredCountries are shown + * @protected + */ + this._allCurrenciesLabel = ''; + /** + * Group label for preferred countries, when preferredCountries are shown + * @protected + */ + this._preferredCurrenciesLabel = ''; + + /** + * Contains everything needed for rendering region options: + * region code, currency code, display name according to locale, display name + * @private + * @type {RegionMeta[]} + */ + this.__regionMetaList = []; + + /** + * A filtered `this.__regionMetaList`, containing all regions provided in `preferredCurrencies` + * @private + * @type {RegionMeta[]} + */ + this.__regionMetaListPreferred = []; + + /** + * @protected + * @type {EventListener} + */ + this._onDropdownValueChange = this._onDropdownValueChange.bind(this); + /** + * @private + * @type {EventListener} + */ + this.__syncCurrencyWithDropdown = this.__syncCurrencyWithDropdown.bind(this); + + this._currencyUtil = currencyUtil; + } + + /** + * @lifecycle LitElement + * @param {import('lit-element').PropertyValues } changedProperties + */ + willUpdate(changedProperties) { + super.willUpdate(changedProperties); + + if (changedProperties.has('allowedCurrencies')) { + this.__createCurrencyMeta(); + } + } + + /** + * @param {import('lit-element').PropertyValues } changedProperties + */ + updated(changedProperties) { + super.updated(changedProperties); + + this.__syncCurrencyWithDropdown(); + + if (changedProperties.has('disabled') || changedProperties.has('readOnly')) { + if (this.disabled || this.readOnly) { + this.refs.dropdown?.value?.setAttribute('disabled', ''); + } else { + this.refs.dropdown?.value?.removeAttribute('disabled'); + } + } + + if (changedProperties.has('allowedCurrencies') && this.allowedCurrencies.length > 0) { + this.__calculateActiveCurrency(); + } + } + + /** + * @lifecycle LitElement + * @param {import('lit-element').PropertyValues } changedProperties + */ + firstUpdated(changedProperties) { + super.firstUpdated?.(changedProperties); + this._initModelValueBasedOnDropdown(); + } + + /** + * @protected + */ + _initModelValueBasedOnDropdown() { + if (!this._initialModelValue && !this.dirty) { + this.__initializedCurrencyCode = this.currency; + this._initialModelValue = { currency: this.currency }; + this.modelValue = this._initialModelValue; + this.initInteractionState(); + } + } + + /** + * Used for Required validation and computation of interaction states. + * We need to override this, because we prefill the input with the currency code, but for proper UX, + * we don't consider this as having interaction state `prefilled` + * @param {string} modelValue + * @return {boolean} + * @protected + */ + _isEmpty(modelValue = this.modelValue) { + return super._isEmpty(modelValue) || this.currency === this.__initializedCurrencyCode; + } + + /** + * @protected + * @param {OnDropdownChangeEvent} event + */ + _onDropdownValueChange(event) { + const isInitializing = event.detail?.initialize; + const dropdownElement = event.target; + const dropdownValue = /** @type {RegionCode} */ ( + dropdownElement.modelValue || dropdownElement.value + ); + if (isInitializing || this.currency === dropdownValue) { + return; + } + + const prevCurrency = this.currency; + + /** @type {RegionCode | string} */ + this.currency = dropdownValue; + + if (prevCurrency !== this.currency && !this.focused) { + if (!this.value) { + this.modelValue = { currency: this.currency, amount: this.value }; + } else { + /** @type {AmountFormatOptions} */ + (this.formatOptions).currency = this.currency; + this.modelValue = this._callParser(this.value); + } + } + } + + /** + * @private + */ + __syncCurrencyWithDropdown(currencyCode = this.currency) { + const dropdownElement = this.refs.dropdown?.value; + if (!dropdownElement || !currencyCode) { + return; + } + + if ('modelValue' in dropdownElement) { + const dropdownCurrencyCode = dropdownElement.modelValue; + if (dropdownCurrencyCode === currencyCode) { + return; + } + /** @type {* & FormatHost} */ (dropdownElement).modelValue = currencyCode; + } else { + const dropdownCurrencyCode = dropdownElement.value; + if (dropdownCurrencyCode === currencyCode) { + return; + } + /** @type {HTMLSelectElement} */ (dropdownElement).value = currencyCode; + } + } + + /** + * Prepares data for options, like "Greece (Ελλάδα)", where "Greece" is `nameForLocale` and + * "Ελλάδα" `nameForRegion`. + * This should be run on change of: + * - allowedCurrencies + * - locale + * @private + */ + __createCurrencyMeta() { + if (!this._allowedOrAllCurrencies?.length || !this.__namesForLocale) { + return; + } + + this.__regionMetaList = []; + this.__regionMetaListPreferred = []; + + this._allowedOrAllCurrencies.forEach(currencyCode => { + const destinationList = this.preferredCurrencies.includes(currencyCode) + ? this.__regionMetaListPreferred + : this.__regionMetaList; + + destinationList.push({ + currencyCode, + nameForLocale: this.__namesForLocale?.of(currencyCode), + currencySymbol: this._currencyUtil.getCurrencySymbol(currencyCode, this._langIso ?? ''), + }); + }); + } + + /** + * Usually, we don't use composition in regular LionFields (non choice-groups). Here we use a LionSelect(Rich) inside. + * We don't want to repropagate any children, since an Application Developer is not concerned with these internals (see repropate logic in FormControlMixin) + * Also, we don't want to give (wrong) info to InteractionStateMixin, that will set the wrong interaction states based on child info. + * TODO: Make "this._repropagationRole !== 'child'" the default for FormControlMixin + * (so that FormControls used within are never repropagated for LionFields) + * @protected + * @configure FormControlMixin: don't repropagate any children + */ + // eslint-disable-next-line class-methods-use-this + _repropagationCondition() { + return false; + } + + __calculateActiveCurrency() { + // 1. Get the currency from pre-configured allowed currencies (if one entry) + if (this.allowedCurrencies?.length === 1) { + [this.currency] = this.allowedCurrencies; + return; + } + + // 2. Try to get the currency from user input + if (this.modelValue?.currency && this.allowedCurrencies?.includes(this.modelValue?.currency)) { + this.currency = this.modelValue.currency; + return; + } + + // 3. Try to get the currency from the preferred currencies + if (this.preferredCurrencies?.length > 0) { + [this.currency] = this.preferredCurrencies; + return; + } + + // 4. Try to get the currency from locale + if ( + this._langIso && + this._currencyUtil?.countryToCurrencyMap.has(this._langIso) && + this._allowedOrAllCurrencies.includes( + // @ts-expect-error - Set.get always returns a CurrencyCode. + this._currencyUtil?.countryToCurrencyMap.get(this._langIso), + ) + ) { + this.currency = this._currencyUtil?.countryToCurrencyMap.get(this._langIso); + return; + } + + // 5. Not derivable + this.currency = undefined; + } + + /** + * Used for rendering the region/currency list + * @property _allowedOrAllRegions + * @type {CurrencyCode[]} + */ + get _allowedOrAllCurrencies() { + return this.allowedCurrencies?.length + ? this.allowedCurrencies + : Array.from(this._currencyUtil?.allCurrencies) || []; + } +} diff --git a/packages/ui/components/input-amount-dropdown/src/currencyUtil.js b/packages/ui/components/input-amount-dropdown/src/currencyUtil.js new file mode 100644 index 000000000..ac37f2e3a --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/currencyUtil.js @@ -0,0 +1,324 @@ +/** + * This country to currency list was exported from Java. + * Java contains a country:currency map according to the i18n spec, but JS does not. + * + * @type {import("../types/index.js").countryToCurrencyList} + * + * @example + * // The following Java code can be used to export the countryToCurrencyList + * + * import java.util.Currency; + * import java.util.Locale; + * + * public class Main { + * public static void main(String[] args) { + * // get all ISO countries + * String[] ISOCountryCodes = Locale.getISOCountries(); + * + * // loop over all the countries. + * for (String country : ISOCountryCodes) { + * try { + * // get the locale for said country + * Locale locale = new Locale("", country); + * + * // creates a Currency instance which has a bunch of standardized information. + * // from that class we can get the currency linked to the country. + * String currencyCode = Currency.getInstance(locale).getCurrencyCode(); + * + * // prints to the console, which can be copied to update the map if changes occurred + * String output = country + ": '" + currencyCode + "',\n"; + * System.out.print(output); + * } catch (Exception e) { + * } + * } + * } + * } + */ +const countryToCurrencyList = { + AD: 'EUR', + AE: 'AED', + AF: 'AFN', + AG: 'XCD', + AI: 'XCD', + AL: 'ALL', + AM: 'AMD', + AO: 'AOA', + AR: 'ARS', + AS: 'USD', + AT: 'EUR', + AU: 'AUD', + AW: 'AWG', + AX: 'EUR', + AZ: 'AZN', + BA: 'BAM', + BB: 'BBD', + BD: 'BDT', + BE: 'EUR', + BF: 'XOF', + BG: 'BGN', + BH: 'BHD', + BI: 'BIF', + BJ: 'XOF', + BL: 'EUR', + BM: 'BMD', + BN: 'BND', + BO: 'BOB', + BQ: 'USD', + BR: 'BRL', + BS: 'BSD', + BT: 'BTN', + BV: 'NOK', + BW: 'BWP', + BY: 'BYN', + BZ: 'BZD', + CA: 'CAD', + CC: 'AUD', + CD: 'CDF', + CF: 'XAF', + CG: 'XAF', + CH: 'CHF', + CI: 'XOF', + CK: 'NZD', + CL: 'CLP', + CM: 'XAF', + CN: 'CNY', + CO: 'COP', + CR: 'CRC', + CU: 'CUP', + CV: 'CVE', + CW: 'XCG', + CX: 'AUD', + CY: 'EUR', + CZ: 'CZK', + DE: 'EUR', + DJ: 'DJF', + DK: 'DKK', + DM: 'XCD', + DO: 'DOP', + DZ: 'DZD', + EC: 'USD', + EE: 'EUR', + EG: 'EGP', + EH: 'MAD', + ER: 'ERN', + ES: 'EUR', + ET: 'ETB', + FI: 'EUR', + FJ: 'FJD', + FK: 'FKP', + FM: 'USD', + FO: 'DKK', + FR: 'EUR', + GA: 'XAF', + GB: 'GBP', + GD: 'XCD', + GE: 'GEL', + GF: 'EUR', + GG: 'GBP', + GH: 'GHS', + GI: 'GIP', + GL: 'DKK', + GM: 'GMD', + GN: 'GNF', + GP: 'EUR', + GQ: 'XAF', + GR: 'EUR', + GS: 'GBP', + GT: 'GTQ', + GU: 'USD', + GW: 'XOF', + GY: 'GYD', + HK: 'HKD', + HM: 'AUD', + HN: 'HNL', + HR: 'EUR', + HT: 'HTG', + HU: 'HUF', + ID: 'IDR', + IE: 'EUR', + IL: 'ILS', + IM: 'GBP', + IN: 'INR', + IO: 'USD', + IQ: 'IQD', + IR: 'IRR', + IS: 'ISK', + IT: 'EUR', + JE: 'GBP', + JM: 'JMD', + JO: 'JOD', + JP: 'JPY', + KE: 'KES', + KG: 'KGS', + KH: 'KHR', + KI: 'AUD', + KM: 'KMF', + KN: 'XCD', + KP: 'KPW', + KR: 'KRW', + KW: 'KWD', + KY: 'KYD', + KZ: 'KZT', + LA: 'LAK', + LB: 'LBP', + LC: 'XCD', + LI: 'CHF', + LK: 'LKR', + LR: 'LRD', + LS: 'LSL', + LT: 'EUR', + LU: 'EUR', + LV: 'EUR', + LY: 'LYD', + MA: 'MAD', + MC: 'EUR', + MD: 'MDL', + ME: 'EUR', + MF: 'EUR', + MG: 'MGA', + MH: 'USD', + MK: 'MKD', + ML: 'XOF', + MM: 'MMK', + MN: 'MNT', + MO: 'MOP', + MP: 'USD', + MQ: 'EUR', + MR: 'MRU', + MS: 'XCD', + MT: 'EUR', + MU: 'MUR', + MV: 'MVR', + MW: 'MWK', + MX: 'MXN', + MY: 'MYR', + MZ: 'MZN', + NA: 'NAD', + NC: 'XPF', + NE: 'XOF', + NF: 'AUD', + NG: 'NGN', + NI: 'NIO', + NL: 'EUR', + NO: 'NOK', + NP: 'NPR', + NR: 'AUD', + NU: 'NZD', + NZ: 'NZD', + OM: 'OMR', + PA: 'PAB', + PE: 'PEN', + PF: 'XPF', + PG: 'PGK', + PH: 'PHP', + PK: 'PKR', + PL: 'PLN', + PM: 'EUR', + PN: 'NZD', + PR: 'USD', + PS: 'ILS', + PT: 'EUR', + PW: 'USD', + PY: 'PYG', + QA: 'QAR', + RE: 'EUR', + RO: 'RON', + RS: 'RSD', + RU: 'RUB', + RW: 'RWF', + SA: 'SAR', + SB: 'SBD', + SC: 'SCR', + SD: 'SDG', + SE: 'SEK', + SG: 'SGD', + SH: 'SHP', + SI: 'EUR', + SJ: 'NOK', + SK: 'EUR', + SL: 'SLE', + SM: 'EUR', + SN: 'XOF', + SO: 'SOS', + SR: 'SRD', + SS: 'SSP', + ST: 'STN', + SV: 'SVC', + SX: 'XCG', + SY: 'SYP', + SZ: 'SZL', + TC: 'USD', + TD: 'XAF', + TF: 'EUR', + TG: 'XOF', + TH: 'THB', + TJ: 'TJS', + TK: 'NZD', + TL: 'USD', + TM: 'TMT', + TN: 'TND', + TO: 'TOP', + TR: 'TRY', + TT: 'TTD', + TV: 'AUD', + TW: 'TWD', + TZ: 'TZS', + UA: 'UAH', + UG: 'UGX', + UM: 'USD', + US: 'USD', + UY: 'UYU', + UZ: 'UZS', + VA: 'EUR', + VC: 'XCD', + VE: 'VES', + VG: 'USD', + VI: 'USD', + VN: 'VND', + VU: 'VUV', + WF: 'XPF', + WS: 'WST', + YE: 'YER', + YT: 'EUR', + ZA: 'ZAR', + ZM: 'ZMW', + ZW: 'ZWG', +}; + +/** + * Map containing all countries and its corresponding currency, following the i18n standard. + * @type {import("../types/index.js").RegionToCurrencyMap} + */ +const countryToCurrencyMap = /** @type {import("../types/index.js").RegionToCurrencyMap} */ ( + new Map(Object.entries(countryToCurrencyList)) +); + +/** + * Set with all possible currencies derived from the i18n standard. + * @type {import("../types/index.js").AllCurrenciesSet} + */ +const allCurrencies = new Set(Object.values(countryToCurrencyList).sort()); + +/** + * Matches a currency symbol to a currency code, with a given locale based on Intl.numberFormat. + * @param {import("./LionInputAmountDropdown.js").CurrencyCode} currency + * @param {string} locale + * @returns {string} + */ +const getCurrencySymbol = (currency, locale) => { + if (!locale || !currency) { + return ''; + } + + return ( + new Intl.NumberFormat(locale, { style: 'currency', currency }) + .formatToParts(1) + .find(x => x.type === 'currency')?.value || '' + ); +}; + +export const currencyUtil = { + countryToCurrencyMap, + allCurrencies, + getCurrencySymbol, +}; diff --git a/packages/ui/components/input-amount-dropdown/src/formatters.js b/packages/ui/components/input-amount-dropdown/src/formatters.js new file mode 100644 index 000000000..db461ac29 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/formatters.js @@ -0,0 +1,24 @@ +import { formatAmount as _formatAmount } from '@lion/ui/input-amount.js'; +import { currencyUtil } from './currencyUtil.js'; + +/** + * @typedef {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} FormatOptions + * @typedef {import('../types/index.js').AmountDropdownModelValue} AmountDropdownModelValue + */ + +/** + * Formats a number considering the default fraction digits provided by Intl. + * + * @param {import('../types/index.js').AmountDropdownModelValue} modelValue to format + * @param {FormatOptions} [givenOptions] + * @param {AmountDropdownModelValue|undefined} [context] + */ +export const formatAmount = (modelValue, givenOptions, context) => { + // @ts-expect-error - cannot cast string to CurrencyCode outside a TS file + if (currencyUtil.allCurrencies.has(modelValue?.currency) && context) { + // TODO: better way of setting parent currency + context.currency = modelValue?.currency; + } + // @ts-expect-error - cannot cast string to CurrencyCode outside a TS file + return _formatAmount(modelValue?.amount, givenOptions); +}; diff --git a/packages/ui/components/input-amount-dropdown/src/localizeNamespaceLoader.js b/packages/ui/components/input-amount-dropdown/src/localizeNamespaceLoader.js new file mode 100644 index 000000000..b5adcad76 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/localizeNamespaceLoader.js @@ -0,0 +1,7 @@ +/* eslint-disable import/no-extraneous-dependencies */ +export const localizeNamespaceLoader = /** @param {string} locale */ locale => { + switch (locale) { + default: + return import('@lion/ui/input-amount-dropdown-translations/en.js'); + } +}; diff --git a/packages/ui/components/input-amount-dropdown/src/parsers.js b/packages/ui/components/input-amount-dropdown/src/parsers.js new file mode 100644 index 000000000..6bef559ff --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/parsers.js @@ -0,0 +1,23 @@ +import { parseAmount as _parseAmount } from '@lion/ui/input-amount.js'; + +/** + * Uses `parseAmount()` to parse a number string and return the best possible javascript number. + * Rounds up the number with the correct amount of decimals according to the currency. + * + * @example + * parseAmount('1,234.56', {currency: 'EUR'}); => { amount: 1234.56, currency: 'EUR' } + * parseAmount('1,234.56', {currency: 'JPY'}); => { amount: 1235, currency: 'JPY' } + * parseAmount('1,234.56', {currency: 'JOD'}); => { amount: 1234.560, currency: 'JOD' } + * + * @param {string} value Number to be parsed + * @param {import('../../localize/types/LocalizeMixinTypes.js').FormatNumberOptions} [givenOptions] Locale Options + * @returns {import('../types/index.js').AmountDropdownModelValue} + */ +export const parseAmount = (value, givenOptions) => { + const parsedAmount = _parseAmount(value, givenOptions); + + return { + amount: parsedAmount, + currency: givenOptions?.currency, + }; +}; diff --git a/packages/ui/components/input-amount-dropdown/src/serializers.js b/packages/ui/components/input-amount-dropdown/src/serializers.js new file mode 100644 index 000000000..04df94d6a --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/serializers.js @@ -0,0 +1,24 @@ +/** + * Serializes the modelValue to the international standard notation. + * @param {import("../types/index.js").AmountDropdownModelValue} modelValue + * @returns {string} + */ +export const serializer = modelValue => + /** + * There seems to be some debate on a common international notation vs localization. + * The unwritten standard is, e.g: EUR 123 + */ + `${modelValue?.currency} ${modelValue?.amount}`; + +/** + * Deserializes the serializedValue back to modelValue. + * @param {string} serializedValue + * @returns {import("../types/index.js").AmountDropdownModelValue} + */ +export const deserializer = serializedValue => { + const [currency, amount] = serializedValue.split(' '); + return { + currency, + amount, + }; +}; diff --git a/packages/ui/components/input-amount-dropdown/src/validators.js b/packages/ui/components/input-amount-dropdown/src/validators.js new file mode 100644 index 000000000..8c4e13a10 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/src/validators.js @@ -0,0 +1,22 @@ +import { IsNumber, Validator } from '@lion/ui/form-core.js'; +import { currencyUtil } from './currencyUtil.js'; + +/** + * @typedef {import('../../form-core/types/validate/validate.js').FeedbackMessageData} FeedbackMessageData + */ + +export class CurrencyAndAmount extends Validator { + static validatorName = 'CurrencyAndAmount'; + + /** + * @param {import('../types/index.js').AmountDropdownModelValue} modelValue amount and currency symbol + */ + // eslint-disable-next-line class-methods-use-this + execute(modelValue) { + // @ts-expect-error - cannot cast string to CurrencyCode outside a TS file + const validCurrencyCode = currencyUtil.allCurrencies.has(modelValue?.currency); + const isNumber = new IsNumber().execute(modelValue.amount); + + return validCurrencyCode && isNumber; + } +} diff --git a/packages/ui/components/input-amount-dropdown/test-helpers/mimicUserChangingDropdown.js b/packages/ui/components/input-amount-dropdown/test-helpers/mimicUserChangingDropdown.js new file mode 100644 index 000000000..c320146d6 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/test-helpers/mimicUserChangingDropdown.js @@ -0,0 +1,22 @@ +/** + * @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement + */ + +/** + * @param {DropdownElement} dropdownEl + * @param {string} value + */ +export 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')); + dropdownEl.dispatchEvent(new Event('input')); + } +} diff --git a/packages/ui/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js b/packages/ui/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js new file mode 100644 index 000000000..e9eeb89f9 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js @@ -0,0 +1,322 @@ +import { LionInputAmountDropdown } from '@lion/ui/input-amount-dropdown.js'; +import sinon from 'sinon'; +import { + fixtureSync as _fixtureSync, + fixture as _fixture, + unsafeStatic, + aTimeout, + defineCE, + expect, + html, +} from '@open-wc/testing'; + +import { isActiveElement } from '../../core/test-helpers/isActiveElement.js'; +import { mimicUserChangingDropdown } from '../test-helpers/mimicUserChangingDropdown.js'; + +/** + * @typedef {import('../types/index.js').TemplateDataForDropdownInputAmount} TemplateDataForDropdownInputAmount + * @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement + * @typedef {import('lit').TemplateResult} TemplateResult + */ + +const fixture = /** @type {(arg: string | TemplateResult) => Promise} */ ( + _fixture +); +const fixtureSync = /** @type {(arg: string | TemplateResult) => LionInputAmountDropdown} */ ( + _fixtureSync +); + +/** + * @param {DropdownElement | HTMLSelectElement} dropdownEl + * @returns {string} + */ +function getDropdownValue(dropdownEl) { + if ('modelValue' in dropdownEl) { + return dropdownEl.modelValue; + } + return dropdownEl.value; +} + +/** + * @param {{ klass:LionInputAmountDropdown }} config + */ +// @ts-expect-error +export function runInputAmountDropdownSuite({ klass } = { klass: LionInputAmountDropdown }) { + // @ts-ignore + const tagName = defineCE(/** @type {* & HTMLElement} */ (class extends klass {})); + const tag = unsafeStatic(tagName); + + describe('LionInputAmountDropdown', () => { + it('syncs value of dropdown on init if input has no value', async () => { + const el = await fixture(html` <${tag}> `); + expect(el.value).to.equal(''); + expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal( + 'GBP', + ); + expect(el.modelValue).to.eql({ currency: 'GBP' }); + }); + + it('syncs value of dropdown on reset if input has no value', async () => { + const el = await fixture(html` <${tag}> `); + el.modelValue = { currency: 'EUR', amount: 123 }; + await el.updateComplete; + el.reset(); + await el.updateComplete; + expect(el.modelValue).to.eql({ currency: 'GBP' }); + expect(el.value).to.equal(''); + }); + + it('syncs value of dropdown on init if input has no value does not influence interaction states', async () => { + const el = await fixture(html` <${tag}> `); + expect(el.dirty).to.be.false; + expect(el.prefilled).to.be.false; + }); + + it('syncs value of dropdown on reset also resets interaction states', async () => { + const el = await fixture(html` <${tag}> `); + el.modelValue = { currency: 'EUR', amount: 123 }; + await el.updateComplete; + + expect(el.dirty).to.be.true; + expect(el.prefilled).to.be.false; + el.reset(); + await el.updateComplete; + expect(el.dirty).to.be.false; + expect(el.prefilled).to.be.false; + }); + + it('syncs value of dropdown when preferredCurrency is set', async () => { + const el = await fixture(html` <${tag} + .preferredCurrencies="${['JPY', 'EUR']}" + > `); + expect(el.modelValue).to.eql({ currency: 'JPY' }); + }); + + it('sets correct interaction states on init if input has a value', async () => { + const el = await fixture( + html` <${tag} .modelValue="${{ currency: 'EUR', amount: 123 }}"> `, + ); + expect(el.dirty).to.be.false; + expect(el.prefilled).to.be.true; + }); + + describe('Dropdown display', () => { + it('calls `templates.dropdown` with TemplateDataForDropdownInputAmount object', async () => { + const modelValue = { currency: 'EUR', amount: 123 }; + const el = fixtureSync(html` <${tag} + .modelValue="${modelValue}" + .allowedCurrencies="${['EUR', 'GBP']}" + .preferredCurrencies="${['GBP']}" + > `); + const spy = sinon.spy( + /** @type {typeof LionInputAmountDropdown} */ (el.constructor).templates, + 'dropdown', + ); + + await el.updateComplete; + const dropdownNode = el.refs.dropdown.value; + const templateDataForDropdown = /** @type {TemplateDataForDropdownInputAmount} */ ( + spy.args[0][0] + ); + expect(templateDataForDropdown).to.eql( + /** @type {TemplateDataForDropdownInputAmount} */ ({ + data: { + currency: 'EUR', + regionMetaList: [ + { + currencyCode: 'EUR', + currencySymbol: '€', + nameForLocale: 'Euro', + }, + ], + regionMetaListPreferred: [ + { + currencyCode: 'GBP', + currencySymbol: '£', + nameForLocale: 'British Pound', + }, + ], + }, + refs: { + dropdown: { + labels: { + selectCurrency: 'Select currency', + allCurrencies: 'All currencies', + preferredCurrencies: 'Suggested currencies', + }, + 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, + }, + }, + // @ts-expect-error [allow-protected] + input: el._inputNode, + }, + }), + ); + spy.restore(); + }); + + it('can override "all-currencies-label"', async () => { + const el = fixtureSync(html` <${tag} + .preferredCurrencies="${['GBP']}" + > `); + const spy = sinon.spy( + /** @type {typeof LionInputAmountDropdown} */ (el.constructor).templates, + 'dropdown', + ); + // @ts-expect-error [allow-protected] + el._allCurrenciesLabel = 'foo'; + await el.updateComplete; + const templateDataForDropdown = /** @type {TemplateDataForDropdownInputAmount} */ ( + spy.args[0][0] + ); + + expect(templateDataForDropdown.refs.dropdown.labels.allCurrencies).to.eql('foo'); + spy.restore(); + }); + + it('can override "preferred-currencies-label"', async () => { + const el = fixtureSync(html` <${tag} + .preferredCurrencies="${['GBP']}" + > `); + const spy = sinon.spy( + /** @type {typeof LionInputAmountDropdown} */ (el.constructor).templates, + 'dropdown', + ); + // @ts-expect-error [allow-protected] + el._preferredCurrenciesLabel = 'foo'; + await el.updateComplete; + const templateDataForDropdown = /** @type {TemplateDataForDropdownInputAmount} */ ( + spy.args[0][0] + ); + expect(templateDataForDropdown.refs.dropdown.labels.preferredCurrencies).to.eql('foo'); + spy.restore(); + }); + + it('syncs dropdown value initially from locale', async () => { + const el = await fixture(html` <${tag} .allowedCurrencies="${['GBP']}"> `); + expect(getDropdownValue(/** @type {DropdownElement} */ (el.refs.dropdown.value))).to.equal( + 'GBP', + ); + expect(el.modelValue).to.eql({ + currency: 'GBP', + }); + }); + + it('syncs disabled attribute to dropdown', async () => { + const el = await fixture(html` <${tag} disabled> `); + expect(/** @type {HTMLElement} */ (el.refs.dropdown.value)?.hasAttribute('disabled')).to.be + .true; + }); + + it('disables dropdown on readonly', async () => { + const el = await fixture(html` <${tag} readonly> `); + expect(/** @type {HTMLElement} */ (el.refs.dropdown.value)?.hasAttribute('disabled')).to.be + .true; + }); + + it('renders to prefix slot in light dom', async () => { + const el = await fixture(html` <${tag} .allowedCurrencies="${['GBP']}"> `); + const prefixSlot = /** @type {HTMLElement} */ ( + /** @type {HTMLElement} */ (el.refs.dropdown.value) + ); + expect(prefixSlot.getAttribute('slot')).to.equal('prefix'); + expect(prefixSlot.slot).to.equal('prefix'); + expect(prefixSlot.parentElement).to.equal(el); + }); + + it('renders to suffix slot in light dom', async () => { + class WithSuffixRenderInputAmountDropdown extends LionInputAmountDropdown { + constructor() { + super(); + + this._dropdownSlot = 'suffix'; + } + } + + const suffixTagName = defineCE(WithSuffixRenderInputAmountDropdown); + const suffixTag = unsafeStatic(suffixTagName); + + const el = await fixture(html` + <${suffixTag} + .allowedCurrencies="${['GBP']}" + > + `); + const prefixSlot = /** @type {HTMLElement} */ ( + /** @type {HTMLElement} */ (el.refs.dropdown.value) + ); + expect(prefixSlot.getAttribute('slot')).to.equal('suffix'); + expect(prefixSlot.slot).to.equal('suffix'); + expect(prefixSlot.parentElement).to.equal(el); + }); + + it('rerenders light dom when CurrencyUtil loaded', async () => { + const el = await fixture(html` <${tag} .allowedCurrencies="${['GBP']}"> `); + // @ts-ignore + const spy = sinon.spy(el, '__rerenderSlot'); + await aTimeout(0); + expect(spy).to.have.been.calledWith('prefix'); + spy.restore(); + }); + }); + + describe('On dropdown value change', () => { + it('changes the currently active currency code in the textbox', async () => { + const el = await fixture(html` + <${tag} + .allowedCurrencies="${['GBP', 'EUR']}" + .modelValue="${{ currency: 'GBP', amount: 123 }}" + > + `); + // @ts-ignore + mimicUserChangingDropdown(el.refs.dropdown.value, 'EUR'); + await el.updateComplete; + expect(el.currency).to.equal('EUR'); + expect(el.modelValue).to.eql({ amount: 123, currency: 'EUR' }); + await el.updateComplete; + expect(el.value).to.equal('123.00'); + }); + + it('changes the currently active country code in the textbox when empty', async () => { + const el = await fixture(html` <${tag} .allowedCurrencies="${['GBP', 'EUR']}"> `); + el.value = ''; + // @ts-ignore + mimicUserChangingDropdown(el.refs.dropdown.value, 'EUR'); + await el.updateComplete; + await el.updateComplete; + + expect(el.modelValue).to.eql({ currency: 'EUR', amount: '' }); + }); + + it('keeps focus on dropdownElement after selection if selected via unopened dropdown', async () => { + const el = await fixture( + html` <${tag} .allowedCurrencies="${[ + 'GBP', + 'EUR', + ]}" .modelValue="${{ currency: 'GBP', amount: 123 }}"> `, + ); + const dropdownElement = el.refs.dropdown.value; + // @ts-ignore + mimicUserChangingDropdown(dropdownElement, 'EUR'); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-tests] + expect(isActiveElement(el._inputNode)).to.be.false; + }); + }); + }); + + describe('is empty', () => { + it('ignores initial currencyCode', async () => { + const el = await fixture(html` <${tag}> `); + // @ts-ignore + expect(el._isEmpty(), 'empty').to.be.true; + }); + }); +} diff --git a/packages/ui/components/input-amount-dropdown/test/LionInputAmountDropdown.test.js b/packages/ui/components/input-amount-dropdown/test/LionInputAmountDropdown.test.js new file mode 100644 index 000000000..44a7e91c1 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/test/LionInputAmountDropdown.test.js @@ -0,0 +1,199 @@ +import { mimicUserChangingDropdown } from '@lion/ui/input-amount-dropdown-test-helpers.js'; +import { runInputAmountDropdownSuite } from '@lion/ui/input-amount-dropdown-test-suites.js'; +import { LionInputAmountDropdown } from '@lion/ui/input-amount-dropdown.js'; +import { aTimeout, expect, fixture } from '@open-wc/testing'; +import { LionSelectRich } from '@lion/ui/select-rich.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { LionOption } from '@lion/ui/listbox.js'; +import { ref } from 'lit/directives/ref.js'; +import { html } from 'lit'; + +import { isActiveElement } from '../../core/test-helpers/isActiveElement.js'; +import { ScopedElementsMixin } from '../../core/src/ScopedElementsMixin.js'; +import '@lion/ui/define/lion-input-amount-dropdown.js'; + +/** + * @typedef {import('../types/index.js').TemplateDataForDropdownInputAmount} TemplateDataForDropdownInputAmount + * @typedef {HTMLSelectElement|HTMLElement & {modelValue:string}} DropdownElement + * @typedef {import('../types/index.js').RegionMeta} RegionMeta + * @typedef {import('lit').TemplateResult} TemplateResult + */ + +class WithFormControlInputAmountDropdown extends ScopedElementsMixin(LionInputAmountDropdown) { + /** + * @configure ScopedElementsMixin + */ + static scopedElements = { + ...super.scopedElements, + 'lion-select-rich': LionSelectRich, + 'lion-option': LionOption, + }; + + static templates = { + ...(super.templates || {}), + /** + * @param {TemplateDataForDropdownInputAmount} 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.currencyCode, + regionMeta => html` + + `, + )} + + `; + }, + }; +} + +runInputAmountDropdownSuite(); + +describe('WithFormControlInputAmountDropdown', () => { + // @ts-expect-error + // Runs it for LionSelectRich, which uses .modelValue/@model-value-changed instead of .value/@change + runInputAmountDropdownSuite({ klass: WithFormControlInputAmountDropdown }); + + it('focuses the textbox right after selection if selected via opened dropdown if interaction-mode is mac', async () => { + class InputAmountDropdownMac extends LionInputAmountDropdown { + static templates = { + ...(super.templates || {}), + /** + * @param {TemplateDataForDropdownInputAmount} 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.currencyCode, + regionMeta => html` + + `, + )} + + `; + }, + }; + } + customElements.define('input-amount-dropdown-mac', InputAmountDropdownMac); + const el = /** @type {LionInputAmountDropdown} */ ( + await fixture(html` + + `) + ); + const dropdownElement = el.refs.dropdown.value; + // @ts-expect-error [allow-protected-in-tests] + if (dropdownElement?._overlayCtrl) { + // @ts-expect-error [allow-protected-in-tests] + dropdownElement._overlayCtrl.show(); + mimicUserChangingDropdown(dropdownElement, 'EUR'); + await el.updateComplete; + await aTimeout(0); + // @ts-expect-error [allow-protected-in-tests] + expect(isActiveElement(el._inputNode)).to.be.true; + } + }); + + it('does not focus the textbox right after selection if selected via opened dropdown if interaction-mode is windows/linux', async () => { + class InputAmountDropdownWindows extends LionInputAmountDropdown { + static templates = { + ...(super.templates || {}), + /** + * @param {TemplateDataForDropdownInputAmount} 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.currencyCode, + regionMeta => html` + + `, + )} + + `; + }, + }; + } + customElements.define('input-amount-dropdown-windows', InputAmountDropdownWindows); + const el = /** @type {LionInputAmountDropdown} */ ( + await fixture(html` + + `) + ); + const dropdownElement = el.refs.dropdown.value; + // @ts-expect-error [allow-protected-in-tests] + if (dropdownElement?._overlayCtrl) { + // @ts-expect-error [allow-protected-in-tests] + dropdownElement._overlayCtrl.show(); + mimicUserChangingDropdown(dropdownElement, 'EUR'); + await el.updateComplete; + await aTimeout(0); + // @ts-expect-error [allow-protected-in-tests] + expect(isActiveElement(el._inputNode)).to.be.false; + } + }); + + describe('defaultValidators', () => { + it('without interaction are not called', async () => { + const el = /** @type {LionInputAmountDropdown} */ ( + await fixture(html` + + `) + ); + await aTimeout(0); + expect(el.hasFeedbackFor).to.deep.equal([]); + }); + + it('with interaction are called', async () => { + const el = /** @type {LionInputAmountDropdown} */ ( + await fixture(html` + + `) + ); + el.modelValue = { currency: 'EUR' }; + await aTimeout(0); + el.modelValue = { currency: 'EUR', amount: '' }; + expect(el.hasFeedbackFor).to.deep.equal(['error']); + }); + }); +}); diff --git a/packages/ui/components/input-amount-dropdown/translations/en.js b/packages/ui/components/input-amount-dropdown/translations/en.js new file mode 100644 index 000000000..3b13edfa4 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/translations/en.js @@ -0,0 +1,5 @@ +export default { + allCurrencies: 'All currencies', + selectCurrency: 'Select currency', + suggestedCurrencies: 'Suggested currencies', +}; diff --git a/packages/ui/components/input-amount-dropdown/types/index.ts b/packages/ui/components/input-amount-dropdown/types/index.ts new file mode 100644 index 000000000..827d68d69 --- /dev/null +++ b/packages/ui/components/input-amount-dropdown/types/index.ts @@ -0,0 +1,234 @@ +import { LionSelectRich } from '@lion/ui/select-rich.js'; +import { LionCombobox } from '@lion/ui/combobox.js'; +import { OverlayController } from '../../overlays/src/OverlayController.js'; + +type RefTemplateData = { + ref?: { value?: HTMLElement }; + props?: { [key: string]: any }; + listeners?: { [key: string]: any }; + labels?: { [key: string]: any }; +}; + +export type RegionMeta = { + currencyCode: CurrencyCode; + nameForLocale?: string; + currencySymbol?: 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 TemplateDataForDropdownInputAmount = { + refs: { + dropdown: RefTemplateData & { + ref: DropdownRef; + props: { style: string }; + listeners: { + change: (event: OnDropdownChangeEvent) => void; + 'model-value-changed': (event: OnDropdownChangeEvent) => void; + }; + labels: { selectCurrency: string }; + }; + input: HTMLInputElement; + }; + data: { + currency: CurrencyCode | string; + regionMetaList: RegionMeta[]; + regionMetaListPreferred: RegionMeta[]; + }; +}; + +export type AmountDropdownModelValue = { + currency?: CurrencyCode | string; + amount?: number | string; +}; + +/** + * All currency codes according to i18n + */ +export type CurrencyCode = + | 'EUR' + | 'AED' + | 'AFN' + | 'XCD' + | 'ALL' + | 'AMD' + | 'AOA' + | 'ARS' + | 'USD' + | 'AUD' + | 'AWG' + | 'AZN' + | 'BAM' + | 'BBD' + | 'BDT' + | 'XOF' + | 'BGN' + | 'BHD' + | 'BIF' + | 'BMD' + | 'BND' + | 'BOB' + | 'BRL' + | 'BSD' + | 'BTN' + | 'NOK' + | 'BWP' + | 'BYN' + | 'BZD' + | 'CAD' + | 'CDF' + | 'XAF' + | 'CHF' + | 'NZD' + | 'CLP' + | 'CNY' + | 'COP' + | 'CRC' + | 'CUP' + | 'CVE' + | 'ANG' + | 'CZK' + | 'DJF' + | 'DKK' + | 'DOP' + | 'DZD' + | 'EGP' + | 'MAD' + | 'ERN' + | 'ETB' + | 'FJD' + | 'FKP' + | 'GBP' + | 'GEL' + | 'GHS' + | 'GIP' + | 'GMD' + | 'GNF' + | 'GTQ' + | 'GYD' + | 'HKD' + | 'HNL' + | 'HTG' + | 'HUF' + | 'IDR' + | 'ILS' + | 'INR' + | 'IQD' + | 'IRR' + | 'ISK' + | 'JMD' + | 'JOD' + | 'JPY' + | 'KES' + | 'KGS' + | 'KHR' + | 'KMF' + | 'KPW' + | 'KRW' + | 'KWD' + | 'KYD' + | 'KZT' + | 'LAK' + | 'LBP' + | 'LKR' + | 'LRD' + | 'LSL' + | 'LYD' + | 'MDL' + | 'MGA' + | 'MKD' + | 'MMK' + | 'MNT' + | 'MOP' + | 'MRU' + | 'MUR' + | 'MVR' + | 'MWK' + | 'MXN' + | 'MYR' + | 'MZN' + | 'NAD' + | 'XPF' + | 'NGN' + | 'NIO' + | 'NPR' + | 'OMR' + | 'PAB' + | 'PEN' + | 'PGK' + | 'PHP' + | 'PKR' + | 'PLN' + | 'PYG' + | 'QAR' + | 'RON' + | 'RSD' + | 'RUB' + | 'RWF' + | 'SAR' + | 'SBD' + | 'SCR' + | 'SDG' + | 'SEK' + | 'SGD' + | 'SHP' + | 'SLE' + | 'SOS' + | 'SRD' + | 'SSP' + | 'STN' + | 'SVC' + | 'SYP' + | 'SZL' + | 'THB' + | 'TJS' + | 'TMT' + | 'TND' + | 'TOP' + | 'TRY' + | 'TTD' + | 'TWD' + | 'TZS' + | 'UAH' + | 'UGX' + | 'UYU' + | 'UZS' + | 'VED' + | 'VES' + | 'VND' + | 'VUV' + | 'WST' + | 'XCG' + | 'YER' + | 'ZAR' + | 'ZMW' + | 'ZWG'; + +/** + * Type for country to currency list. + * The type is set to Partial as there are some islands that don't have a corresponding + * currencyCode as a standard (at the time of writing at least). + * + * Said missing countries code: AC, TA, XK + */ +export type countryToCurrencyList = Partial<{ + [key in import('../../input-tel/types/index.js').RegionCode]: CurrencyCode; +}>; + +/** + * Represents a mapping of region codes to currency codes using a Map. + */ +export type RegionToCurrencyMap = Map< + import('../../input-tel/types/index.js').RegionCode, + CurrencyCode +>; + +/** + * Represents a set of all unique currency codes derived from the RegionToCurrencyMap values. + */ +export type AllCurrenciesSet = Set; diff --git a/packages/ui/components/input-tel/types/index.ts b/packages/ui/components/input-tel/types/index.ts index cb786183f..45250a52a 100644 --- a/packages/ui/components/input-tel/types/index.ts +++ b/packages/ui/components/input-tel/types/index.ts @@ -36,6 +36,7 @@ export type RegionCode = | 'BR' | 'BS' | 'BT' + | 'BV' | 'BW' | 'BY' | 'BZ' @@ -91,11 +92,13 @@ export type RegionCode = | 'GP' | 'GQ' | 'GR' + | 'GS' | 'GT' | 'GU' | 'GW' | 'GY' | 'HK' + | 'HM' | 'HN' | 'HR' | 'HT' @@ -180,6 +183,7 @@ export type RegionCode = | 'PK' | 'PL' | 'PM' + | 'PN' | 'PR' | 'PS' | 'PT' @@ -215,6 +219,7 @@ export type RegionCode = | 'TA' | 'TC' | 'TD' + | 'TF' | 'TG' | 'TH' | 'TJ' @@ -230,6 +235,7 @@ export type RegionCode = | 'TZ' | 'UA' | 'UG' + | 'UM' | 'US' | 'UY' | 'UZ' diff --git a/packages/ui/components/localize/src/number/getFractionDigits.js b/packages/ui/components/localize/src/number/getFractionDigits.js index c588f00df..476f2a573 100644 --- a/packages/ui/components/localize/src/number/getFractionDigits.js +++ b/packages/ui/components/localize/src/number/getFractionDigits.js @@ -5,7 +5,7 @@ import { formatNumberToParts } from './formatNumberToParts.js'; * getFractionDigits('JOD'); // return 3 * * @typedef {import('../../types/LocalizeMixinTypes.js').FormatNumberPart} FormatNumberPart - * @param {string} [currency="EUR"] Currency code e.g. EUR + * @param {import('../../../input-amount-dropdown/types/index.js').CurrencyCode | string} [currency="EUR"] Currency code e.g. EUR * @returns {number} fraction for the given currency */ export function getFractionDigits(currency = 'EUR') { diff --git a/packages/ui/exports/define/lion-input-amount-dropdown.js b/packages/ui/exports/define/lion-input-amount-dropdown.js new file mode 100644 index 000000000..2e9414787 --- /dev/null +++ b/packages/ui/exports/define/lion-input-amount-dropdown.js @@ -0,0 +1,3 @@ +import { LionInputAmountDropdown } from '../input-amount-dropdown.js'; + +customElements.define('lion-input-amount-dropdown', LionInputAmountDropdown); diff --git a/packages/ui/exports/input-amount-dropdown-test-helpers.js b/packages/ui/exports/input-amount-dropdown-test-helpers.js new file mode 100644 index 000000000..51c079257 --- /dev/null +++ b/packages/ui/exports/input-amount-dropdown-test-helpers.js @@ -0,0 +1 @@ +export { mimicUserChangingDropdown } from '../components/input-amount-dropdown/test-helpers/mimicUserChangingDropdown.js'; diff --git a/packages/ui/exports/input-amount-dropdown-test-suites.js b/packages/ui/exports/input-amount-dropdown-test-suites.js new file mode 100644 index 000000000..809e37e61 --- /dev/null +++ b/packages/ui/exports/input-amount-dropdown-test-suites.js @@ -0,0 +1 @@ +export { runInputAmountDropdownSuite } from '../components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js'; diff --git a/packages/ui/exports/input-amount-dropdown.js b/packages/ui/exports/input-amount-dropdown.js new file mode 100644 index 000000000..93a5e953e --- /dev/null +++ b/packages/ui/exports/input-amount-dropdown.js @@ -0,0 +1 @@ +export { LionInputAmountDropdown } from '../components/input-amount-dropdown/src/LionInputAmountDropdown.js'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 1918abfb7..60b41d50e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,7 @@ "./input-range-translations/*": "./components/input-range/translations/*", "./input-stepper-translations/*": "./components/input-stepper/translations/*", "./input-tel-translations/*": "./components/input-tel/translations/*", + "./input-amount-dropdown-translations/*": "./components/input-amount-dropdown/translations/*", "./overlays-translations/*": "./components/overlays/translations/*", "./pagination-translations/*": "./components/pagination/translations/*", "./progress-indicator-translations/*": "./components/progress-indicator/translations/*",