chore: adds lion-input-amount-dropdown (#2505)

Co-authored-by: gerjanvangeest <Gerjan.van.Geest@ing.com>
This commit is contained in:
Robin Van Roy 2025-08-13 14:40:57 +02:00 committed by GitHub
parent 5f1d716627
commit 57800c4501
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1898 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
adds the lion-input-amount-dropdown component

View file

@ -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

View file

@ -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`
<lion-input-amount-dropdown
label="Select currency via dropdown"
help-text="Shows all currencies by default"
name="amount"
></lion-input-amount-dropdown>
`;
};
```
## 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`
<lion-input-amount-dropdown
label="Select currency via dropdown"
help-text="Shows only allowed currencies"
name="amount"
.allowedCurrencies=${['EUR', 'GBP']}
></lion-input-amount-dropdown>
`;
};
```
## 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`
<lion-input-amount-dropdown
label="Select currency via dropdown"
help-text="Preferred currencies show on top"
name="amount"
.allowedCurrencies=${['EUR', 'GBP', 'USD', 'JPY']}
.preferredCurrencies=${['USD', 'JPY']}
></lion-input-amount-dropdown>
`;
};
```
## 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`
<demo-amount-dropdown
label="Select region via dropdown"
help-text="the dropdown shows in the suffix slot"
name="amount"
></demo-amount-dropdown>
`;
};
```

View file

