import { html, css } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { ClassInfo, classMap } from 'lit/directives/class-map.js'; import { OmniFormElement } from '../core/OmniFormElement.js'; import type { InputEventTypes } from '../keyboard/Keyboard.js'; import '../label/Label.js'; /** * Control to enter a formatted currency value. * * @import * ```js * import '@capitec/omni-components/currency-field'; * ``` * @example * * ```html * * * ``` * * @element omni-currency-field * * Registry of all properties defined by the component. * * @fires {CustomEvent<{}>} change - Dispatched when the component value changes. * * @cssprop --omni-currency-field-text-align - Currency field text align. * @cssprop --omni-currency-field-font-color - Currency field font color. * @cssprop --omni-currency-field-font-family - Currency field font family. * @cssprop --omni-currency-field-font-size - Currency field font size. * @cssprop --omni-currency-field-font-weight - Currency field font weight. * @cssprop --omni-currency-field-padding - Currency field padding. * @cssprop --omni-currency-field-height - Currency field height. * @cssprop --omni-currency-field-width - Currency field width. * * @cssprop --omni-currency-field-disabled-font-color - Currency field disabled font color. * * @cssprop --omni-currency-field-label-left-margin - Currency field label left margin. * * @cssprop --omni-currency-field-symbol-font-size - Currency field symbol font size. * @cssprop --omni-currency-field-symbol-color - Currency field symbol font color. * @cssprop --omni-currency-field-symbol-left-padding - Currency field symbol left padding. * @cssprop --omni-currency-field-symbol-select - Currency field symbol selectable state. * */ @customElement('omni-currency-field') export class CurrencyField extends OmniFormElement { @query('#inputField') private _inputElement?: HTMLInputElement; /** * Currency symbol. * @attr [currency-symbol] */ @property({ type: String, reflect: true, attribute: 'currency-symbol' }) currencySymbol: string = '$'; /** * Thousands separator. * @attr [thousands-separator] */ @property({ type: String, reflect: true, attribute: 'thousands-separator' }) thousandsSeparator: string = ''; /** * Fractional separator. * @attr [fractional-separator] */ @property({ type: String, reflect: true, attribute: 'fractional-separator' }) fractionalSeparator: string = '.'; /** * Fractional precision. * @attr [fractional-precision] */ @property({ type: Number, reflect: true, attribute: 'fractional-precision' }) fractionalPrecision: number = 2; /** * Disables native on screen keyboards for the component. * @attr [no-native-keyboard] */ @property({ type: Boolean, reflect: true, attribute: 'no-native-keyboard' }) noNativeKeyboard?: boolean; /** * Formatter provided to format the value. * @attr */ @property({ type: String, reflect: true }) formatter: string = '\\B(?=(\\d{3})+(?!\\d))'; override connectedCallback(): void { super.connectedCallback(); this.addEventListener('click', this._onClickInput.bind(this), { capture: true }); this.addEventListener('focus', this._onFocusInput.bind(this), { capture: true }); this.addEventListener('blur', this._onBlur.bind(this), { capture: true }); // Used instead of keydown to catch inputs for mobile devices. this.addEventListener('beforeinput', this._beforeInput.bind(this), { capture: true }); // Used to catch and format paste actions. this.addEventListener('paste', this._onPaste.bind(this), { capture: true }); // Used to make the component blur when enter key is pressed on a mobile keyboard this.addEventListener('keyup', this._blurOnEnter.bind(this), { capture: true }); } // Format the bound value. protected override async firstUpdated(): Promise { if (this.value !== null && this.value !== undefined) { await this._formatToCurrency(this.value.toString()).then((res) => { this._inputElement!.value = res; }); } } override focus(options?: FocusOptions | undefined): void { if (this._inputElement) { this._inputElement.focus(options); } else { super.focus(options); } } override async attributeChangedCallback(name: string, _old: string | null, value: string | null): Promise { super.attributeChangedCallback(name, _old, value); if (name === 'value') { await this._formatToCurrency(value as string).then((res) => { this._inputElement!.value = res; }); } else if ((name === 'thousands-separator' || name === 'fractional-separator') && this._inputElement) { if (this.value) { await this._formatToCurrency(this.value.toString()).then((res) => { this._inputElement!.value = res; }); } } } // Dispatch a custom change event required as we manipulate and format the value of the input. _dispatchChange(amount: number) { this.dispatchEvent( new CustomEvent('change', { detail: { value: amount } }) ); } // Used to check if the value provided in a valid numeric value. _isNumber(number: string) { return /\d/.test(number); } // Used to check if the value consists of only zeroes. _isAllZeros(centValue: string) { return /^0*$/.test(centValue); } // Convert given value to cents. _convertToCents(currencyValue: string) { return currencyValue.replace(this.fractionalSeparator, '').replace(new RegExp(this.thousandsSeparator, 'g'), ''); } // Format to a full currency value with whole amount and cents. _formatToCurrencyValue(value: string): string { value += '.'; for (let index = 0; index < this.fractionalPrecision; index++) { value += '0'; } return value; } // Parse the amount part (Whole value without cents). _parseAmount(value: string): number | null { let cleanValue = ''; for (let i = 0; i < value.length; i++) { const character = value.charAt(i); if (/\d/.test(character)) { cleanValue += character; } } if (cleanValue) { return parseInt(cleanValue); } else { return null; } } _setValue(value: string) { const floatValue = this._formatToFloat(value); this.value = floatValue; this._dispatchChange(this.value as number); } // Parse the cents portion of the currency value. _parseFraction(value: string): string { let cleanValue = ''; for (let i = 0; i < value.length; i++) { const character = value.charAt(i); if (/\d/.test(character)) { cleanValue += character; } } return cleanValue; } // Blur when the enter key is pressed on a virtual keyboard. _blurOnEnter(e: KeyboardEvent) { if (e.code === 'Enter' || e.keyCode === 13) { (e.currentTarget as HTMLElement).blur(); } } // Format the value to a currency formatted string value. async _formatToCurrency(preFormattedValue: number | string): Promise { if (preFormattedValue === 0) { return preFormattedValue.toString(); } if (!preFormattedValue) { return ''; } let formattedValue = preFormattedValue.toString(); await this.updateComplete; // Decimal separator has to be resolved here. if (formattedValue.includes(this.fractionalSeparator)) { const amountPart = this._parseAmount(formattedValue.substring(0, formattedValue.indexOf(this.fractionalSeparator))) ?.toString() .replace(new RegExp(this.formatter, 'g'), this.thousandsSeparator || ''); let fractionPart = this._parseFraction(formattedValue.substring(formattedValue.indexOf(this.fractionalSeparator) + 1)); if (fractionPart.length >= this.fractionalPrecision) { fractionPart = fractionPart.substring(0, this.fractionalPrecision); } else if (fractionPart.length < this.fractionalPrecision) { const difference = this.fractionalPrecision - fractionPart.length; for (let index = 0; index < difference; index++) { fractionPart += '0'; } } // Format amount and fraction (cents) part to currency string, ignoring fraction if still partially completed eg: just '.' is valid. this._setValue(amountPart + this.fractionalSeparator + fractionPart); return amountPart + this.fractionalSeparator + fractionPart; } else if (formattedValue.includes('.')) { const amountPart = this._parseAmount(formattedValue.substring(0, formattedValue.indexOf('.'))) ?.toString() .replace(new RegExp(this.formatter, 'g'), this.thousandsSeparator || ''); let fractionPart = this._parseFraction(formattedValue.substring(formattedValue.indexOf('.') + 1)); if (fractionPart.length >= this.fractionalPrecision) { fractionPart = fractionPart.substring(0, this.fractionalPrecision); } // Format amount and fraction (cents) part to currency string, ignoring fraction if still partially completed eg: just '.' is valid. this._setValue(amountPart + this.fractionalSeparator + fractionPart); return amountPart + this.fractionalSeparator + fractionPart; } formattedValue = this._parseAmount(formattedValue) ?.toString() .replace(new RegExp(this.formatter, 'g'), this.thousandsSeparator || '') as string; this._setValue(formattedValue); return this._formatToCurrencyValue(formattedValue); } // Format the internal value to a float which will be used to set the value of the component. _formatToFloat(formattedValue: string): string | number { if (formattedValue.length > 0) { let preFloatReplaceAll = ''; if (formattedValue.includes(this.fractionalSeparator) && this.fractionalPrecision > 0) { preFloatReplaceAll = formattedValue.replace(new RegExp(this.thousandsSeparator, 'g'), '').replace(this.fractionalSeparator, '.'); return Number(parseFloat(preFloatReplaceAll).toFixed(this.fractionalPrecision)).toFixed(this.fractionalPrecision); } else { preFloatReplaceAll = formattedValue.replace(new RegExp(this.thousandsSeparator, 'g'), ''); return Number(parseFloat(preFloatReplaceAll).toFixed(0)); } } else { return ''; } } // When the input is focussed set the default value to zero with the amount of cents dependent on the fractional precision. _onFocusInput() { const input = this._inputElement as HTMLInputElement; if (!this.value) { this.value = this._formatToCurrencyValue('0'); } if (input) { /* * Added to position the caret at the end of the input. * Chrome has an odd quirk where the focus event fires before the cursor is moved into the field. * Added a timeout of 0 ms to defer the operation until the stack is clear. */ setTimeout(() => { input.selectionStart = input.selectionEnd = input.value?.length ?? 0; }, 0); } } // When clicking in the input position the caret at the end of the input unless there is a valid highlighted selection. _onClickInput() { const input = this._inputElement as HTMLInputElement; if (input) { if (input.selectionStart === input.selectionEnd) { // setTimeout(() => { input.selectionStart = input.selectionEnd = input.value?.length ?? 0; }, 0); } } } // On blur if the component has the default value set the input value to a empty string and value property to undefined so the label can transform back into its original position. async _onBlur(): Promise { if (this._inputElement) { const inputCentValue = this._convertToCents(this._inputElement.value); if (this._isAllZeros(inputCentValue)) { this._inputElement.value = ''; this.value = undefined; } } } // When a value is pasted in the input. _onPaste(e: ClipboardEvent) { const input = this._inputElement as HTMLInputElement; const clipboardData = e.clipboardData; const pastedData = clipboardData?.getData('Text'); let centValue = ''; // Try to parse the value pasted into a valid numeric amount. const numericPastedData = this._parseAmount(pastedData as string); // Check if the numeric pasted data is not null then update the value in the input. if (input && numericPastedData) { e.preventDefault(); // Check if selection is the entire value. if (input.value.length === input.selectionEnd && input.selectionStart !== input.selectionEnd) { // Added for cases where the pasted data can be a value that is less than the fractional precision it should be treated as cents. let preNumericValue = '0'; if (numericPastedData.toString().length < this.fractionalPrecision + 1) { const difference = this.fractionalPrecision + 1 - numericPastedData.toString().length; for (let index = 0; index < difference; index++) { preNumericValue += '0'; } centValue = this._convertToCents(preNumericValue + numericPastedData.toString()); } else { centValue = this._convertToCents(numericPastedData.toString()); } } // Check if the content being pasted is at the end of the input. else if (input.selectionStart === input.value.length) { centValue = this._convertToCents(input.value + numericPastedData); } else { centValue = this._convertToCents( input.value.slice(0, input.selectionStart as number) + numericPastedData + input.value.slice(input.selectionEnd as number) ); } // Required to ensure that input value length does not exceed maxlength attribute value. if (centValue.length > input.maxLength) { centValue = centValue.substring(0, input.maxLength); } // Extract the amount part of the cent value. const amountPart = centValue.substring(0, centValue.length - this.fractionalPrecision); // Extract the cents part of the cent value. const fractionPart = centValue.slice(-this.fractionalPrecision); const parsedAmountPart = this._parseAmount(amountPart); input.value = parsedAmountPart + this.fractionalSeparator + fractionPart; this._setValue(input.value); return; } else { // If pasted value is not valid position the caret to the end of the input. e.preventDefault(); setTimeout(() => { input.selectionStart = input.selectionEnd = input.value?.length ?? 0; }, 0); return; } } _beforeInput(e: InputEvent) { const input = this._inputElement as HTMLInputElement; let centValue = this._convertToCents(this._inputElement?.value ? this._inputElement?.value : '0'); if (centValue && input) { e.preventDefault(); /** * Check if the input elements converted cent value is all zeros. * Check if the data of the input event is zero */ if (this._isAllZeros(centValue as string) && e.data === '0') { return; } else if (this._isNumber(e.data as string)) { //If the entire input value is selected and has to be replaced with the character provided if (input.value.length === input.selectionEnd && input.selectionStart !== input.selectionEnd) { let preNumericValue = '0'; for (let index = 0; index < this.fractionalPrecision; index++) { preNumericValue += '0'; } centValue = this._convertToCents(preNumericValue + e.data); } // If the caret is positioned at the end of the input else if (input.selectionStart === input.value.length) { centValue = centValue += e.data; } else { centValue = this._convertToCents( input.value.slice(0, input.selectionStart as number) + e.data + input.value.slice(input.selectionEnd as number) ); } // Required to ensure that input value length does not exceed maxlength attribute value. if (centValue.length > input.maxLength) { centValue = centValue.substring(0, input.maxLength); } // Extract the amount part of the cent value. let amountPart = centValue.substring(0, centValue.length - this.fractionalPrecision); // Extract the cents part of the cent value. const fractionPart = centValue.slice(-this.fractionalPrecision); if (this._isAllZeros(amountPart)) { amountPart = '0'; } input.value = amountPart + this.fractionalSeparator + fractionPart; this._setValue(input.value); return; } else if (!this._isNumber(e.data as string) && !(e.inputType as InputEventTypes)) { input.value = centValue; this._setValue(input.value); return; } // Check if inputType prop is populated and value of input is not all zeros. if ((e.inputType as InputEventTypes) && !this._isAllZeros(centValue as string)) { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; setTimeout(() => { // Condition if the backspace key is hit works on virtual and desktop keyboards if (e.inputType === 'deleteContentBackward') { centValue = centValue?.substring(0, centValue.length - 1); if (that._isAllZeros(centValue as string)) { input.value = that._formatToCurrencyValue('0'); const floatValue = that._formatToFloat(input.value); that.value = floatValue; that._dispatchChange(that.value as number); } else { if (input.value.length === input.selectionEnd && input.selectionStart !== input.selectionEnd) { let preNumericValue = '0'; for (let index = 0; index < that.fractionalPrecision; index++) { preNumericValue += '0'; } centValue = that._convertToCents(preNumericValue); } else if (input.selectionStart !== input.selectionEnd) { centValue = that._convertToCents( input.value.slice(0, input.selectionStart as number) + input.value.slice(input.selectionEnd as number) ); } } } // Condition if the delete key is pressed on a desktop keyboard check if the entire value of the input is selected or a portion and update accordingly. else if (e.inputType === 'deleteContentForward') { if (input.value.length === input.selectionEnd && input.selectionStart !== input.selectionEnd) { let preNumericValue = '0'; for (let index = 0; index < that.fractionalPrecision; index++) { preNumericValue += '0'; } centValue = that._convertToCents(preNumericValue); } else if (input.selectionStart !== input.selectionEnd) { centValue = that._convertToCents( input.value.slice(0, input.selectionStart as number) + input.value.slice(input.selectionEnd as number) ); } } const amountPart = centValue?.substring(0, centValue.length - that.fractionalPrecision) as string; const fractionPart = centValue?.slice(-that.fractionalPrecision); const parsedAmountPart = amountPart ? that._parseAmount(amountPart) : '0'; input.value = parsedAmountPart + that.fractionalSeparator + fractionPart; this._setValue(input.value); input.selectionStart = input.selectionEnd = input.value?.length ?? 0; return; }, 0); } //Ensuring on older devices that the caret doesn't jump when hitting the delete key on a virtual keyboard. else { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this; setTimeout(() => { input.value = that._formatToCurrencyValue('0'); this._setValue(input.value); input.selectionStart = input.selectionEnd = input.value?.length ?? 0; }, 0); return; } } } static override get styles() { return [ super.styles, css` .field { flex: 1 1 auto; border: none; background: none; box-shadow: none; outline: 0; margin: 0; text-align: var(--omni-currency-field-text-align, left); color: var(--omni-currency-field-font-color, var(--omni-font-color)); font-family: var(--omni-currency-field-font-family, var(--omni-font-family)); font-size: var(--omni-currency-field-font-size, var(--omni-font-size)); font-weight: var(--omni-currency-field-font-weight, var(--omni-font-weight)); padding: var(--omni-currency-field-padding, 10px); height: var(--omni-currency-field-height, 100%); width: var(--omni-currency-field-width, 100%); } .field.disabled { color: var(--omni-currency-field-disabled-font-color, #7C7C7C); } .field.error { color: var(--omni-currency-field-error-font-color, var(--omni-font-color)); } .label { margin-left: var(--omni-currency-field-label-left-margin, var(--omni-form-label-margin-left, 25px)); } .currency-symbol { font-size: var(--omni-currency-field-symbol-font-size, 16px); color: var(--omni-currency-field-symbol-color, var(--omni-font-color)); padding-left: var(--omni-currency-field-symbol-left-padding, 10px); user-select: var(--omni-currency-field-symbol-select, text); } ` ]; } protected override renderPrefix() { return html``; } protected override renderContent() { const field: ClassInfo = { field: true, disabled: this.disabled, error: this.error as string }; return html` `; } } declare global { interface HTMLElementTagNameMap { 'omni-currency-field': CurrencyField; } }