jb-date-input
Version:
jalali date input web component
1,232 lines (1,210 loc) • 47 kB
text/typescript
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);
}