@ -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 `<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:
* the currency code following ISO 4217 and its corresponding currency symbol using Intl.
*
* @customElement lion-input-amount-dropdown
*/
// @ts-expect-error - The types returned by 'parser(...)' are incompatible between these types. AmountDropdownModelValue' is not assignable to type 'number' */
export class LionInputAmountDropdown extends LionInputAmount {
/**
* @configure LitElement
* @type {any}
*/
static properties = {
preferredCurrencies: { type: Array },
allowedCurrencies: { type: Array },
__dropdownSlot: { type: String },
};
static localizeNamespaces = [
{ 'lion-input-amount-dropdown': localizeNamespaceLoader },
...super.localizeNamespaces,
];
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 {TemplateDataForDropdownInputAmount}
*/
get _templateDataDropdown() {
const refs = {
dropdown: {
ref: this.refs.dropdown,
props: {
style: `height: 100%;`,
},
listeners: {
change: this._onDropdownValueChange,
'model-value-changed': this._onDropdownValueChange,
},
labels: {
selectCurrency: this._localizeManager.msg('lion-input-amount-dropdown:selectCurrency'),
allCurrencies:
this._allCurrenciesLabel ||
this._localizeManager.msg('lion-input-amount-dropdown:allCurrencies'),
preferredCurrencies:
this._preferredCurrenciesLabel ||
this._localizeManager.msg('lion-input-amount-dropdown:suggestedCurrencies'),
},
},
input: this._inputNode,
};
return {
refs,
data: {
// @ts-expect-error - cannot cast string to CurrencyCode outside a TS file
currency: this.currency,
regionMetaList: this.__regionMetaList,
regionMetaListPreferred: this.__regionMetaListPreferred,
},
};
}
/**
* @returns {string}
*/
get _dropdownSlot() {
return /** @type {string} */ this.__dropdownSlot;
}
set _dropdownSlot(position) {
if (position !== 'suffix' && position !== 'prefix') {
throw new Error('Only the suffix and prefix slots are valid positions for the dropdown.');
}
this.__dropdownSlot = position;
}
static templates = {
dropdown: (/** @type {TemplateDataForDropdownInputAmount} */ 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?.selectCurrency}"
@change="${refs?.dropdown?.listeners?.change}"
style="${refs?.dropdown?.props?.style}"
>
${data?.regionMetaListPreferred?.length
? html`
<optgroup label="${refs?.dropdown?.labels?.preferredCurrencies}">
${data.regionMetaListPreferred.map(renderOption)}
</optgroup>
<optgroup label="${refs?.dropdown?.labels?.allCurrencies}">
${data?.regionMetaList?.map(renderOption)}
</optgroup>
`
: html` ${data?.regionMetaList?.map(renderOption)}`}
</select>
`;
},
/**
* @param {TemplateDataForDropdownInputAmount} templateDataForDropdown
* @param {RegionMeta} contextData
*/
// eslint-disable-next-line class-methods-use-this
dropdownOption: (
templateDataForDropdown,
{ currencyCode, nameForLocale, currencySymbol },
) => html`
<option
value="${currencyCode}"
aria-label="${ifDefined(
nameForLocale && currencySymbol ? `${nameForLocale}, ${currencySymbol}` : '',
)}"
>
${currencyCode} (${currencySymbol})&nbsp;
</option>
`,
};
/**
* @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) || [];
}
}

View file

@ -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,
};

View file

@ -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);
};

View file

@ -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');
}
};

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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;
}
}

View file

@ -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'));
}
}

View file

@ -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<LionInputAmountDropdown>} */ (
_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}></${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}></${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}></${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}></${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']}"
></${tag}> `);
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 }}"></${tag}> `,
);
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']}"
></${tag}> `);
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']}"
></${tag}> `);
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']}"
></${tag}> `);
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']}"></${tag}> `);
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></${tag}> `);
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></${tag}> `);
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']}"></${tag}> `);
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']}"
></${suffixTag}>
`);
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']}"></${tag}> `);
// @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 }}"
></${tag}>
`);
// @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']}"></${tag}> `);
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 }}"></${tag}> `,
);
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}></${tag}> `);
// @ts-ignore
expect(el._isEmpty(), 'empty').to.be.true;
});
});
}

View file

@ -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`
<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.currencyCode,
regionMeta => html`
<lion-option .choiceValue="${regionMeta.currencyCode}"> </lion-option>
`,
)}
</lion-select-rich>
`;
},
};
}
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`
<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}"
interaction-mode="mac"
>
${repeat(
data.regionMetaList,
regionMeta => regionMeta.currencyCode,
regionMeta => html`
<lion-option .choiceValue="${regionMeta.currencyCode}"> </lion-option>
`,
)}
</lion-select-rich>
`;
},
};
}
customElements.define('input-amount-dropdown-mac', InputAmountDropdownMac);
const el = /** @type {LionInputAmountDropdown} */ (
await fixture(html`
<input-amount-dropdown-mac
.allowedCurrencies="${['GBP', 'EUR']}"
.modelValue="{ currency: 'GBP', amount: '123' }"
></input-amount-dropdown-mac>
`)
);
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`
<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}"
interaction-mode="windows/linux"
>
${repeat(
data.regionMetaList,
regionMeta => regionMeta.currencyCode,
regionMeta => html`
<lion-option .choiceValue="${regionMeta.currencyCode}"> </lion-option>
`,
)}
</lion-select-rich>
`;
},
};
}
customElements.define('input-amount-dropdown-windows', InputAmountDropdownWindows);
const el = /** @type {LionInputAmountDropdown} */ (
await fixture(html`
<input-amount-dropdown-windows
.allowedCurrencies="${['GBP', 'EUR']}"
.modelValue="{ currency: 'GBP', amount: '123' }"
></input-amount-dropdown-windows>
`)
);
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`
<lion-input-amount-dropdown .allowedCurrencies="${['EUR']}"></lion-input-amount-dropdown>
`)
);
await aTimeout(0);
expect(el.hasFeedbackFor).to.deep.equal([]);
});
it('with interaction are called', async () => {
const el = /** @type {LionInputAmountDropdown} */ (
await fixture(html`
<lion-input-amount-dropdown
.allowedCurrencies="${['GBP', 'EUR']}"
></lion-input-amount-dropdown>
`)
);
el.modelValue = { currency: 'EUR' };
await aTimeout(0);
el.modelValue = { currency: 'EUR', amount: '' };
expect(el.hasFeedbackFor).to.deep.equal(['error']);
});
});
});

View file

@ -0,0 +1,5 @@
export default {
allCurrencies: 'All currencies',
selectCurrency: 'Select currency',
suggestedCurrencies: 'Suggested currencies',
};

View file

@ -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<CurrencyCode>;

View file

@ -36,6 +36,7 @@ export type RegionCode =
| 'BR' | 'BR'
| 'BS' | 'BS'
| 'BT' | 'BT'
| 'BV'
| 'BW' | 'BW'
| 'BY' | 'BY'
| 'BZ' | 'BZ'
@ -91,11 +92,13 @@ export type RegionCode =
| 'GP' | 'GP'
| 'GQ' | 'GQ'
| 'GR' | 'GR'
| 'GS'
| 'GT' | 'GT'
| 'GU' | 'GU'
| 'GW' | 'GW'
| 'GY' | 'GY'
| 'HK' | 'HK'
| 'HM'
| 'HN' | 'HN'
| 'HR' | 'HR'
| 'HT' | 'HT'
@ -180,6 +183,7 @@ export type RegionCode =
| 'PK' | 'PK'
| 'PL' | 'PL'
| 'PM' | 'PM'
| 'PN'
| 'PR' | 'PR'
| 'PS' | 'PS'
| 'PT' | 'PT'
@ -215,6 +219,7 @@ export type RegionCode =
| 'TA' | 'TA'
| 'TC' | 'TC'
| 'TD' | 'TD'
| 'TF'
| 'TG' | 'TG'
| 'TH' | 'TH'
| 'TJ' | 'TJ'
@ -230,6 +235,7 @@ export type RegionCode =
| 'TZ' | 'TZ'
| 'UA' | 'UA'
| 'UG' | 'UG'
| 'UM'
| 'US' | 'US'
| 'UY' | 'UY'
| 'UZ' | 'UZ'

View file

@ -5,7 +5,7 @@ import { formatNumberToParts } from './formatNumberToParts.js';
* getFractionDigits('JOD'); // return 3 * getFractionDigits('JOD'); // return 3
* *
* @typedef {import('../../types/LocalizeMixinTypes.js').FormatNumberPart} FormatNumberPart * @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 * @returns {number} fraction for the given currency
*/ */
export function getFractionDigits(currency = 'EUR') { export function getFractionDigits(currency = 'EUR') {

View file

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

View file

@ -0,0 +1 @@
export { mimicUserChangingDropdown } from '../components/input-amount-dropdown/test-helpers/mimicUserChangingDropdown.js';

View file

@ -0,0 +1 @@
export { runInputAmountDropdownSuite } from '../components/input-amount-dropdown/test-suites/LionInputAmountDropdown.suite.js';

View file

@ -0,0 +1 @@
export { LionInputAmountDropdown } from '../components/input-amount-dropdown/src/LionInputAmountDropdown.js';

View file

@ -25,6 +25,7 @@
"./input-range-translations/*": "./components/input-range/translations/*", "./input-range-translations/*": "./components/input-range/translations/*",
"./input-stepper-translations/*": "./components/input-stepper/translations/*", "./input-stepper-translations/*": "./components/input-stepper/translations/*",
"./input-tel-translations/*": "./components/input-tel/translations/*", "./input-tel-translations/*": "./components/input-tel/translations/*",
"./input-amount-dropdown-translations/*": "./components/input-amount-dropdown/translations/*",
"./overlays-translations/*": "./components/overlays/translations/*", "./overlays-translations/*": "./components/overlays/translations/*",
"./pagination-translations/*": "./components/pagination/translations/*", "./pagination-translations/*": "./components/pagination/translations/*",
"./progress-indicator-translations/*": "./components/progress-indicator/translations/*", "./progress-indicator-translations/*": "./components/progress-indicator/translations/*",