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; readonly id = input(''); readonly altInput = input(false); readonly convertModelValue = input(false); readonly enableTime = input(false); readonly noCalendar = input(false); readonly inline = input(false); readonly class = input(''); readonly dateFormat = input('Y-m-d'); readonly placeholder = input(''); readonly mode = input('single'); readonly minDate = input(undefined); readonly maxDate = input(undefined); readonly readonly = input(false); @Output() focus = new EventEmitter(); @Output() blur = new EventEmitter(); 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 = `${monthName} `; } if (yearPillBtn) { yearPillBtn.innerHTML = `${currentYear} `; } } 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 += ` `; }); this.viewContainerEl.innerHTML = `
Select Month
${monthsHtml}
`; } 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 += ` `; } this.viewContainerEl.innerHTML = `
${startYear} - ${endYear}
${yearsHtml}
`; } 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; } }