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 `` 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`
+
+ ${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`
+
+ ${currencyCode} (${currencySymbol})
+
+ `,
+ };
+
+ /**
+ * @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}>${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;
+ });
+ });
+}
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/*",