Files
syscom-master-admin-ui/src/app/@spk/plugins&reusable/spk-flatpickr/spk-flatpickr.ts
T

662 lines
24 KiB
TypeScript

import {
Component,
ElementRef,
EventEmitter,
OnDestroy,
Output,
ViewChild,
forwardRef,
input
} from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { FlatpickrDefaults, FlatpickrDirective, provideFlatpickrDefaults } from 'angularx-flatpickr';
export type SpkFlatpickrMode = 'single' | 'multiple' | 'range';
export interface DateRangeValue {
from: string | null;
to: string | null;
}
export type DatePickerValue = string | DateRangeValue | null;
export interface FlatPickrOutputOptions {
selectedDates: Date[];
dateString: string;
instance: any;
}
export type CalendarViewMode = 'calendar' | 'month' | 'year';
export function formatSingleDate(raw: any): string | null {
if (raw == null || raw === '') return null;
if (raw instanceof Date) {
if (isNaN(raw.getTime())) return null;
const year = raw.getFullYear();
const month = String(raw.getMonth() + 1).padStart(2, '0');
const day = String(raw.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
if (typeof raw === 'number') {
const d = new Date(raw);
return isNaN(d.getTime()) ? null : formatSingleDate(d);
}
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (!trimmed) return null;
const datePart = trimmed.split(/[T\s]/)[0];
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart;
const parsed = new Date(trimmed);
if (!isNaN(parsed.getTime())) {
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
return null;
}
export function toFlatpickrValue(val: DatePickerValue, mode: SpkFlatpickrMode): string | null {
if (!val) return null;
if (mode === 'range') {
if (typeof val === 'object' && 'from' in val) {
const from = formatSingleDate(val.from);
const to = formatSingleDate(val.to);
if (from && to) return `${from} to ${to}`;
if (from) return from;
return null;
}
if (typeof val === 'string') return val;
return null;
}
return formatSingleDate(val);
}
export function fromFlatpickrValue(raw: any, mode: SpkFlatpickrMode): DatePickerValue {
if (!raw) return null;
if (mode === 'range') {
let fromStr: string | null = null;
let toStr: string | null = null;
if (Array.isArray(raw)) {
fromStr = formatSingleDate(raw[0]);
toStr = formatSingleDate(raw[1]);
} else if (typeof raw === 'object' && !(raw instanceof Date)) {
fromStr = formatSingleDate(raw.from);
toStr = formatSingleDate(raw.to);
} else if (typeof raw === 'string') {
const parts = raw.split(/\s+to\s+|\s+-\s+/i);
fromStr = formatSingleDate(parts[0]);
toStr = formatSingleDate(parts[1]);
}
return (fromStr || toStr) ? { from: fromStr, to: toStr } : null;
}
if (Array.isArray(raw)) {
return formatSingleDate(raw[0]);
}
return formatSingleDate(raw);
}
@Component({
selector: 'spk-flatpickr',
standalone: true,
imports: [FlatpickrDirective, FormsModule, ReactiveFormsModule],
providers: [
FlatpickrDefaults,
provideFlatpickrDefaults(),
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SpkFlatpickr),
multi: true
}
],
templateUrl: './spk-flatpickr.html',
styleUrl: './spk-flatpickr.scss'
})
export class SpkFlatpickr implements ControlValueAccessor, OnDestroy {
@ViewChild('nativeInput', { static: false }) nativeInputRef?: ElementRef<HTMLInputElement>;
readonly id = input<string>('');
readonly altInput = input<boolean>(false);
readonly convertModelValue = input<boolean>(false);
readonly enableTime = input<boolean>(false);
readonly noCalendar = input<boolean>(false);
readonly inline = input<boolean>(false);
readonly class = input<string>('');
readonly dateFormat = input<string>('Y-m-d');
readonly placeholder = input<string>('');
readonly mode = input<SpkFlatpickrMode>('single');
readonly minDate = input<string | Date | undefined>(undefined);
readonly maxDate = input<string | Date | undefined>(undefined);
readonly readonly = input<boolean>(false);
@Output() focus = new EventEmitter<void>();
@Output() blur = new EventEmitter<void>();
value: any = null;
disabled: boolean = false;
fpInstance: any = null;
private viewMode: CalendarViewMode = 'calendar';
private viewContainerEl: HTMLElement | null = null;
private yearPageOffset = 0;
private pendingIgnoredElements: HTMLElement[] = [];
private scrollResizeHandler: () => void = () => this.repositionCalendar();
private onChange: (value: any) => void = () => { };
private onTouched: () => void = () => { };
readonly monthsList = [
{ index: 0, shortName: 'Jan', name: 'January' },
{ index: 1, shortName: 'Feb', name: 'February' },
{ index: 2, shortName: 'Mar', name: 'March' },
{ index: 3, shortName: 'Apr', name: 'April' },
{ index: 4, shortName: 'May', name: 'May' },
{ index: 5, shortName: 'Jun', name: 'June' },
{ index: 6, shortName: 'Jul', name: 'July' },
{ index: 7, shortName: 'Aug', name: 'August' },
{ index: 8, shortName: 'Sep', name: 'September' },
{ index: 9, shortName: 'Oct', name: 'October' },
{ index: 10, shortName: 'Nov', name: 'November' },
{ index: 11, shortName: 'Dec', name: 'December' },
];
ngOnDestroy(): void {
this.removePositionListeners();
if (this.viewContainerEl && this.viewContainerEl.parentNode) {
this.viewContainerEl.parentNode.removeChild(this.viewContainerEl);
}
this.viewContainerEl = null;
}
open(): void {
if (this.fpInstance && typeof this.fpInstance.open === 'function') {
this.fpInstance.open();
}
}
close(): void {
if (this.fpInstance && typeof this.fpInstance.close === 'function') {
this.fpInstance.close();
}
}
toggle(): void {
if (this.fpInstance) {
if (this.fpInstance.isOpen) {
this.close();
} else {
this.open();
}
}
}
clear(): void {
this.value = null;
if (this.fpInstance && typeof this.fpInstance.clear === 'function') {
this.fpInstance.clear();
}
}
registerIgnoredElement(el: HTMLElement): void {
if (!el) return;
if (this.fpInstance && this.fpInstance.config) {
if (!this.fpInstance.config.ignoredFocusElements) {
this.fpInstance.config.ignoredFocusElements = [];
}
if (!this.fpInstance.config.ignoredFocusElements.includes(el)) {
this.fpInstance.config.ignoredFocusElements.push(el);
}
} else {
if (!this.pendingIgnoredElements.includes(el)) {
this.pendingIgnoredElements.push(el);
}
}
}
writeValue(val: any): void {
const normalized = val ?? null;
if (this.isSameValue(this.value, normalized)) {
return;
}
if (this.fpInstance && this.fpInstance.isOpen && this.mode() === 'range' && this.fpInstance.selectedDates?.length === 1) {
this.value = normalized;
return;
}
this.value = normalized;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
onValueChange(newVal: any): void {
const normalized = newVal ?? null;
if (this.isSameValue(this.value, normalized)) {
return;
}
this.value = normalized;
this.onChange(this.value);
this.onTouched();
}
onFlatpickrChange(event: FlatPickrOutputOptions): void {
if (!event) return;
const rawValue = (event.dateString && event.dateString.trim())
? event.dateString
: (event.selectedDates && event.selectedDates.length > 0 ? event.selectedDates : null);
this.onValueChange(rawValue);
if (this.mode() === 'single' && event.selectedDates && event.selectedDates.length === 1) {
this.close();
} else if (this.mode() === 'range' && event.selectedDates && event.selectedDates.length === 2) {
this.close();
}
}
onInputFocus(): void {
this.focus.emit();
}
onInputBlur(): void {
this.blur.emit();
this.onTouched();
}
onReady(event: FlatPickrOutputOptions): void {
this.fpInstance = event.instance;
if (this.fpInstance && this.fpInstance.config) {
if (!this.fpInstance.config.ignoredFocusElements) {
this.fpInstance.config.ignoredFocusElements = [];
}
this.pendingIgnoredElements.forEach(el => {
if (!this.fpInstance.config.ignoredFocusElements.includes(el)) {
this.fpInstance.config.ignoredFocusElements.push(el);
}
});
this.pendingIgnoredElements = [];
}
this.initViewContainer();
this.ensureCustomHeader();
}
onOpen(): void {
this.setCalendarView('calendar');
this.ensureCustomHeader();
this.repositionCalendar();
this.attachPositionListeners();
}
onClose(): void {
this.setCalendarView('calendar');
this.removePositionListeners();
}
onMonthChange(): void {
this.ensureCustomHeader();
}
onYearChange(): void {
this.ensureCustomHeader();
}
repositionCalendar(): void {
if (!this.fpInstance || !this.fpInstance.calendarContainer || !this.fpInstance.isOpen) return;
const calendar = this.fpInstance.calendarContainer as HTMLElement;
const inputEl = (this.nativeInputRef?.nativeElement || this.fpInstance.element || this.fpInstance._input) as HTMLElement;
if (!calendar || !inputEl) return;
const rect = inputEl.getBoundingClientRect();
const calendarHeight = calendar.offsetHeight || 310;
const calendarWidth = calendar.offsetWidth || 300;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
const showAbove = spaceBelow < calendarHeight && spaceAbove >= calendarHeight;
let top: number;
if (showAbove) {
top = window.scrollY + rect.top - calendarHeight - 6;
calendar.classList.add('arrowBottom');
calendar.classList.remove('arrowTop');
} else {
top = window.scrollY + rect.bottom + 6;
calendar.classList.add('arrowTop');
calendar.classList.remove('arrowBottom');
}
let left = window.scrollX + rect.left;
if (left + calendarWidth > viewportWidth - 12) {
left = Math.max(12, window.scrollX + viewportWidth - calendarWidth - 12);
}
calendar.style.position = 'absolute';
calendar.style.top = `${Math.max(0, top)}px`;
calendar.style.left = `${Math.max(0, left)}px`;
calendar.style.right = 'auto';
calendar.style.zIndex = '99999';
}
private attachPositionListeners(): void {
window.addEventListener('resize', this.scrollResizeHandler, { passive: true });
window.addEventListener('scroll', this.scrollResizeHandler, { capture: true, passive: true });
}
private removePositionListeners(): void {
window.removeEventListener('resize', this.scrollResizeHandler);
window.removeEventListener('scroll', this.scrollResizeHandler, { capture: true });
}
private isSameValue(v1: any, v2: any): boolean {
if (v1 === v2) return true;
if (v1 == null && v2 == null) return true;
if (v1 == null || v2 == null) return false;
if (typeof v1 === 'string' && typeof v2 === 'string') return v1.trim() === v2.trim();
if (typeof v1 === 'object' && typeof v2 === 'object') {
if ('from' in v1 && 'from' in v2) {
return v1.from === v2.from && v1.to === v2.to;
}
}
return false;
}
private initViewContainer(): void {
if (!this.fpInstance || !this.fpInstance.calendarContainer) return;
const container = this.fpInstance.calendarContainer as HTMLElement;
if (container.querySelector('.spk-calendar-view-container')) {
this.viewContainerEl = container.querySelector('.spk-calendar-view-container');
return;
}
container.style.position = 'relative';
const viewContainer = document.createElement('div');
viewContainer.className = 'spk-calendar-view-container absolute left-1 right-1 top-[2.4rem] h-auto z-[1000] bg-white dark:bg-[#1e293b] p-1.5 rounded-lg border border-defaultborder dark:border-white/10 shadow-2xl flex flex-col animate-fade-in';
viewContainer.style.display = 'none';
container.appendChild(viewContainer);
this.viewContainerEl = viewContainer;
this.registerIgnoredElement(viewContainer);
const preventBubbling = (e: Event) => {
e.preventDefault();
e.stopPropagation();
};
viewContainer.addEventListener('mousedown', preventBubbling);
viewContainer.addEventListener('mouseup', preventBubbling);
viewContainer.addEventListener('click', (e: Event) => {
e.preventDefault();
e.stopPropagation();
this.handleContainerClick(e);
});
}
private ensureCustomHeader(): void {
if (!this.fpInstance || !this.fpInstance.calendarContainer) return;
const container = this.fpInstance.calendarContainer as HTMLElement;
const currentMonthContainer = container.querySelector('.flatpickr-current-month');
if (!currentMonthContainer) return;
const nativeElements = currentMonthContainer.querySelectorAll(
'.flatpickr-monthDropdown-months, .numInputWrapper, .cur-year, .cur-month'
);
nativeElements.forEach(el => {
(el as HTMLElement).style.display = 'none';
});
let pillsWrapper = currentMonthContainer.querySelector('.spk-header-pills-wrapper') as HTMLElement | null;
if (!pillsWrapper) {
pillsWrapper = document.createElement('div');
pillsWrapper.className = 'spk-header-pills-wrapper flex items-center justify-center gap-1.5';
const monthPill = document.createElement('button');
monthPill.type = 'button';
monthPill.className = 'spk-month-pill-btn px-2.5 py-1 text-xs font-semibold rounded-md bg-white dark:bg-[#1e293b] text-primary shadow-xs border border-primary/20 hover:border-primary/40 hover:bg-primary/5 transition-all flex items-center gap-1 cursor-pointer';
monthPill.setAttribute('aria-label', 'Select Month');
const yearPill = document.createElement('button');
yearPill.type = 'button';
yearPill.className = 'spk-year-pill-btn px-2.5 py-1 text-xs font-semibold rounded-md bg-white dark:bg-[#1e293b] text-primary shadow-xs border border-primary/20 hover:border-primary/40 hover:bg-primary/5 transition-all flex items-center gap-1 cursor-pointer';
yearPill.setAttribute('aria-label', 'Select Year');
pillsWrapper.appendChild(monthPill);
pillsWrapper.appendChild(yearPill);
currentMonthContainer.appendChild(pillsWrapper);
this.registerIgnoredElement(monthPill);
this.registerIgnoredElement(yearPill);
this.registerIgnoredElement(pillsWrapper);
const preventFocusLoss = (e: Event) => {
e.preventDefault();
e.stopPropagation();
};
const handleHeaderInteraction = (e: Event, targetView: CalendarViewMode) => {
e.preventDefault();
e.stopPropagation();
this.setCalendarView(this.viewMode === targetView ? 'calendar' : targetView);
};
monthPill.addEventListener('mousedown', preventFocusLoss);
monthPill.addEventListener('mouseup', preventFocusLoss);
monthPill.addEventListener('click', (e) => handleHeaderInteraction(e, 'month'));
yearPill.addEventListener('mousedown', preventFocusLoss);
yearPill.addEventListener('mouseup', preventFocusLoss);
yearPill.addEventListener('click', (e) => handleHeaderInteraction(e, 'year'));
}
const currentMonthIdx = this.fpInstance.currentMonth;
const currentYear = this.fpInstance.currentYear;
const monthName = this.monthsList[currentMonthIdx]?.name || 'Month';
const monthPillBtn = pillsWrapper.querySelector('.spk-month-pill-btn');
const yearPillBtn = pillsWrapper.querySelector('.spk-year-pill-btn');
if (monthPillBtn) {
monthPillBtn.innerHTML = `<span>${monthName}</span> <i class="ri-arrow-down-s-line text-sm opacity-70"></i>`;
}
if (yearPillBtn) {
yearPillBtn.innerHTML = `<span>${currentYear}</span> <i class="ri-arrow-down-s-line text-sm opacity-70"></i>`;
}
}
private setCalendarView(mode: CalendarViewMode): void {
this.viewMode = mode;
if (!this.viewContainerEl) return;
if (mode === 'calendar') {
this.viewContainerEl.style.display = 'none';
this.viewContainerEl.innerHTML = '';
return;
}
this.viewContainerEl.style.display = 'flex';
if (mode === 'month') {
this.renderMonthContent();
} else if (mode === 'year') {
this.yearPageOffset = 0;
this.renderYearContent();
}
}
private renderMonthContent(): void {
if (!this.viewContainerEl || !this.fpInstance) return;
const currentMonth = this.fpInstance.currentMonth;
const currentYear = this.fpInstance.currentYear;
let monthsHtml = '';
this.monthsList.forEach(m => {
const isCurrent = m.index === currentMonth;
const disabled = this.isMonthDisabled(m.index, currentYear);
const activeClass = isCurrent
? 'bg-primary text-white border-primary font-bold shadow-xs scale-[1.02]'
: 'bg-white dark:bg-[#1e293b] text-slate-700 dark:text-slate-200 border-slate-200/80 dark:border-white/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary';
const disabledClass = disabled ? 'opacity-30 cursor-not-allowed pointer-events-none' : 'cursor-pointer';
monthsHtml += `
<button type="button" class="spk-month-btn py-1 px-1 text-[0.75rem] rounded-md font-medium transition-all text-center border ${activeClass} ${disabledClass}" data-action="select-month" data-month="${m.index}">
${m.shortName}
</button>
`;
});
this.viewContainerEl.innerHTML = `
<div class="flex items-center justify-between px-0.5 pb-1 mb-1 border-b border-slate-200 dark:border-white/10">
<span class="text-[0.7rem] font-bold text-primary dark:text-primary-light uppercase tracking-wider ps-1">Select Month</span>
<button type="button" class="spk-close-btn p-0.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded-md cursor-pointer transition-colors" data-action="close">
<i class="ri-close-line text-xs pointer-events-none"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-1 p-1 bg-slate-50/70 dark:bg-white/5 rounded-lg border border-slate-200/60 dark:border-white/10">
${monthsHtml}
</div>
`;
}
private renderYearContent(): void {
if (!this.viewContainerEl || !this.fpInstance) return;
const currentYear = this.fpInstance.currentYear;
const baseYear = Math.floor(currentYear / 12) * 12 + (this.yearPageOffset * 12);
const startYear = baseYear;
const endYear = baseYear + 11;
let yearsHtml = '';
const minYr = this.getMinYear();
const maxYr = this.getMaxYear();
for (let y = startYear; y <= endYear; y++) {
const isCurrent = y === currentYear;
const disabled = (minYr !== null && y < minYr) || (maxYr !== null && y > maxYr);
const activeClass = isCurrent
? 'bg-primary text-white border-primary font-bold shadow-xs scale-[1.02]'
: 'bg-white dark:bg-[#1e293b] text-slate-700 dark:text-slate-200 border-slate-200/80 dark:border-white/10 hover:border-primary/50 hover:bg-primary/5 hover:text-primary';
const disabledClass = disabled ? 'opacity-30 cursor-not-allowed pointer-events-none' : 'cursor-pointer';
yearsHtml += `
<button type="button" class="spk-year-btn py-1 px-1 text-[0.75rem] rounded-md font-medium transition-all text-center border ${activeClass} ${disabledClass}" data-action="select-year" data-year="${y}">
${y}
</button>
`;
}
this.viewContainerEl.innerHTML = `
<div class="flex items-center justify-between px-0.5 pb-1 mb-1 border-b border-slate-200 dark:border-white/10">
<div class="flex items-center gap-1.5 mx-auto">
<button type="button" class="spk-prev-years-btn px-1.5 py-0.5 text-primary bg-primary/10 dark:bg-primary/20 hover:bg-primary hover:text-white rounded-md transition-all cursor-pointer flex items-center justify-center border border-primary/20" data-action="prev-year-page" title="Previous 12 years">
<i class="ri-arrow-left-s-line text-[0.65rem] font-bold pointer-events-none"></i>
</button>
<span class="text-[0.7rem] font-bold text-primary dark:text-primary-light uppercase tracking-wider px-1.5 py-0.5 rounded-md bg-primary/5 dark:bg-primary/10 border border-primary/15">${startYear} - ${endYear}</span>
<button type="button" class="spk-next-years-btn px-1.5 py-0.5 text-primary bg-primary/10 dark:bg-primary/20 hover:bg-primary hover:text-white rounded-md transition-all cursor-pointer flex items-center justify-center border border-primary/20" data-action="next-year-page" title="Next 12 years">
<i class="ri-arrow-right-s-line text-[0.65rem] font-bold pointer-events-none"></i>
</button>
</div>
<button type="button" class="spk-close-btn p-0.5 text-slate-400 hover:text-red-500 dark:hover:text-red-400 rounded-md cursor-pointer transition-colors" data-action="close">
<i class="ri-close-line text-xs pointer-events-none"></i>
</button>
</div>
<div class="grid grid-cols-3 gap-1 p-1 bg-slate-50/70 dark:bg-white/5 rounded-lg border border-slate-200/60 dark:border-white/10">
${yearsHtml}
</div>
`;
}
private handleContainerClick(e: Event): void {
const target = e.target as HTMLElement;
const actionEl = target.closest('[data-action]') as HTMLElement;
if (!actionEl) return;
const action = actionEl.getAttribute('data-action');
if (action === 'select-month') {
const monthIdx = parseInt(actionEl.getAttribute('data-month') || '0', 10);
this.fpInstance.changeMonth(monthIdx, false);
this.setCalendarView('calendar');
this.ensureCustomHeader();
} else if (action === 'select-year') {
const year = parseInt(actionEl.getAttribute('data-year') || '0', 10);
this.fpInstance.changeYear(year);
this.setCalendarView('calendar');
this.ensureCustomHeader();
} else if (action === 'prev-year-page') {
this.yearPageOffset--;
this.renderYearContent();
} else if (action === 'next-year-page') {
this.yearPageOffset++;
this.renderYearContent();
} else if (action === 'close') {
this.setCalendarView('calendar');
}
}
private isMonthDisabled(monthIndex: number, currentYear: number): boolean {
const minYr = this.getMinYear();
const maxYr = this.getMaxYear();
const minMo = this.getMinMonth();
const maxMo = this.getMaxMonth();
if (minYr !== null && currentYear < minYr) return true;
if (maxYr !== null && currentYear > maxYr) return true;
if (minYr !== null && currentYear === minYr && minMo !== null && monthIndex < minMo) return true;
if (maxYr !== null && currentYear === maxYr && maxMo !== null && maxMo < monthIndex) return true;
return false;
}
private getMinYear(): number | null {
const min = this.minDate();
if (!min) return null;
if (typeof min === 'string') return new Date(min).getFullYear();
if (min instanceof Date) return min.getFullYear();
return null;
}
private getMaxYear(): number | null {
const max = this.maxDate();
if (!max) return null;
if (typeof max === 'string') return new Date(max).getFullYear();
if (max instanceof Date) return max.getFullYear();
return null;
}
private getMinMonth(): number | null {
const min = this.minDate();
if (!min) return null;
if (typeof min === 'string') return new Date(min).getMonth();
if (min instanceof Date) return min.getMonth();
return null;
}
private getMaxMonth(): number | null {
const max = this.maxDate();
if (!max) return null;
if (typeof max === 'string') return new Date(max).getMonth();
if (max instanceof Date) return max.getMonth();
return null;
}
}