UNPKG

jb-date-input

Version:
1,232 lines (1,210 loc) 47 kB
import HTML from './jb-date-input.html'; import CSS from './jb-date-input.scss'; import 'jb-calendar'; import 'jb-input'; import 'jb-popover'; // eslint-disable-next-line no-duplicate-imports import { type JBCalendarWebComponent } from 'jb-calendar'; import type { JBFormInputStandards } from 'jb-form'; import { InputTypes, ValueTypes, type ElementsObject, type DateRestrictions, type JBDateInputValueObject, type ValueType, InputType, type ValidationValue, type JBCalendarValue } from './types'; import { DateFactory } from './date-factory'; import { checkMaxValidation, checkMinValidation, getEmptyValueObject, handleDayBeforeInput, handleMonthBeforeInput } from './helpers'; import { ValidationHelper, type ValidationResult, type ValidationItem, type WithValidation, type ShowValidationErrorParameters } from 'jb-validation'; import { requiredValidation } from './validations'; // eslint-disable-next-line no-duplicate-imports import { JBInputWebComponent } from 'jb-input'; import { createInputEvent, createKeyboardEvent, createFocusEvent, listenAndSilentEvent, isMobile, enToFaDigits, faToEnDigits } from 'jb-core'; export * from "./types.js"; if (HTMLElement == undefined) { //in case of server render or old browser console.error('you cant render web component on a server side. try to load this component as a client side component'); } const emptyInputValueString = ' / / '; //TODO: refactor date-input to use Date value as a core value so date object could be filled even with incomplete value export class JBDateInputWebComponent extends HTMLElement implements WithValidation<ValidationValue>, JBFormInputStandards<string> { static formAssociated = true; #internals?: ElementInternals; elements!: ElementsObject; #validation = new ValidationHelper<ValidationValue>({ clearValidationError: this.clearValidationError.bind(this), getValue: () => this.#validationValue, getValidations: this.#getInsideValidations.bind(this), getValueString: (val) => val.text, setValidationResult: this.#setValidationResult.bind(this), showValidationError: this.showValidationError.bind(this) } ) #isAutoValidationDisabled = false; get isAutoValidationDisabled(): boolean { return this.#isAutoValidationDisabled; } set isAutoValidationDisabled(value: boolean) { this.#isAutoValidationDisabled = value; } #dateFactory: DateFactory = new DateFactory({ inputType: (this.getAttribute("input-type") as InputTypes), valueType: this.getAttribute("value-type") as ValueTypes }); #showCalendar = false; inputFormat = 'YYYY/MM/DD'; #inputRegex = /^(?<year>[\u06F0-\u06F90-9,\s]{4})\/(?<month>[\u06F0-\u06F90-9,\s]{2})\/(?<day>[\u06F0-\u06F90-9,\s]{2})$/g; get validation() { return this.#validation; } dateRestrictions: DateRestrictions = { min: null, max: null }; #disabled = false; get disabled() { return this.#disabled; } set disabled(value: boolean) { this.#disabled = value; this.elements.input.disabled = value; if (value) { //TODO: remove as any when typescript support (this.#internals as any).states?.add("disabled"); } else { (this.#internals as any).states?.delete("disabled"); } } //selection input behavior get selectionStart(): number { return this.elements.input.selectionStart; } set selectionStart(value: number) { this.elements.input.selectionStart = value; } get selectionEnd(): number { return this.elements.input.selectionEnd; } set selectionEnd(value: number) { this.elements.input.selectionEnd = value; } get selectionDirection(): "forward" | "backward" | "none" { return this.elements.input.selectionDirection; } set selectionDirection(value: "forward" | "backward" | "none") { this.elements.input.selectionDirection = value; } setSelectionRange(start: number | null, end: number | null, direction?: "forward" | "backward" | "none") { this.elements.input.setSelectionRange(start, end, direction); } #required = false; set required(value: boolean) { this.#required = value; this.#checkValidity(false); } get required() { return this.#required; } DefaultValidationErrorMessage = "مقدار وارد شده نا معتبر است" #valueObject: JBDateInputValueObject = getEmptyValueObject(); get name() { return this.getAttribute('name') || ''; } get form() { return this.#internals!.form; } get value(): string { const value = this.getDateValue(); return value; } set value(value: string | Date) { this.#setDateValue(value); this.#updateInputTextFromValue(); } //set an empty date value as a default initial value initialValue: string | null = null; get isDirty() { //when initial value is null mean we calculate and build value string base on format, value type , etc on every check to make sure is dirty works well on empty value in every scenario return this.value !== (this.initialValue ?? this.#dateFactory.getDateValueStringFromValueObject(getEmptyValueObject(), this.valueType)); } get #validationValue(): ValidationValue { return { inputObject: this.#dateFactory.getDateObjectValueBaseOnFormat(this.#sInputValue, this.inputFormat), text: this.#sInputValue, valueText: this.value, valueObject: this.#valueObject }; } setMonthList(inputType: InputType, monthName: string[]) { this.elements.calendar.setMonthList(inputType, monthName); } #updateFormAssociatedValue(): void { //in html form we need to get date input value in native way this function update and set value of the input so form can get it when needed if (this.#internals && typeof this.#internals.setFormValue == "function") { this.#internals.setFormValue(this.value); } } /** * @description return date value if value valid and return null if inputted value is not valid */ get valueInDate(): Date | null { return this.#dateFactory.getDateValueFromValueObject(this.#valueObject); } get inputValue() { return this.#inputValue; } #placeholder: string | null = null; get placeholder() { return this.#placeholder; } set placeholder(value: string | null) { this.#placeholder = value; if (value !== null) { this.elements.input.elements.input.placeholder = value; } else { this.elements.input.elements.input.placeholder = ""; } this.#updateInputTextFromValue(); } //standardized input value get #sInputValue(): string { let value = this.#inputValue; if (this.#showPersianNumber) { value = faToEnDigits(value); } return value; } get #inputValue() { return this.elements.input.value; } set #inputValue(value: string) { this.elements.input.value = value; } get showCalendar() { return this.#showCalendar; } set showCalendar(value) { this.#showCalendar = value; if (value == true) { //we have to do it because js dont tell us when dir change so we have to check and set it every time we open calendar this.elements.calendar.setupStyleBaseOnCssDirection(); this.elements.popover.open(); this.elements.calendarTriggerButton.classList.add('--active'); } else { this.elements.popover.close(); this.elements.calendarTriggerButton.classList.remove('--active'); } } get inputType(): InputType { return this.#dateFactory.inputType; } set inputType(value: InputType) { if (Object.values(InputTypes).includes(value as InputTypes)) { this.#dateFactory.setInputType(value); this.onInputTypeChange(); } else { console.error(`${value} is not a valid input type`); } } get valueType() { return this.#dateFactory.valueType; } set valueType(value: ValueType) { if (Object.values(ValueTypes).includes(value as ValueTypes)) { this.#dateFactory.setValueType(value); } else { console.error(`${value} is not a valid value type`); } } get yearValue(): number | null { switch (this.valueType) { case "JALALI": return this.#valueObject.jalali.year; case "GREGORIAN": return this.#valueObject.gregorian.year; case "TIME_STAMP": return this.#valueObject.gregorian.year; default: return null; } } get yearDisplayValue(): number | null { switch (this.inputType) { case "JALALI": return this.#valueObject.jalali.year; case "GREGORIAN": return this.#valueObject.gregorian.year; default: return null; } } get monthValue(): number | null { switch (this.valueType) { case "JALALI": return this.#valueObject.jalali.month; case "GREGORIAN": return this.#valueObject.gregorian.month; case "TIME_STAMP": return this.#valueObject.gregorian.month; default: return null; } } get monthDisplayValue(): number | null { switch (this.inputType) { case "JALALI": return this.#valueObject.jalali.month; case "GREGORIAN": return this.#valueObject.gregorian.month; default: return null; } } get dayValue(): number | null { switch (this.valueType) { case "JALALI": return this.#valueObject.jalali.day; case "GREGORIAN": return this.#valueObject.gregorian.day; case "TIME_STAMP": return this.#valueObject.gregorian.day; default: return null; } } get dayDisplayValue(): number | null { switch (this.inputType) { case "JALALI": return this.#valueObject.jalali.day; case "GREGORIAN": return this.#valueObject.gregorian.day; default: return null; } } get yearBaseOnInputType(): number | null { switch (this.inputType) { case InputTypes.jalali: return this.#valueObject.jalali.year; case InputTypes.gregorian: return this.#valueObject.gregorian.year; default: return null; } } get monthBaseOnInputType(): number | null { switch (this.inputType) { case InputTypes.jalali: return this.#valueObject.jalali.month; case InputTypes.gregorian: return this.#valueObject.gregorian.month; default: return null; } } get dayBaseOnInputType(): number | null { switch (this.inputType) { case InputTypes.jalali: return this.#valueObject.jalali.day; case InputTypes.gregorian: return this.#valueObject.gregorian.day; default: return null; } } get typedYear(): string { const typedYear = this.inputValue.substring(0, 4); return typedYear; } get typedMonth(): string { const typedMonth = this.inputValue.substring(5, 7); return typedMonth; } get typedDay(): string { const typedDay = this.inputValue.substring(8, 10); return typedDay; } get sTypedYear(): string { const typedYear = this.#sInputValue.substring(0, 4); return typedYear; } get sTypedMonth(): string { const typedMonth = this.#sInputValue.substring(5, 7); return typedMonth; } get sTypedDay(): string { const typedDay = this.#sInputValue.substring(8, 10); return typedDay; } get valueFormat() { return this.#dateFactory.valueFormat; } #showPersianNumber = false; get showPersianNumber() { return this.#showPersianNumber; } set showPersianNumber(value) { this.#showPersianNumber = value; this.#updateInputTextFromValue(); } constructor() { super(); if (typeof this.attachInternals == "function") { //some browser dont support attachInternals this.#internals = this.attachInternals(); } this.#initWebComponent(); // js standard input element to more associate it with form element } connectedCallback() { // standard web component event that called when all of dom is bounded this.#callOnLoadEvent(); this.#initProp(); } #callOnLoadEvent() { const event = new CustomEvent('load', { bubbles: true, composed: true }); this.dispatchEvent(event); } #callOnInitEvent() { const event = new CustomEvent('init', { bubbles: true, composed: true }); this.dispatchEvent(event); } #initWebComponent() { const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true }); const html = `<style>${CSS}</style>` + '\n' + HTML; const element = document.createElement('template'); element.innerHTML = html; shadowRoot.appendChild(element.content.cloneNode(true)); this.elements = { input: shadowRoot.querySelector('jb-input')!, calendarTriggerButton: shadowRoot.querySelector('.calendar-trigger')!, calendar: shadowRoot.querySelector('jb-calendar')!, popover: shadowRoot.querySelector('jb-popover')!, }; this.#registerEventListener(); this.#initDeviceSpecifics(); } /** * @description activate some features specially on mobile or other specific devices */ #initDeviceSpecifics() { if (isMobile()) { // on mobile this.elements.input.setAttribute('readonly', 'true'); //TODO: handle back button and prevent back when calendar is open } else { // on non-mobile this.elements.input.removeAttribute('readonly'); } } #registerEventListener() { this.elements.input.addEventListener('beforeinput', this.#onInputBeforeInput.bind(this)); listenAndSilentEvent(this.elements.input, 'focus', this.#onInputFocus.bind(this), { passive: true }); listenAndSilentEvent(this.elements.input, 'blur', this.#onInputBlur.bind(this), { passive: true }); listenAndSilentEvent(this.elements.input, 'keypress', this.#onInputKeyPress.bind(this)); listenAndSilentEvent(this.elements.input, 'keyup', this.#onInputKeyup.bind(this)); listenAndSilentEvent(this.elements.input, 'keydown', this.#onInputKeydown.bind(this)); // this.elements.calendarTriggerButton.addEventListener('focus', this.#onCalendarButtonFocused.bind(this)); this.elements.calendarTriggerButton.addEventListener('blur', this.#onCalendarButtonBlur.bind(this)); this.elements.calendarTriggerButton.addEventListener('click', this.#onCalendarButtonClick.bind(this)); // this.elements.calendar.addEventListener('select', (e) => this.#onCalendarSelect(e as CustomEvent)); this.elements.calendar.addEventListener('init', this.#onCalendarElementInitiated.bind(this)); this.elements.calendar.addEventListener('blur', this.#onCalendarBlur.bind(this), { passive: true }); this.elements.popover.addEventListener('close', this.#onPopoverClose.bind(this), { passive: true }); } //true if all sub component initiated #isAllSubComponentInitiated = false; /** * @description wait for all sub-component to be load */ async #waitForComponentsLoad() { if (this.#isAllSubComponentInitiated) { return Promise.resolve(); } await customElements.whenDefined("jb-input"); await customElements.whenDefined("jb-calendar"); await customElements.whenDefined("jb-popover"); // const calendarPromise = new Promise<void>((resolve) => { // this.elements.calendar.addEventListener('init', () => { // resolve(); // }, { once: true, passive: true }); // }); this.#isAllSubComponentInitiated = true; return Promise.resolve(); } #initProp() { this.#waitForComponentsLoad().then(() => { this.#setValueObjNull(); this.value = this.getAttribute('value') || ''; this.#callOnInitEvent(); }); } static get dateInputObservedAttributes() { return ['value-type', 'value', 'name', 'format', 'min', 'max', 'required', 'input-type', 'direction', 'show-persian-number', 'placeholder', 'disabled','error']; } static get observedAttributes() { return [...JBInputWebComponent.observedAttributes, ...JBDateInputWebComponent.dateInputObservedAttributes]; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (JBDateInputWebComponent.dateInputObservedAttributes.includes(name)) { this.#onAttributeChange(name, newValue); } else if (JBInputWebComponent.observedAttributes.includes(name)) { this.elements.input.setAttribute(name, newValue); } // do something when an attribute has changed } #onAttributeChange(name: string, value: string) { switch (name) { case 'value': this.value = value; break; case 'name': this.elements.input.setAttribute('name', value); break; case 'value-type': this.valueType = value as ValueTypes; break; case 'format': this.setFormat(value); break; case 'min': this.#setMinDate(value); break; case 'max': this.#setMaxDate(value); break; case 'required': if (value === "" || value == "true") { this.required = true; } else { this.required = false; } break; case 'input-type': this.inputType = value as InputTypes; break; case 'direction': this.elements.calendar.setAttribute('direction', value); break; case 'show-persian-number': if (value == 'true' || value == '') { this.showPersianNumber = true; this.elements.calendar.showPersianNumber = true; } if (value == 'false' || value == null) { this.showPersianNumber = false; this.elements.calendar.showPersianNumber = false; } break; case 'placeholder': this.placeholder = value; break; case 'disabled': this.disabled = value === "" || value == "true"; break; case 'error': this.reportValidity(); break; } } setFormat(newFormat: string) { //override new format base on user config this.#dateFactory.valueFormat = newFormat; //if we have min and max date settled before format set we set them again so it works const minDate = this.getAttribute('min'); if (minDate) { this.#setMinDate(minDate); } const maxDate = this.getAttribute('max'); if (maxDate) { this.#setMaxDate(maxDate); } } setMinDate(minDate: string | Date) { this.#setMinDate(minDate); } #setMinDate(dateInput: string | Date) { let minDate: Date | null = null; //create min date base on input value type if (typeof dateInput == "string") { minDate = this.#dateFactory.getDateFromValueDateString(dateInput); } else { minDate = dateInput; } if (minDate) { this.dateRestrictions.min = minDate; if (this.elements.calendar.dateRestrictions) { this.elements.calendar.dateRestrictions.min = minDate; } } else { console.error(`min date ${dateInput} is not valid and it will be ignored`, '\n', 'please provide min date in format : ' + this.#dateFactory.valueFormat); } } setMaxDate(maxDate: string | Date) { this.#setMaxDate(maxDate); } #setMaxDate(dateInput: string | Date) { let maxDate: Date | null = null; //create max date base on input value type if (typeof dateInput == "string") { maxDate = this.#dateFactory.getDateFromValueDateString(dateInput); } else { maxDate = dateInput; } if (maxDate) { this.dateRestrictions.max = maxDate; if (this.elements.calendar.dateRestrictions) { this.elements.calendar.dateRestrictions.max = maxDate; } } else { console.error(`max date ${dateInput} is not valid and it will be ignored`, '\n', 'please provide max date in format : ' + this.#dateFactory.valueFormat); } } inputChar(char: string, pos: number) { this.#inputChar(char, pos); } #inputChar(char: string, pos: number) { if (pos == 4 || pos == 7) { char = '/'; } if (pos > 9 || pos < 0) { return; } this.#inputRegex.lastIndex = 0; const newValueArr = this.#inputValue.split(''); if (this.#showPersianNumber) { char = enToFaDigits(char); } newValueArr[pos] = char; const newValue = newValueArr.join(''); //due ro performance issue i remove validation check on every char input // const isValid = this.#inputRegex.test(newValue); // if (isValid) { this.#inputValue = newValue; //} } #isValidChar(char: string) { //allow 0-9 ۰-۹ and / char only return /[\u06F0-\u06F90-9/]/g.test(char); } #standardString(dateString: string) { //TODO: convert en to persian or persian to en base on user config const sNumString = faToEnDigits(dateString); //convert dsd137/06/31rer to 1373/06/31 const sString = sNumString.replace(/[^\u06F0-\u06F90-9/]/g, ''); return sString; } /** * this event generate by ourself in before input after input done */ #onInputInput(e: InputEvent) { this.#dispatchOnInputEvent(e); } #dispatchOnInputEvent(e: InputEvent): void { const event = createInputEvent('input', e, { cancelable: false }); this.dispatchEvent(event); } #dispatchBeforeInputEvent(e: InputEvent): boolean { e.stopPropagation(); const event = createInputEvent('beforeinput', e, { cancelable: true }); this.dispatchEvent(event); if (event.defaultPrevented) { e.preventDefault(); } return event.defaultPrevented; } #onInputBeforeInput(e: InputEvent) { const isPrevented = this.#dispatchBeforeInputEvent(e); if (isPrevented) { return; } //TODO: handel range selection const inputSelectionStart = (e.target as HTMLInputElement).selectionStart!; const baseCaretPos = inputSelectionStart; const inputtedString: string | null = e.data; if (inputtedString) { //insert mode //check if we are in placeholder mode we update or input text to standard mode if (this.placeholder && this.#inputValue === "") { this.#inputValue = emptyInputValueString; } // make string something like 1373/06/31 from dsd۱۳۷۳/06/31rer const standardString = this.#standardString(inputtedString); standardString.split('').forEach((inputtedChar: string, i: number) => { let caretPos = baseCaretPos + i; if (!this.#isValidChar(inputtedChar)) { e.preventDefault(); return; } if (caretPos == 4 || caretPos == 7) { // in / pos if (inputtedChar == '/') { (e.target as HTMLInputElement).setSelectionRange(caretPos + 1, caretPos + 1); } //push carrot if it behind / char caretPos++; } // we want user typed char ignored in some scenario let isIgnoreChar = false; if (inputtedChar == '/') { return; } const typedNumber = parseInt(inputtedChar); if (caretPos == 5 && typedNumber > 1) { //second pos of month this.#inputChar("0", caretPos); caretPos++; } const monthRes = handleMonthBeforeInput.call(this, typedNumber, caretPos); caretPos = monthRes.caretPos; const dayRes = handleDayBeforeInput.call(this, typedNumber, caretPos); caretPos = dayRes.caretPos; isIgnoreChar = isIgnoreChar || dayRes.isIgnoreChar || monthRes.isIgnoreChar; if (!isIgnoreChar) { this.#inputChar(inputtedChar, caretPos); (e.target as HTMLInputElement).setSelectionRange(caretPos + 1, caretPos + 1); } }); e.preventDefault(); } if (e.inputType == 'deleteContentBackward' || e.inputType == 'deleteContentForward' || e.inputType == 'delete' || e.inputType == 'deleteByCut' || e.inputType == 'deleteByDrag') { //delete mode const inputSelectionEnd = (e.target as HTMLInputElement).selectionEnd!; let d = 0; if (e.inputType == 'deleteContentBackward') { //backspace delete d = -1; } for (let i = inputSelectionStart; i <= inputSelectionEnd; i++) { this.#inputChar(' ', i + d); } this.elements.input.setSelectionRange(inputSelectionStart + d, inputSelectionStart + d); //show placeholder if input were empty if (this.placeholder && this.#inputValue == emptyInputValueString) { this.#inputValue = ""; } e.preventDefault(); } //because we preventDefault before input input will never be called so have to call it after we manually input all chars //TODO: make it cancellable this.#onInputInput(e); } #onInputKeyPress(e: KeyboardEvent) { e.stopPropagation(); const keyPressEvent = createKeyboardEvent('keypress', e, { cancelable: false }); this.dispatchEvent(keyPressEvent); } #onInputKeyup(e: KeyboardEvent) { this.#updateValueFromInputString(this.#sInputValue); this.#dispatchOnInputKeyup(e); } #dispatchOnInputKeyup(e: KeyboardEvent) { e.stopPropagation(); const event = createKeyboardEvent("keyup", e, { cancelable: false }); this.dispatchEvent(event); } #onInputKeydown(e: KeyboardEvent) { const notCancelled = this.#dispatchKeyDownEvent(e); if (!notCancelled) { e.preventDefault(); return; } const target = (e.target as JBInputWebComponent); if (e.keyCode == 38 || e.keyCode == 40) { //up and down button const caretPos = target.selectionStart!; if (caretPos < 5) { e.keyCode == 38 ? this.#addYear(1) : this.#addYear(-1); target.setSelectionRange(0, 4); } if (caretPos > 4 && caretPos < 8) { e.keyCode == 38 ? this.#addMonth(1) : this.#addMonth(-1); target.setSelectionRange(5, 7); } if (caretPos > 7) { e.keyCode == 38 ? this.#addDay(1) : this.#addDay(-1); target.setSelectionRange(8, 10); } e.preventDefault(); } } #dispatchKeyDownEvent(e: KeyboardEvent) { e.stopPropagation(); const event = createKeyboardEvent("keydown", e, { cancelable: false }); return this.dispatchEvent(event); } #addYear(interval: number) { const currentYear = this.yearDisplayValue ? this.yearDisplayValue : this.#dateFactory.yearOnEmptyBaseOnInputType; const currentMonth = this.monthDisplayValue || 1; const currentDay = this.dayDisplayValue || 1; const { hour, minute, millisecond, second } = this.#valueObject.time; this.#setDateValueFromNumberBaseOnInputType(currentYear + interval, currentMonth, currentDay, hour, minute, second, millisecond); this.#updateInputTextFromValue(); } #addMonth(interval: number) { const currentYear = this.yearDisplayValue ? this.yearDisplayValue : this.#dateFactory.yearOnEmptyBaseOnInputType; const currentMonth = this.monthDisplayValue || 1; const currentDay = this.dayDisplayValue || 1; const { hour, minute, millisecond, second } = this.#valueObject.time; this.#setDateValueFromNumberBaseOnInputType(currentYear, currentMonth + interval, currentDay, hour, minute, second, millisecond); this.#updateInputTextFromValue(); } #addDay(interval: number) { const currentYear = this.yearDisplayValue ? this.yearDisplayValue : this.#dateFactory.yearOnEmptyBaseOnInputType; const currentMonth = this.monthDisplayValue || 1; const currentDay = this.dayDisplayValue || 1; const { hour, minute, millisecond, second } = this.#valueObject.time; this.#setDateValueFromNumberBaseOnInputType(currentYear, currentMonth, currentDay + interval, hour, minute, second, millisecond); this.#updateInputTextFromValue(); } /** * @description will convert current valueObject to expected value string */ getDateValue(type: ValueType = this.valueType): string { return this.#dateFactory.getDateValueStringFromValueObject(this.#valueObject, type); } /** * @description when user change value this function called and update inner value object base on user value */ #setDateValue(value: string | Date) { if (typeof value == "string") { switch (this.#dateFactory.valueType) { case "GREGORIAN": case "JALALI": this.#setDateValueFromString(value); break; case "TIME_STAMP": this.#setDateValueFromTimeStamp(value); break; } } else if (value instanceof Date) { this.#setDateValueFromDate(value); } this.#updateFormAssociatedValue(); } #setValueObjNull() { // mean we reset calendar value and set it to null this.#valueObject = getEmptyValueObject(); } #updateCalendarView() { //update jb-calendar view base on current data const value: JBCalendarValue = { year: this.#dateFactory.getCalendarYear(this.#valueObject), month: this.#dateFactory.getCalendarMonth(this.#valueObject), day: this.#dateFactory.getCalendarDay(this.#valueObject), }; if (value.year && value.month && value.day) { //if we have all data we update calendar value this.elements.calendar.value = value; } else if (value.year && value.month) { //if we dont have all data we just set view year and month this.elements.calendar.data.selectedYear = value.year; this.elements.calendar.data.selectedMonth = value.month; } } /** * @description set date value from javascript Date */ #setDateValueFromDate(value: Date) { const valueObject = this.#dateFactory.getDateObjectValueFromDateValue(value); this.#valueObject = valueObject; this.#updateCalendarView(); } /** * @description set date value from timestamp base on valueType */ #setDateValueFromTimeStamp(value: string) { const timeStamp = parseInt(value); this.#valueObject = this.#dateFactory.getDateValueObjectFromTimeStamp(timeStamp); this.#updateCalendarView(); } /** * @description set date value from string base on valueType */ #setDateValueFromString(value: string) { const dateInObject = this.#dateFactory.getDateObjectValueBaseOnFormat(value); if (dateInObject.year && dateInObject.month && dateInObject.day) { this.#setDateValueFromNumbers(parseInt(dateInObject.year), parseInt(dateInObject.month), parseInt(dateInObject.day), parseInt(dateInObject.hour ?? '00'), parseInt(dateInObject.minute ?? '00'), parseInt(dateInObject.second ?? '00'), parseInt(dateInObject.millisecond ?? '000')); } else { if (value !== null && value !== undefined && value !== '') { console.error('your inputted Date doest match default or your specified Format'); } else { this.#setValueObjNull(); } } } /** * @description set value object base on currently valueType */ #setDateValueFromNumbers(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number) { const prevYear = this.yearValue; const prevMonth = this.monthValue; const result: JBDateInputValueObject = this.#dateFactory.getDateValueObjectBaseOnValueType(year, month, day, prevYear, prevMonth, hour, minute, second, millisecond); this.#valueObject = result; this.#updateCalendarView(); } /** * set value object base on currently inputType (call this function when date is complete) * @param {number} year jalali or gregorian year * @param {number} month jalali or gregorian month * @param {number} day jalali or gregorian day */ #setDateValueFromNumberBaseOnInputType(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number) { //TODO: refactor this component to use date value as a core object const prevYear = this.yearBaseOnInputType; const prevMonth = this.monthBaseOnInputType; const result: JBDateInputValueObject = this.#dateFactory.getDateValueObjectBaseOnInputType(year, month, day, prevYear, prevMonth, hour, minute, second, millisecond); this.#valueObject = result; this.#updateCalendarView(); this.#updateFormAssociatedValue(); } #updateInputTextFromValue() { const { year, month, day } = this.inputType == InputTypes.jalali ? this.#valueObject.jalali : this.#valueObject.gregorian; if (this.placeholder && !(year && month && day)) { //if we have placeholder and inputted value were all null we show placeholder until user input some value this.#inputValue = ""; return; } // let str = this.inputFormat; let yearString = ' ', monthString = ' ', dayString = ' '; if (year != null && !Number.isNaN(year)) { if (year < 10) { yearString = '000' + year; } else if (year < 100) { yearString = '00' + year; } else if (year < 1000) { yearString = '0' + year; } else { yearString = year.toString(); } } if (month != null && !Number.isNaN(month)) { if (month < 10) { monthString = '0' + month; } else { monthString = month.toString(); } } if (day != null && !Number.isNaN(day)) { if (day < 10) { dayString = '0' + day; } else { dayString = day.toString(); } } //convert to fa char if needed if (this.#showPersianNumber) { yearString = enToFaDigits(yearString); monthString = enToFaDigits(monthString); dayString = enToFaDigits(dayString); } str = str.replace('YYYY', yearString).replace('MM', monthString).replace('DD', dayString); this.#inputValue = str; } /** * called when input text change and we want to update value object base on input text * @param {string}inputString */ #updateValueFromInputString(inputString: string) { const res = this.#inputRegex.exec(inputString); if (res && res.groups) { //TODO: update this when support date time and get times factor from input const { hour, minute, millisecond, second } = this.#valueObject.time; const year = parseInt(res.groups.year); const month = parseInt(res.groups.month); const day = parseInt(res.groups.day); if (year && month && day) { this.#setDateValueFromNumberBaseOnInputType(year, month, day, hour, minute, second, millisecond); } } } /** * @public * @description focus on date input web-component */ focus() { //public this.elements.input.focus(); this.showCalendar = true; } #handleCaretPosOnInputFocus() { const caretPos = this.elements.input.selectionStart; if (caretPos) { const trimmedYearLength = this.typedYear.trim().length; if (trimmedYearLength < caretPos && caretPos <= 4) { //if year was null we move cursor to first char of year this.elements.input.setSelectionRange(trimmedYearLength, trimmedYearLength); return; } const trimmedMonthLength = this.typedMonth.trim().length; if (trimmedMonthLength + 5 < caretPos && caretPos > 4 && caretPos <= 7) { //if month was null we move cursor to first char of month this.elements.input.setSelectionRange(trimmedMonthLength + 5, trimmedMonthLength + 5); return; } const trimmedDayLength = this.typedDay.trim().length; if (trimmedDayLength + 8 < caretPos && caretPos > 7 && caretPos <= 10) { //if day was null we move cursor to first char of day this.elements.input.setSelectionRange(trimmedDayLength + 8, trimmedDayLength + 8); return; } } } #lastInputStringValue = ' / / '; /** * check if there is no update from last time then if change we update. remember to call returned update. * @param { string }newString newly typed String */ #checkIfInputTextIsChangedFromLastTime(newString: string) { const updatePrevValue = () => { this.#lastInputStringValue = newString; }; if (this.#lastInputStringValue != newString) { this.#lastInputStringValue = newString; return { isUpdated: true, updatePrevValue }; } return { isUpdated: false, updatePrevValue }; } #onInputFocus(e: FocusEvent) { this.#lastInputStringValue = this.#sInputValue; this.focus(); //dont add once:true here because we need to detect every caret pos change during the type and then remove it from our input on blur document.addEventListener('selectionchange', this.#handleCaretPosOnInputFocus.bind(this)); this.#dispatchFocusEvent(e); } #dispatchFocusEvent(e: FocusEvent) { e.stopPropagation(); const event = createFocusEvent("focus", e, { cancelable: false }); this.dispatchEvent(event); } #onInputBlur(e: FocusEvent) { document.removeEventListener('selectionchange', this.#handleCaretPosOnInputFocus.bind(this)); const focusedElement = e.relatedTarget; if (focusedElement !== this.elements.calendar && focusedElement !== this.elements.calendarTriggerButton) { this.showCalendar = false; } const inputText = this.#sInputValue; //check if there is no update from last time then if change we update const changeTestRes = this.#checkIfInputTextIsChangedFromLastTime(inputText); if (changeTestRes.isUpdated) { this.#updateValueFromInputString(inputText); const dispatchedEvent = this.#dispatchOnChangeEvent(); this.#checkValidity(true); if (dispatchedEvent.defaultPrevented) { e.preventDefault(); this.#updateValueFromInputString(this.#lastInputStringValue); } else { changeTestRes.updatePrevValue(); } } this.#dispatchBlurEvent(e); } #dispatchBlurEvent(e: FocusEvent) { e.stopPropagation(); const event = createFocusEvent("blur", e, { cancelable: false }); this.dispatchEvent(event); } #onCalendarBlur(e: FocusEvent) { const focusedElement = e.relatedTarget; if (focusedElement !== this.elements.input && focusedElement !== this.elements.calendarTriggerButton) { this.showCalendar = false; } } #onPopoverClose() { this.showCalendar = false; this.elements.input.blur(); } #dispatchOnChangeEvent() { const event = new Event('change', { composed: true, bubbles: true, cancelable: true }); this.dispatchEvent(event); return event; } /** * @deprecated use dom.validation.checkValidity instead */ triggerInputValidation(showError = true) { // this method is for use out of component for example if user click on submit button and developer want to check if all fields are valid //takeAction determine if we want to show user error in web component default Manner or developer will handle it by himself return this.#checkValidity(showError); } #getInsideValidations() { const validationList: ValidationItem<ValidationValue>[] = []; if(this.getAttribute("error") !== null && this.getAttribute("error").trim().length > 0){ validationList.push({ validator: undefined, message: this.getAttribute("error"), stateType: "customError" }); } if (this.required) { validationList.push(requiredValidation); } if (this.dateRestrictions.min) { validationList.push({ validator: (value) => { return checkMinValidation(new Date(value.valueObject.timeStamp), this.dateRestrictions.min); }, message: 'تاریخ انتخابی کمتر از بازه مجاز است', stateType: "rangeUnderflow" }); } if (this.dateRestrictions.max) { validationList.push({ validator: (value) => { return checkMaxValidation(new Date(value.valueObject.timeStamp), this.dateRestrictions.max); }, message: 'تاریخ انتخابی بیشتر از بازه مجاز است', stateType: "rangeOverflow" }); } return validationList; } showValidationError(error: ShowValidationErrorParameters) { this.elements.input.showValidationError(error); (this.#internals as any).states?.add("invalid"); } clearValidationError() { this.elements.input.clearValidationError(); (this.#internals as any).states?.delete("invalid"); } #onCalendarElementInitiated() { this.elements.calendar.dateRestrictions.min = this.dateRestrictions.min; this.elements.calendar.dateRestrictions.max = this.dateRestrictions.max; this.elements.calendar.defaultCalendarData = { gregorian: { year: this.#dateFactory.nicheNumbers.calendarYearOnEmpty.gregorian, month: this.#dateFactory.nicheNumbers.calendarMonthOnEmpty.gregorian, }, jalali: { year: this.#dateFactory.nicheNumbers.calendarYearOnEmpty.jalali, month: this.#dateFactory.nicheNumbers.calendarMonthOnEmpty.jalali, } }; this.#updateCalendarView(); } #isCalendarButtonClickEventIsAfterFocusEvent = false; #onCalendarButtonFocused(e: FocusEvent) { const prevFocused = e.relatedTarget; if (this.showCalendar && prevFocused && [this.elements.calendar as EventTarget, this.elements.input as EventTarget].includes(prevFocused)) { //if calendar was displayed but user click on icon we hide it here (prevFocused as HTMLInputElement).focus(); this.showCalendar = false; } else { // if user focus on calendar button from outside of calendar area we show calendar this.#isCalendarButtonClickEventIsAfterFocusEvent = true; this.showCalendar = true; } } #onCalendarButtonBlur(e: FocusEvent) { if (![this.elements.calendar as EventTarget, this.elements.input as EventTarget].includes(e.relatedTarget!)) { this.showCalendar = false; } } #onCalendarButtonClick() { const focusedElement = this.shadowRoot?.activeElement; if (focusedElement && !this.#isCalendarButtonClickEventIsAfterFocusEvent && focusedElement == this.elements.calendarTriggerButton) { //check if this click is event exactly after focus or not if its after focus we just pass but if its not and its a second click we close menu or reopen menu if closed before this.showCalendar = !this.showCalendar; } this.#isCalendarButtonClickEventIsAfterFocusEvent = false; } #onCalendarSelect(e: CustomEvent) { const target = e.target as JBCalendarWebComponent; const { year, month, day } = target.value; if (year && month && day) { const prevValueDate = structuredClone(this.valueInDate); const { hour, minute, millisecond, second } = this.#valueObject.time; this.#setDateValueFromNumberBaseOnInputType(year, month, day, hour, minute, second, millisecond); this.#updateInputTextFromValue(); this.showCalendar = false; this.#callOnDateSelect(); this.#checkValidity(true); const dispatchedEvent = this.#dispatchOnChangeEvent(); if (dispatchedEvent.defaultPrevented) { e.preventDefault(); this.#setDateValueFromDate(prevValueDate); this.#updateInputTextFromValue(); } } } #callOnDateSelect() { //when user pick a day in calendar modal const event = new CustomEvent('select'); this.dispatchEvent(event); } async onInputTypeChange() { //wait for sub-component load on first value initiation if (!this.#isAllSubComponentInitiated) { await this.#waitForComponentsLoad(); } this.elements.calendar.inputType = this.inputType; this.#updateInputTextFromValue(); } /** * set opened calendar date when date input value is empty * @public * @param year which year you want to show in empty state in calendar. * @param month which month you want to show in empty state in calendar. * @param dateType default is your configured input-type but you can set it otherwise if you want to change other type of calendar in case of change in input-type. */ setCalendarDefaultDateView(year: number, month: number, dateType: InputType | undefined) { if (year && month) { this.#dateFactory.setCalendarDefaultDateView(year, month, dateType); this.#updateCalendarView(); } } #checkValidity(showError: boolean) { if (!this.isAutoValidationDisabled) { return this.#validation.checkValidity({ showError }); } } /** * @public * @description this method used to check for validity but doesn't show error to user and just return the result * this method used by #internal of component */ checkValidity(): boolean { const validationResult = this.#validation.checkValiditySync({ showError: false }); if (!validationResult.isAllValid) { this.#dispatchInvalidEvent(); } return validationResult.isAllValid; } /** * @public * @description this method used to check for validity and show error to user */ reportValidity(): boolean { const validationResult = this.#validation.checkValiditySync({ showError: true }); if (!validationResult.isAllValid) { this.#dispatchInvalidEvent(); } return validationResult.isAllValid; } #dispatchInvalidEvent() { const event = new CustomEvent('invalid'); this.dispatchEvent(event); } /** * @description this method called on every checkValidity calls and update validation result of #internal */ #setValidationResult(result: ValidationResult<ValidationValue>) { if (result.isAllValid) { this.#internals.setValidity({}, ''); } else { const states: ValidityStateFlags = {}; let message = ""; result.validationList.forEach((res) => { if (!res.isValid) { if (res.validation.stateType) { states[res.validation.stateType] = true; } else { states["customError"] = true; } if (message == '') { message = res.message; } } }); this.#internals.setValidity(states, message); } } get validationMessage() { return this.#internals.validationMessage; } } const myElementNotExists = !customElements.get('jb-date-input'); if (myElementNotExists) { window.customElements.define('jb-date-input', JBDateInputWebComponent); }