UNPKG

preline

Version:

Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.

790 lines (668 loc) 21.9 kB
/* * HSDatepicker * @version: 3.0.1 * @author: Preline Labs Ltd. * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) * Copyright 2024 Preline Labs Ltd. */ import { dispatch } from "../../utils"; import { Calendar, DatesArr, Range } from "vanilla-calendar-pro"; import CustomVanillaCalendar from "./vanilla-datepicker-pro"; import { templates } from "./templates"; import { classToClassList, htmlToElement } from "../../utils"; import HSSelect from "../select"; import { ISelectOptions } from "../select/interfaces"; import { ICustomDatepickerOptions, IDatepicker } from "./interfaces"; import HSBasePlugin from "../base-plugin"; import { ICollectionItem } from "../../interfaces"; declare var _: any; class HSDatepicker extends HSBasePlugin<{}> implements IDatepicker { private dataOptions: ICustomDatepickerOptions; private updatedStyles: ICustomDatepickerOptions["styles"]; private vanillaCalendar: Calendar; constructor(el: HTMLElement, options?: {}, events?: {}) { super(el, options, events); const dataOptions: ICustomDatepickerOptions = el.getAttribute("data-hs-datepicker") ? JSON.parse(el.getAttribute("data-hs-datepicker")!) : {}; this.dataOptions = { ...dataOptions, ...options, }; const removeDefaultStyles = typeof this.dataOptions?.removeDefaultStyles !== "undefined" ? this.dataOptions?.removeDefaultStyles : false; this.updatedStyles = _.mergeWith( removeDefaultStyles ? {} : CustomVanillaCalendar.defaultStyles, this.dataOptions?.styles || {}, (a: any, b: any) => { if (typeof a === "string" && typeof b === "string") { return `${a} ${b}`; } }, ); const today = new Date(); const defaults = { styles: this.updatedStyles, dateMin: this.dataOptions.dateMin ?? today.toISOString().split("T")[0], dateMax: this.dataOptions.dateMax ?? "2470-12-31", mode: this.dataOptions.mode ?? "default", inputMode: typeof this.dataOptions.inputMode !== "undefined" ? this.dataOptions.inputMode : true, }; const chainCallbacks = ( superCallback?: Function, customCallback?: (self: Calendar) => void, ) => (self: Calendar) => { superCallback?.(self); customCallback?.(self); }; const initTime = (self: Calendar) => { if (this.hasTime(self)) this.initCustomTime(self); }; const _options = { layouts: { month: templates.month, }, onInit: chainCallbacks(this.dataOptions.onInit, (self) => { if (defaults.mode === "custom-select" && !this.dataOptions.inputMode) { initTime(self); } }), onShow: chainCallbacks(this.dataOptions.onShow, (self) => { if (defaults.mode === "custom-select") { this.updateCustomSelects(self); initTime(self); } }), onHide: chainCallbacks(this.dataOptions.onHide, (self) => { if (defaults.mode === "custom-select") { this.destroySelects(self.context.mainElement); } }), onUpdate: chainCallbacks(this.dataOptions.onUpdate, (self) => { this.updateCalendar(self.context.mainElement); }), onCreateDateEls: chainCallbacks( this.dataOptions.onCreateDateEls, (self) => { if (defaults.mode === "custom-select") this.updateCustomSelects(self); }, ), onChangeToInput: chainCallbacks( this.dataOptions.onChangeToInput, (self) => { if (!self.context.inputElement) return; this.setInputValue( self.context.inputElement, self.context.selectedDates, ); const data = { selectedDates: self.context.selectedDates, selectedTime: self.context.selectedTime, rest: self.context, }; this.fireEvent("change", data); dispatch("change.hs.datepicker", this.el, data); }, ), onChangeTime: chainCallbacks(this.dataOptions.onChangeTime, initTime), onClickYear: chainCallbacks(this.dataOptions.onClickYear, initTime), onClickMonth: chainCallbacks(this.dataOptions.onClickMonth, initTime), onClickArrow: chainCallbacks(this.dataOptions.onClickArrow, (self) => { if (defaults.mode === "custom-select") { setTimeout(() => { this.disableNav(); this.disableOptions(); this.updateCalendar(self.context.mainElement); }); } }), }; const processedOptions = { ...defaults, layouts: { default: this.processCustomTemplate(templates.default, "default"), multiple: this.processCustomTemplate(templates.multiple, "multiple"), year: this.processCustomTemplate(templates.year, "default"), }, }; this.vanillaCalendar = new CustomVanillaCalendar( this.el, _.merge(_options, this.dataOptions, processedOptions), ); this.init(); } private init() { this.createCollection(window.$hsDatepickerCollection, this); this.vanillaCalendar.init(); if (this.dataOptions?.selectedDates) { this.setInputValue( this.vanillaCalendar.context.inputElement, this.formatDateArrayToIndividualDates(this.dataOptions?.selectedDates), ); } } private getTimeParts(time: string) { const [_time, meridiem] = time.split(" "); const [hours, minutes] = _time.split(":"); return [hours, minutes, meridiem]; } private getCurrentMonthAndYear(el: HTMLElement) { const currentMonthHolder = el.querySelector('[data-vc="month"]'); const currentYearHolder = el.querySelector('[data-vc="year"]'); return { month: +currentMonthHolder.getAttribute("data-vc-month"), year: +currentYearHolder.getAttribute("data-vc-year"), }; } private setInputValue(target: HTMLInputElement, dates: DatesArr) { const dateSeparator = this.dataOptions?.inputModeOptions?.dateSeparator ?? "."; const itemsSeparator = this.dataOptions?.inputModeOptions?.itemsSeparator ?? ", "; const selectionDatesMode = this.dataOptions?.selectionDatesMode ?? "single"; if (dates.length && dates.length > 1) { if (selectionDatesMode === "multiple") { const temp: string[] = []; dates.forEach((date) => temp.push(this.changeDateSeparator(date, dateSeparator)) ); target.value = temp.join(itemsSeparator); } else { target.value = [ this.changeDateSeparator(dates[0], dateSeparator), this.changeDateSeparator(dates[1], dateSeparator), ].join(itemsSeparator); } } else if (dates.length && dates.length === 1) { target.value = this.changeDateSeparator(dates[0], dateSeparator); } else target.value = ""; } private changeDateSeparator( date: string | number | Date, separator = ".", defaultSeparator = "-", ) { const newDate = (date as string).split(defaultSeparator); return newDate.join(separator); } private formatDateArrayToIndividualDates(dates: DatesArr): string[] { const selectionDatesMode = this.dataOptions?.selectionDatesMode ?? "single"; const expandDateRange = (start: string, end: string): string[] => { const startDate = new Date(start); const endDate = new Date(end); const result: string[] = []; while (startDate <= endDate) { result.push(startDate.toISOString().split("T")[0]); startDate.setDate(startDate.getDate() + 1); } return result; }; const formatDate = (date: string | number | Date): string[] => { if (typeof date === "string") { const rangeMatch = date.match( /^(\d{4}-\d{2}-\d{2})\s*[^a-zA-Z0-9]*\s*(\d{4}-\d{2}-\d{2})$/, ); if (rangeMatch) { const [_, start, end] = rangeMatch; return selectionDatesMode === "multiple-ranged" ? [start, end] : expandDateRange(start.trim(), end.trim()); } return [date]; } else if (typeof date === "number") { return [new Date(date).toISOString().split("T")[0]]; } else if (date instanceof Date) { return [date.toISOString().split("T")[0]]; } return []; }; return dates.flatMap(formatDate); } private hasTime(el: Calendar) { const { mainElement } = el.context; const hours = mainElement.querySelector( "[data-hs-select].--hours", ) as HTMLElement; const minutes = mainElement.querySelector( "[data-hs-select].--minutes", ) as HTMLElement; const meridiem = mainElement.querySelector( "[data-hs-select].--meridiem", ) as HTMLElement; return hours && minutes && meridiem; } private createArrowFromTemplate( template: string, classes: string | boolean = false, ) { if (!classes) return template; const temp = htmlToElement(template); classToClassList(classes as string, temp); return temp.outerHTML; } private concatObjectProperties< T extends ISelectOptions, U extends ISelectOptions, >( shared: T, other: U, ): Partial<T & U> { const result: Partial<T & U> = {}; const allKeys = new Set<keyof T | keyof U>([ ...Object.keys(shared || {}), ...Object.keys(other || {}), ] as Array<keyof T | keyof U>); allKeys.forEach((key) => { const sharedValue = shared[key as keyof T] || ""; const otherValue = other[key as keyof U] || ""; result[key as keyof T & keyof U] = `${sharedValue} ${otherValue}` .trim() as T[keyof T & keyof U] & U[keyof T & keyof U]; }); return result; } private updateTemplate( template: string, shared: ISelectOptions, specific: ISelectOptions, ) { if (!shared) return template; const defaultOptions = JSON.parse( template.match(/data-hs-select='([^']+)'/)[1], ); const concatOptions = this.concatObjectProperties(shared, specific); const mergedOptions = _.merge(defaultOptions, concatOptions); const updatedTemplate = template.replace( /data-hs-select='[^']+'/, `data-hs-select='${JSON.stringify(mergedOptions)}'`, ); return updatedTemplate; } private initCustomTime(self: Calendar) { const { mainElement } = self.context; const timeParts = this.getTimeParts(self.selectedTime ?? "12:00 PM"); const selectors = { hours: mainElement.querySelector( "[data-hs-select].--hours", ) as HTMLElement, minutes: mainElement.querySelector( "[data-hs-select].--minutes", ) as HTMLElement, meridiem: mainElement.querySelector( "[data-hs-select].--meridiem", ) as HTMLElement, }; Object.entries(selectors).forEach(([key, element]) => { if (!HSSelect.getInstance(element, true)) { const instance = new HSSelect(element); instance.setValue( timeParts[key === "meridiem" ? 2 : key === "minutes" ? 1 : 0], ); instance.el.addEventListener("change.hs.select", (evt: CustomEvent) => { this.destroySelects(mainElement); const updatedTime = { hours: key === "hours" ? evt.detail.payload : timeParts[0], minutes: key === "minutes" ? evt.detail.payload : timeParts[1], meridiem: key === "meridiem" ? evt.detail.payload : timeParts[2], }; self.set({ selectedTime: `${updatedTime.hours}:${updatedTime.minutes} ${updatedTime.meridiem}`, }, { dates: false, year: false, month: false, }); }); } }); } private initCustomMonths(self: Calendar) { const { mainElement } = self.context; const columns = Array.from(mainElement.querySelectorAll(".--single-month")); if (columns.length) { columns.forEach((column: HTMLElement, idx: number) => { const _month = column.querySelector( "[data-hs-select].--month", ) as HTMLElement; const isInstanceExists = HSSelect.getInstance(_month, true); if (isInstanceExists) return false; const instance = new HSSelect(_month); const { month, year } = this.getCurrentMonthAndYear(column); instance.setValue(`${month}`); instance.el.addEventListener("change.hs.select", (evt: CustomEvent) => { this.destroySelects(mainElement); self.set({ selectedMonth: (+evt.detail.payload - idx < 0 ? 11 : +evt.detail.payload - idx) as Range<12>, selectedYear: (+evt.detail.payload - idx < 0 ? +year - 1 : year) as number, }, { dates: false, time: false, }); }); }); } } private initCustomYears(self: Calendar) { const { mainElement } = self.context; const columns = Array.from(mainElement.querySelectorAll(".--single-month")); if (columns.length) { columns.forEach((column: HTMLElement) => { const _year = column.querySelector( "[data-hs-select].--year", ) as HTMLElement; const isInstanceExists = HSSelect.getInstance(_year, true); if (isInstanceExists) return false; const instance = new HSSelect(_year); const { month, year } = this.getCurrentMonthAndYear(column); instance.setValue(`${year}`); instance.el.addEventListener("change.hs.select", (evt: CustomEvent) => { const { dateMax, displayMonthsCount } = this.vanillaCalendar.context; const maxYear = new Date(dateMax).getFullYear(); const maxMonth = new Date(dateMax).getMonth(); this.destroySelects(mainElement); self.set({ selectedMonth: ((month > maxMonth - displayMonthsCount) && +evt.detail.payload === maxYear ? maxMonth - displayMonthsCount + 1 : month) as Range<12>, selectedYear: evt.detail.payload, }, { dates: false, time: false, }); }); }); } } private generateCustomTimeMarkup() { const customSelectOptions = this.updatedStyles?.customSelect; const hours = customSelectOptions ? this.updateTemplate( templates.hours, customSelectOptions?.shared || {} as ISelectOptions, customSelectOptions?.hours || {} as ISelectOptions, ) : templates.hours; const minutes = customSelectOptions ? this.updateTemplate( templates.minutes, customSelectOptions?.shared || {} as ISelectOptions, customSelectOptions?.minutes || {} as ISelectOptions, ) : templates.minutes; const meridiem = customSelectOptions ? this.updateTemplate( templates.meridiem, customSelectOptions?.shared || {} as ISelectOptions, customSelectOptions?.meridiem || {} as ISelectOptions, ) : templates.meridiem; const time = this?.dataOptions?.templates?.time ?? ` <div class="pt-3 flex justify-center items-center gap-x-2"> ${hours} <span class="text-gray-800 dark:text-white">:</span> ${minutes} ${meridiem} </div> `; return `<div class="--time">${time}</div>`; } private generateCustomMonthMarkup() { const mode = this?.dataOptions?.mode ?? "default"; const customSelectOptions = this.updatedStyles?.customSelect; const updatedTemplate = customSelectOptions ? this.updateTemplate( templates.months, customSelectOptions?.shared || {} as ISelectOptions, customSelectOptions?.months || {} as ISelectOptions, ) : templates.months; const month = mode === "custom-select" ? updatedTemplate : "<#Month />"; return month; } private generateCustomYearMarkup() { const mode = this?.dataOptions?.mode ?? "default"; if (mode === "custom-select") { const today = new Date(); const dateMin = this?.dataOptions?.dateMin ?? today.toISOString().split("T")[0]; const tempDateMax = this?.dataOptions?.dateMax ?? "2470-12-31"; const dateMax = tempDateMax; const startDate = new Date(dateMin); const endDate = new Date(dateMax); const startDateYear = startDate.getFullYear(); const endDateYear = endDate.getFullYear(); const generateOptions = () => { let result = ""; for (let i = startDateYear; i <= endDateYear; i++) { result += `<option value="${i}">${i}</option>`; } return result; }; const years = templates.years(generateOptions()); const customSelectOptions = this.updatedStyles?.customSelect; const updatedTemplate = customSelectOptions ? this.updateTemplate( years, customSelectOptions?.shared || {} as ISelectOptions, customSelectOptions?.years || {} as ISelectOptions, ) : years; return updatedTemplate; } else { return "<#Year />"; } } private generateCustomArrowPrevMarkup() { const arrowPrev = this?.dataOptions?.templates?.arrowPrev ? this.createArrowFromTemplate( this.dataOptions.templates.arrowPrev, this.updatedStyles.arrowPrev, ) : "<#ArrowPrev [month] />"; return arrowPrev; } private generateCustomArrowNextMarkup() { const arrowNext = this?.dataOptions?.templates?.arrowNext ? this.createArrowFromTemplate( this.dataOptions.templates.arrowNext, this.updatedStyles.arrowNext, ) : "<#ArrowNext [month] />"; return arrowNext; } private parseCustomTime(template: string) { template = template.replace( /<#CustomTime\s*\/>/g, this.generateCustomTimeMarkup(), ); return template; } private parseCustomMonth(template: string) { template = template.replace( /<#CustomMonth\s*\/>/g, this.generateCustomMonthMarkup(), ); return template; } private parseCustomYear(template: string) { template = template.replace( /<#CustomYear\s*\/>/g, this.generateCustomYearMarkup(), ); return template; } private parseArrowPrev(template: string) { template = template.replace( /<#CustomArrowPrev\s*\/>/g, this.generateCustomArrowPrevMarkup(), ); return template; } private parseArrowNext(template: string) { template = template.replace( /<#CustomArrowNext\s*\/>/g, this.generateCustomArrowNextMarkup(), ); return template; } private processCustomTemplate( template: string, type: "default" | "multiple", ): string { const templateAccordingToType = type === "default" ? this?.dataOptions?.layouts?.default : this?.dataOptions?.layouts?.multiple; const processedCustomMonth = this.parseCustomMonth( templateAccordingToType ?? template, ); const processedCustomYear = this.parseCustomYear(processedCustomMonth); const processedCustomTime = this.parseCustomTime(processedCustomYear); const processedCustomArrowPrev = this.parseArrowPrev(processedCustomTime); const processedCustomTemplate = this.parseArrowNext( processedCustomArrowPrev, ); return processedCustomTemplate; } private disableOptions() { const { mainElement, dateMax, displayMonthsCount } = this.vanillaCalendar.context; const maxDate = new Date(dateMax); const columns = Array.from(mainElement.querySelectorAll(".--single-month")); columns.forEach((column, idx) => { const year = +column.querySelector('[data-vc="year"]')?.getAttribute( "data-vc-year", )!; const monthOptions = column.querySelectorAll( "[data-hs-select].--month option", ); const pseudoOptions = column.querySelectorAll( "[data-hs-select-dropdown] [data-value]", ); const isDisabled = (option: HTMLOptionElement | HTMLElement) => { const value = +option.getAttribute("data-value")!; return value > maxDate.getMonth() - displayMonthsCount + idx + 1 && year === maxDate.getFullYear(); }; Array.from(monthOptions).forEach((option: HTMLOptionElement) => option.toggleAttribute("disabled", isDisabled(option)) ); Array.from(pseudoOptions).forEach((option: HTMLOptionElement) => option.classList.toggle("disabled", isDisabled(option)) ); }); } private disableNav() { const { mainElement, dateMax, selectedYear, selectedMonth, displayMonthsCount, } = this.vanillaCalendar.context; const maxYear = new Date(dateMax).getFullYear(); const next = mainElement.querySelector( '[data-vc-arrow="next"]', ) as HTMLElement; if (selectedYear === maxYear && selectedMonth + displayMonthsCount > 11) { next.style.visibility = "hidden"; } else next.style.visibility = ""; } private destroySelects(container: HTMLElement) { const selects = Array.from(container.querySelectorAll("[data-hs-select]")); selects.forEach((select: HTMLElement) => { const instance = HSSelect.getInstance(select, true) as ICollectionItem< HSSelect >; if (instance) instance.element.destroy(); }); } private updateSelect(el: HTMLElement, value: string) { const instance = HSSelect.getInstance(el, true) as ICollectionItem< HSSelect >; if (instance) instance.element.setValue(value); } private updateCalendar(calendar: HTMLElement) { const columns = calendar.querySelectorAll(".--single-month"); if (columns.length) { columns.forEach((column: HTMLElement) => { const { month, year } = this.getCurrentMonthAndYear(column); this.updateSelect( column.querySelector("[data-hs-select].--month"), `${month}`, ); this.updateSelect( column.querySelector("[data-hs-select].--year"), `${year}`, ); }); } } private updateCustomSelects(el: Calendar) { setTimeout(() => { this.disableOptions(); this.disableNav(); this.initCustomMonths(el); this.initCustomYears(el); }); } // Public methods public getCurrentState() { return { selectedDates: this.vanillaCalendar.selectedDates, selectedTime: this.vanillaCalendar.selectedTime, }; } // Static methods static getInstance(target: HTMLElement | string, isInstance?: boolean) { const elInCollection = window.$hsDatepickerCollection.find( (el) => el.element.el === (typeof target === "string" ? document.querySelector(target) : target), ); return elInCollection ? isInstance ? elInCollection : elInCollection.element.el : null; } static autoInit() { if (!window.$hsDatepickerCollection) window.$hsDatepickerCollection = []; document .querySelectorAll(".hs-datepicker:not(.--prevent-on-load-init)") .forEach((el: HTMLElement) => { if ( !window.$hsDatepickerCollection.find( (elC) => (elC?.element?.el as HTMLElement) === el, ) ) { new HSDatepicker(el); } }); } } declare global { interface Window { HSDatepicker: Function; $hsDatepickerCollection: ICollectionItem<HSDatepicker>[]; } } window.addEventListener("load", () => { HSDatepicker.autoInit(); // Uncomment for debug // console.log('Datepicker collection:', window.$hsDatepickerCollection); }); if (typeof window !== "undefined") { window.HSDatepicker = HSDatepicker; } export default HSDatepicker;