import CSS from "./jb-number-input.css"; import VariablesCSS from "./variables.css"; import "jb-input"; import { type NumberFieldParameter, type NumberInputElements } from './types.js'; // eslint-disable-next-line no-duplicate-imports import { JBInputWebComponent, ValueSetterEventType, type JBInputValue } from "jb-input"; //TODO: update it when you move validation to core package import { type ValidationItem } from "jb-validation"; import { isNumberValidator } from "./validation"; import { isStringIsNumber, standardValueForNumberInput } from "./utils.js"; import { renderButtonsHTML } from "./render"; export * from "./types.js"; //TODO: add barcode scanner or nfc reader export class JBNumberInputWebComponent extends JBInputWebComponent { #numberFieldParameters: NumberFieldParameter = { //if input type is number we use this step to change value on +- clicks maxValue: null, minValue: null, //how many decimal place we accept decimalPrecision: null, acceptNegative: true, }; // get minValue() { return this.#numberFieldParameters.minValue; } set minValue(value: number | string) { if (value === undefined || value === null) { this.#numberFieldParameters.minValue = null; return; } const newValue = Number(value); if (Number.isNaN(newValue)) { console.error("min value is not a valid number"); return; } this.#numberFieldParameters.minValue = newValue; } // get maxValue() { return this.#numberFieldParameters.maxValue; } set maxValue(value: number | string) { if (value === undefined || value === null) { this.#numberFieldParameters.maxValue = null; return; } const newValue = Number(value); if (Number.isNaN(newValue)) { console.error("max value is not a valid number"); return; } this.#numberFieldParameters.maxValue = newValue; } // get decimalPrecision() { return this.#numberFieldParameters.decimalPrecision; } set decimalPrecision(value: number | string) { if (value === undefined || value === null) { this.#numberFieldParameters.decimalPrecision = null; return; } const newValue = Number(value); if (Number.isNaN(newValue)) { console.error("decimalPrecision value is not a valid number"); return; } this.#numberFieldParameters.decimalPrecision = newValue; } // get acceptNegative() { return this.#numberFieldParameters.acceptNegative; } set acceptNegative(value: boolean) { this.#numberFieldParameters.acceptNegative = Boolean(value); } //how many step number increase or decrease on + , - or arrow up , arrow down #step = 1; get step() { return this.#step; } set step(value: number) { if (value === undefined || value === null) { this.#step = null; return; } if (Number.isNaN(Number(value))) { console.error("step must be a number"); return; } this.#step = value; } //for money and big number separate with a comma #showThousandSeparator = false; get showThousandSeparator() { return this.#showThousandSeparator; } set showThousandSeparator(value: boolean) { const newValue = Boolean(value); if (newValue === this.#showThousandSeparator) { return; } this.#showThousandSeparator = newValue; this.value = `${this.value}`; } #thousandSeparator = "," get thousandSeparator() { return this.#thousandSeparator; } set thousandSeparator(value: string) { if (this.#thousandSeparator === value) { return; } this.#thousandSeparator = String(value); this.value = `${this.value}`; } //will show persian number even if user type en number but value will be passed as en number #showPersianNumber = false; get showPersianNumber() { return this.#showPersianNumber; } set showPersianNumber(value: boolean) { this.#showPersianNumber = Boolean(value); this.value = `${this.value}`; } //if user type or paste something not a number, this char will be filled the replacement in most cases will be '0' #invalidNumberReplacement = ""; get invalidNumberReplacement() { return this.#invalidNumberReplacement; } set invalidNumberReplacement(value: string) { this.#invalidNumberReplacement = String(value); } numberInputElements!: NumberInputElements; #showControlButton = false; get showControlButton() { return this.#showControlButton; } set showControlButton(value: boolean) { if (value == this.#showControlButton) { //nothing changes return; } this.#showControlButton = value; if (value === true) { this.#addControlButtons(); } else if (value === false) { this.#removeControlButtons(); } } constructor() { super(); this.#initNumberInputWebComponent(); } #addNumberInputEventListeners() { this.elements.input.addEventListener("beforeinput", this.#onNumberInputBeforeInput.bind(this)); this.addEventListener("keydown", this.#onNumberInputKeyDown.bind(this)); } #initNumberInputWebComponent() { const html = ``; const element = document.createElement("template"); element.innerHTML = html; this.shadowRoot.appendChild(element.content.cloneNode(true)); this.validation.addValidationListGetter(this.#getNumberInputValidations.bind(this)); this.elements.input.inputMode = "numeric"; this.numberInputElements = { controlButtons: null }; this.#addNumberInputEventListeners(); this.addStandardValueCallback(this.#standardNumberValue.bind(this)); } static get numberInputObservedAttributes() { return ["thousand-separator", "step", "show-persian-number", "min", "max", "decimal-precision", "accept-negative", "show-control-button"]; } static get observedAttributes() { return [ ...JBInputWebComponent.observedAttributes, ...JBNumberInputWebComponent.numberInputObservedAttributes ]; } attributeChangedCallback(name: string, oldValue: string, newValue: string): void { // call base jb-input on attribute changes if ([...JBNumberInputWebComponent.numberInputObservedAttributes, 'type'].includes(name)) { this.#onNumberInputAttributeChange(name, newValue); } else { this.onAttributeChange(name, newValue); } } #standardNumberValue(valueString: string, oldValue:JBInputValue, prevResult:JBInputValue, eventType:ValueSetterEventType ): JBInputValue { return standardValueForNumberInput( valueString, this.#numberFieldParameters, { invalidNumberReplacement: this.#invalidNumberReplacement, thousandSeparator: this.#thousandSeparator, useThousandSeparator: this.showThousandSeparator, showPersianNumber: this.#showPersianNumber }, eventType ); } #onNumberInputAttributeChange(name: string, value: string) { switch (name) { case 'thousand-separator': if (value == '' || value == "true" || value == "false") { this.showThousandSeparator = value == '' ? true : value === 'true'; } else { this.#showThousandSeparator = true; this.#thousandSeparator = value; } break; case 'step': this.step = Number(value); break; case "show-persian-number": this.showPersianNumber = value == '' ? true : value === 'true'; break; case 'min': this.minValue = value; break; case 'max': this.maxValue = value; break; case "decimal-precision": this.decimalPrecision = value; break; case "accept-negative": if (value == '' || value == "true" || value == "false") { this.acceptNegative = value == '' ? true : value === 'true'; } break; case "show-control-button": if (value == '' || value == "true" || value == "false") { this.showControlButton = value == '' ? true : value === 'true'; } break; case 'type': //we do nothing but just prevent input to get number type because of some limitation //TODO: change inputmode base on provided type if it doesn't provided by user break; } } #getNumberInputValidations(): ValidationItem[] { return [isNumberValidator]; } #dispatchOnChangeEvent() { const eventInit: EventInit = { //TODO: make it cancelable like jb-input does cancelable: false, }; const event = new Event("change", eventInit); this.dispatchEvent(event); } #addFloatNumber(num1: number, num2: number) { const prec1 = `${num1}`.split(".")[1]; const prec2 = `${num2}`.split(".")[1]; const zarib1 = prec1 ? Math.pow(10, prec1.length + 1) : 1; const zarib2 = prec2 ? Math.pow(10, prec2.length + 1) : 1; const zarib = Math.max(zarib1, zarib2); const stNum1 = num1 * zarib; const stNum2 = num2 * zarib; const res = stNum1 + stNum2; return res / zarib; } increaseNumber(shouldCallOnChange = false) { const currentNumber = Number(this.value); if (isNaN(currentNumber)) { return; } const step = this.#step; const newNumber = this.#addFloatNumber(currentNumber, step); this.value = `${newNumber}`; this.validation.checkValidity({ showError: true }); if (shouldCallOnChange) { this.#dispatchOnChangeEvent(); } } decreaseNumber(shouldCallOnChange = false) { const currentNumber = parseFloat(this.value); if (isNaN(currentNumber)) { return; } const step = this.#numberFieldParameters ? this.#step : 1; let newNumber = this.#addFloatNumber(currentNumber, -1 * step); if ( newNumber < 0 && !( this.#numberFieldParameters && this.#numberFieldParameters.acceptNegative ) ) { newNumber = 0; } this.value = `${newNumber}`; this.validation.checkValidity({ showError: true }); if (shouldCallOnChange) { this.#dispatchOnChangeEvent(); } } #addControlButtons() { const buttonsElement = document.createElement("div"); buttonsElement.classList.add("number-control-wrapper"); buttonsElement.innerHTML = renderButtonsHTML(); buttonsElement .querySelector(".increase-number-button")! .addEventListener("click", this.increaseNumber.bind(this, true)); buttonsElement .querySelector(".decrease-number-button")! .addEventListener("click", this.decreaseNumber.bind(this, true)); this.elements.slots.endSection.appendChild(buttonsElement); this.numberInputElements.controlButtons = buttonsElement; } #removeControlButtons() { if (this.numberInputElements.controlButtons) { this.numberInputElements.controlButtons.remove(); } } #onNumberInputKeyDown(e: KeyboardEvent): void { //handle up and down on number key const key = e.key; if (key == "ArrowUp") { this.increaseNumber(false); e.preventDefault(); } if (key == "ArrowDown") { this.decreaseNumber(false); e.preventDefault(); } } #onNumberInputBeforeInput(e: InputEvent): void { //TODO: read and simplify const endCaretPos = (e.target as HTMLInputElement).selectionEnd || 0; const startCaretPos = (e.target as HTMLInputElement).selectionStart || 0; let isPreventDefault = false; // we check number input type field and prevent non number values if (e.inputType !== "deleteContentBackward" && !isStringIsNumber(e.data)) { isPreventDefault = true; // we made exception for . char if its valid by user if ( e.data == "." && this.#numberFieldParameters!.decimalPrecision !== 0 && this.value.indexOf(".") == -1 && !(endCaretPos == 0 || startCaretPos == 0) && !( this.#numberFieldParameters!.decimalPrecision !== null && this.value.substring(endCaretPos).length > this.#numberFieldParameters!.decimalPrecision ) ) { isPreventDefault = false; } //for '-' char we check if negative number is allowed if (this.#numberFieldParameters.acceptNegative && e.data[0] == "-" && (startCaretPos == 0) ) { isPreventDefault = false; } } if (isPreventDefault) { e.preventDefault(); } } } const myElementNotExists = !customElements.get("jb-number-input"); if (myElementNotExists) { window.customElements.define("jb-number-input", JBNumberInputWebComponent); }