diff --git a/.changeset/great-coins-pretend.md b/.changeset/great-coins-pretend.md new file mode 100644 index 000000000..8dbbe0678 --- /dev/null +++ b/.changeset/great-coins-pretend.md @@ -0,0 +1,5 @@ +--- +'@lion/input-amount': minor +--- + +InputAmount component is now typed properly. diff --git a/packages/input-amount/index.d.ts b/packages/input-amount/index.d.ts new file mode 100644 index 000000000..301152b45 --- /dev/null +++ b/packages/input-amount/index.d.ts @@ -0,0 +1,3 @@ +export { LionInputAmount } from './src/LionInputAmount.js'; +export { formatAmount } from './src/formatters.js'; +export { parseAmount } from './src/parsers.js'; diff --git a/packages/input-amount/src/LionInputAmount.js b/packages/input-amount/src/LionInputAmount.js index 4052ee557..2c4a33f56 100644 --- a/packages/input-amount/src/LionInputAmount.js +++ b/packages/input-amount/src/LionInputAmount.js @@ -11,6 +11,7 @@ import { parseAmount } from './parsers.js'; * @customElement lion-input-amount * @extends {LionInput} */ +// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you. export class LionInputAmount extends LocalizeMixin(LionInput) { static get properties() { return { @@ -18,14 +19,12 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { * @desc an iso code like 'EUR' or 'USD' that will be displayed next to the input * and from which an accessible label (like 'euros') is computed for screen * reader users - * @type {string} */ currency: String, /** * @desc the modelValue of the input-amount has the 'Number' type. This allows * Application Developers to easily read from and write to this input or write custom * validators. - * @type {number} */ modelValue: Number, }; @@ -55,7 +54,7 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { static get styles() { return [ - ...super.styles, + super.styles, css` .input-group__container > .input-group__input ::slotted(.form-control) { text-align: right; @@ -68,6 +67,8 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { super(); this.parser = parseAmount; this.formatter = formatAmount; + /** @type {string | undefined} */ + this.currency = undefined; this.__isPasting = false; this.addEventListener('paste', () => { @@ -89,9 +90,10 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { } } + /** @param {import('lit-element').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); - if (changedProperties.has('currency')) { + if (changedProperties.has('currency') && this.currency) { this._onCurrencyChanged({ currency: this.currency }); } } @@ -114,12 +116,16 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { return super._reflectBackOn() || this.__isPasting; } + /** + * @param {Object} opts + * @param {string} opts.currency + */ _onCurrencyChanged({ currency }) { - if (this._isPrivateSlot('after')) { + if (this._isPrivateSlot('after') && this._currencyDisplayNode) { this._currencyDisplayNode.textContent = this.__currencyLabel; } this.formatOptions.currency = currency; - this._calculateValues(); + this._calculateValues({ source: null }); this.__setCurrencyDisplayLabel(); } @@ -127,10 +133,12 @@ export class LionInputAmount extends LocalizeMixin(LionInput) { // TODO: (@erikkroes) for optimal a11y, abbreviations should be part of aria-label // example, for a language switch with text 'en', an aria-label of 'english' is not // sufficient, it should also contain the abbreviation. - this._currencyDisplayNode.setAttribute('aria-label', getCurrencyName(this.currency)); + if (this.currency && this._currencyDisplayNode) { + this._currencyDisplayNode.setAttribute('aria-label', getCurrencyName(this.currency, {})); + } } get __currencyLabel() { - return formatCurrencyLabel(this.currency, localize.locale); + return this.currency ? formatCurrencyLabel(this.currency, localize.locale) : ''; } } diff --git a/packages/input-amount/src/formatters.js b/packages/input-amount/src/formatters.js index a6f1f7315..8e8da0880 100644 --- a/packages/input-amount/src/formatters.js +++ b/packages/input-amount/src/formatters.js @@ -1,19 +1,22 @@ import { formatNumber, getFractionDigits, normalizeCurrencyLabel } from '@lion/localize'; +/** + * @typedef {import('@lion/localize/types/LocalizeMixinTypes').FormatNumberOptions} FormatOptions + */ + /** * Formats a number considering the default fraction digits provided by Intl. * - * @param {float} modelValue Number to format - * @param {object} givenOptions Options for Intl + * @param {number} modelValue Number to format + * @param {FormatOptions} [givenOptions] */ export function formatAmount(modelValue, givenOptions) { - if (modelValue === '') { - return ''; - } + /** @type {FormatOptions} */ const options = { currency: 'EUR', ...givenOptions, }; + if (typeof options.minimumFractionDigits === 'undefined') { options.minimumFractionDigits = getFractionDigits(options.currency); } @@ -24,6 +27,11 @@ export function formatAmount(modelValue, givenOptions) { return formatNumber(modelValue, options); } +/** + * + * @param {string} currency + * @param {string} locale + */ export function formatCurrencyLabel(currency, locale) { if (currency === '') { return ''; diff --git a/packages/input-amount/src/parsers.js b/packages/input-amount/src/parsers.js index d63b499d5..f04e1bb23 100644 --- a/packages/input-amount/src/parsers.js +++ b/packages/input-amount/src/parsers.js @@ -64,15 +64,19 @@ function getParseMode(value, { mode = 'auto' } = {}) { * parseWithLocale('1,234', { locale: 'en-GB' }) => 1234 * * @param {string} value Number to be parsed - * @param {object} options Locale Options + * @param {Object} options Locale Options + * @param {string} [options.locale] */ function parseWithLocale(value, options) { const locale = options && options.locale ? options.locale : null; const separator = getDecimalSeparator(locale); const regexNumberAndLocaleSeparator = new RegExp(`[0-9${separator}-]`, 'g'); - let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator).join(''); + let numberAndLocaleSeparator = value.match(regexNumberAndLocaleSeparator)?.join(''); if (separator === ',') { - numberAndLocaleSeparator = numberAndLocaleSeparator.replace(',', '.'); + numberAndLocaleSeparator = numberAndLocaleSeparator?.replace(',', '.'); + } + if (!numberAndLocaleSeparator) { + return NaN; } return parseFloat(numberAndLocaleSeparator); } @@ -84,7 +88,7 @@ function parseWithLocale(value, options) { * Warning: This function works only with numbers that can be heuristically parsed. * * @param {string} value Number that can be heuristically parsed - * @return {float} parsed javascript number + * @return {number} parsed javascript number */ function parseHeuristic(value) { if (value.match(/[0-9., ]/g)) { @@ -115,7 +119,7 @@ function parseHeuristic(value) { * parseAmount('1,234.56'); // method: heuristic => 1234.56 * * @param {string} value Number to be parsed - * @param {object} options Locale Options + * @param {object} [options] Locale Options */ export function parseAmount(value, options) { const containsNumbers = value.match(/\d/g); @@ -129,10 +133,15 @@ export function parseAmount(value, options) { const cleanedInput = matchedInput.join(''); const parseMode = getParseMode(cleanedInput, options); switch (parseMode) { - case 'unparseable': - return parseFloat(cleanedInput.match(/[0-9]/g).join('')); + case 'unparseable': { + const cleanedInputMatchStr = cleanedInput.match(/[0-9]/g)?.join(''); + if (!cleanedInputMatchStr) { + return NaN; + } + return parseFloat(cleanedInputMatchStr); + } case 'withLocale': - return parseWithLocale(cleanedInput, options); + return parseWithLocale(cleanedInput, options || {}); case 'heuristic': return parseHeuristic(cleanedInput); default: diff --git a/packages/input-amount/test/lion-input-amount-integrations.test.js b/packages/input-amount/test/lion-input-amount-integrations.test.js index c278cf409..3be8ab702 100644 --- a/packages/input-amount/test/lion-input-amount-integrations.test.js +++ b/packages/input-amount/test/lion-input-amount-integrations.test.js @@ -7,7 +7,6 @@ const tagString = 'lion-input-amount'; describe(' integrations', () => { runInteractionStateMixinSuite({ tagString, - suffix: 'lion-input-amount', allowedModelValueTypes: [Number], }); diff --git a/packages/input-amount/test/lion-input-amount.test.js b/packages/input-amount/test/lion-input-amount.test.js index 730cc0ad4..c40f259ea 100644 --- a/packages/input-amount/test/lion-input-amount.test.js +++ b/packages/input-amount/test/lion-input-amount.test.js @@ -6,34 +6,40 @@ import '../lion-input-amount.js'; import { formatAmount } from '../src/formatters.js'; import { parseAmount } from '../src/parsers.js'; +/** + * @typedef {import('../src/LionInputAmount').LionInputAmount} LionInputAmount + */ + describe('', () => { beforeEach(() => { localizeTearDown(); }); it('uses formatAmount for formatting', async () => { - const el = await fixture(``); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); expect(el.formatter).to.equal(formatAmount); }); it('formatAmount uses currency provided on webcomponent', async () => { // JOD displays 3 fraction digits by default localize.locale = 'fr-FR'; - const el = await fixture( + const el = /** @type {LionInputAmount} */ (await fixture( html``, - ); + )); expect(el.formattedValue).to.equal('123,000'); }); it('formatAmount uses locale provided in formatOptions', async () => { - let el = await fixture( + let el = /** @type {LionInputAmount} */ (await fixture( html` `, - ); + )); expect(el.formattedValue).to.equal('123.00'); el = await fixture( html` @@ -47,27 +53,33 @@ describe('', () => { }); it('ignores global locale change if property is provided', async () => { - const el = await fixture(html` + const el = /** @type {LionInputAmount} */ (await fixture(html` - `); + `)); expect(el.formattedValue).to.equal('123,456.78'); // British localize.locale = 'nl-NL'; - await aTimeout(); + await aTimeout(0); expect(el.formattedValue).to.equal('123,456.78'); // should stay British }); it('uses parseAmount for parsing', async () => { - const el = await fixture(``); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); expect(el.parser).to.equal(parseAmount); }); it('sets inputmode attribute to decimal', async () => { - const el = await fixture(``); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); expect(el._inputNode.inputMode).to.equal('decimal'); }); it('has type="text" to activate default keyboard on mobile with all necessary symbols', async () => { - const el = await fixture(``); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); expect(el._inputNode.type).to.equal('text'); }); @@ -77,53 +89,74 @@ describe('', () => { }); it('displays currency if provided', async () => { - const el = await fixture(``); - expect(Array.from(el.children).find(child => child.slot === 'after').innerText).to.equal('EUR'); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); + expect( + /** @type {HTMLElement[]} */ (Array.from(el.children)).find(child => child.slot === 'after') + ?.innerText, + ).to.equal('EUR'); }); it('displays correct currency for TRY if locale is tr-TR', async () => { localize.locale = 'tr-TR'; - const el = await fixture(``); - expect(Array.from(el.children).find(child => child.slot === 'after').innerText).to.equal('TL'); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); + expect( + /** @type {HTMLElement[]} */ (Array.from(el.children)).find(child => child.slot === 'after') + ?.innerText, + ).to.equal('TL'); }); it('can update currency', async () => { - const el = await fixture(``); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); el.currency = 'USD'; await el.updateComplete; - expect(Array.from(el.children).find(child => child.slot === 'after').innerText).to.equal('USD'); + expect( + /** @type {HTMLElement[]} */ (Array.from(el.children)).find(child => child.slot === 'after') + ?.innerText, + ).to.equal('USD'); }); it('ignores currency if a suffix is already present', async () => { - const el = await fixture( + const el = /** @type {LionInputAmount} */ (await fixture( `my-currency`, - ); - expect(Array.from(el.children).find(child => child.slot === 'suffix').innerText).to.equal( - 'my-currency', - ); + )); + expect( + /** @type {HTMLElement[]} */ (Array.from(el.children)).find(child => child.slot === 'suffix') + ?.innerText, + ).to.equal('my-currency'); el.currency = 'EUR'; await el.updateComplete; - expect(Array.from(el.children).find(child => child.slot === 'suffix').innerText).to.equal( - 'my-currency', - ); + expect( + /** @type {HTMLElement[]} */ (Array.from(el.children)).find(child => child.slot === 'suffix') + ?.innerText, + ).to.equal('my-currency'); }); describe('Accessibility', () => { it('adds currency id to aria-labelledby of input', async () => { - const el = await fixture(``); - expect(el._currencyDisplayNode.getAttribute('data-label')).to.be.not.null; - expect(el._inputNode.getAttribute('aria-labelledby')).to.contain(el._currencyDisplayNode.id); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); + expect(el._currencyDisplayNode?.getAttribute('data-label')).to.be.not.null; + expect(el._inputNode.getAttribute('aria-labelledby')).to.contain(el._currencyDisplayNode?.id); }); it('adds an aria-label to currency slot', async () => { - const el = await fixture(``); - expect(el._currencyDisplayNode.getAttribute('aria-label')).to.equal('euros'); + const el = /** @type {LionInputAmount} */ (await fixture( + ``, + )); + expect(el._currencyDisplayNode?.getAttribute('aria-label')).to.equal('euros'); el.currency = 'USD'; await el.updateComplete; - expect(el._currencyDisplayNode.getAttribute('aria-label')).to.equal('US dollars'); + expect(el._currencyDisplayNode?.getAttribute('aria-label')).to.equal('US dollars'); el.currency = 'PHP'; await el.updateComplete; - expect(el._currencyDisplayNode.getAttribute('aria-label')).to.equal('Philippine pisos'); + expect(el._currencyDisplayNode?.getAttribute('aria-label')).to.equal('Philippine pisos'); }); }); diff --git a/tsconfig.json b/tsconfig.json index fabe5dd02..c15b122da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "packages/tooltip/**/*.js", "packages/button/src/**/*.js", "packages/listbox/src/*.js", - "packages/input/**/*.js" + "packages/input/**/*.js", + "packages/input-amount/**/*.js" ], "exclude": [ "node_modules",