662 lines
24 KiB
TypeScript
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;
|
|
}
|
|
}
|