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
text/typescript
/*
* 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;