/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, CSSResultArray, TemplateResult, nothing } from 'lit'; import { customElement, state, property } from 'lit/decorators.js'; import { styles } from './nile-calendar.css'; import { classMap } from 'lit/directives/class-map.js'; import { query } from 'lit/decorators.js'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import { ClearMode } from './nile-calendar.enum'; import type { PropertyValueMap } from 'lit'; type NileCalendarDateRange={ startDate: string, endDate: string } type TimeUnits= 'minutes'|'hours'|'days'|'weeks'|'months'; /** * Nile icon component. * * @tag nile-calendar * */ @customElement('nile-calendar') export class NileCalendar extends NileElement { /** * The styles for NileCalendar * @remarks If you are extending this class you can extend the base styles with super. Eg `return [super(), myCustomStyles]` */ public static get styles(): CSSResultArray { return [styles]; } @property({ type: String, attribute: 'value' }) valueAttribute: | string | null = null; @property({ type: String, attribute:true, reflect:true }) allowedDates: string = JSON.stringify({}); @property({ type: Boolean, reflect: true }) range = false; @property({ type: Boolean, attribute: true, reflect:true }) hideTypes = false; @property({ type: Boolean, reflect: true }) doubleClickUnselect = false; @property({ type: Boolean, reflect: true }) allowClear = false; @property({ type: String, reflect: true }) type :'absolute' | 'relative' = 'absolute'; @property({ type: String }) selectedUnit: TimeUnits; @property({ type: Number }) selectedValue: number; @property({ type: Array, attribute: 'hide-duration-fields' }) hideDurationFields: String[] = []; @property({ type: Boolean}) showManualInputs :boolean=false; @property({ type: Number , attribute: true, reflect: true }) startYear: number; @property({ type: Number , attribute: true, reflect: true }) endYear: number; @property({ type: Boolean, attribute:true, reflect: true}) showMonthDropdown = false; @property({ type: Boolean, attribute:true, reflect: true}) showYearDropdown = false; @property({ type: String, reflect: true, attribute: true}) dateFormat: string = 'MM/DD/YYYY'; @state() startDate: Date | null = null; @state() endDate: Date | null = null; @state() isSelectingStart = true; @state() hideInput: Boolean = false; @state() value: Date | null; @state() currentMonth: number = new Date().getMonth(); @state() currentYear: number = new Date().getFullYear(); @state() allowedDatesLocal: NileCalendarDateRange | any = null; @property({ type: Boolean, attribute: true, reflect: true }) disabled = false; connectedCallback() { super.connectedCallback(); this.initializeValue(); this.emit('nile-init'); } disconnectedCallback() { super.disconnectedCallback(); this.emit('nile-destroy'); } protected updated(changedProperties: PropertyValueMap | Map): void { super.updated(changedProperties); if (changedProperties.has('valueAttribute')) { if(!this.valueAttribute){ this.value=null; this.startDate=null; this.endDate=null; this.isSelectingStart=true; } const date = new Date(this.valueAttribute || ''); if (!isNaN(date.getTime())) { const offset = date.getTimezoneOffset(); this.value = new Date(date.getTime() - offset * 60 * 1000); this.currentMonth = this.value.getMonth(); this.currentYear = this.value.getFullYear(); } this.initializeValue(); } } private get monthNames() { return Array.from({ length: 12 }, (_, i) => new Date(0, i).toLocaleString('default', { month: 'long' }) ); } private get yearOptions(): number[] { const fallbackStart = 2000; const fallbackEnd = 2050; const start = this.startYear ?? fallbackStart; const end = this.endYear ?? fallbackEnd; if (start > end) return []; return Array.from({ length: end - start + 1 }, (_, i) => start + i); } private onMonthSelected(monthIndex: number) { this.currentMonth = monthIndex; this.emit('nile-month-change', { month: monthIndex }); } private onYearSelected(year: number) { this.currentYear = year; this.emit('nile-year-change', { year }); } @watch('allowedDates') checkValidAllowedDate() { let newDateRange: NileCalendarDateRange | null; try { newDateRange=JSON.parse(this.allowedDates); } catch (error) { newDateRange=null; } if (!newDateRange || Object.keys(newDateRange).length == 0) { this.allowedDatesLocal=null; return; } this.hideInput=true; const startDate = this.getUTCDate(newDateRange.startDate) const endDate = this.getUTCDate(newDateRange.endDate) if (startDate > endDate) { console.error('StartDate must be greater than endDate'); } else { this.allowedDatesLocal=newDateRange; } } private isPrevDisabled(): boolean { return ( this.currentMonth === 0 && this.startYear !== undefined && this.currentYear <= this.startYear ); } private isNextDisabled(): boolean { return ( this.currentMonth === 11 && this.endYear !== undefined && this.currentYear >= this.endYear ); } private formatDate(date: Date | null): string { if (!date) return ''; const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); let formatted = this.dateFormat; formatted = formatted.replace(/YYYY/g, String(yyyy)); formatted = formatted.replace(/MM/g, mm); formatted = formatted.replace(/DD/g, dd); formatted = formatted.replace(/YY/g, String(yyyy).slice(-2)); return formatted; } private parseDate(input: string, existingDate?: Date | null): Date | null { if (!input || !this.dateFormat) return null; const formatPattern = this.dateFormat .replace(/YYYY/, '(?\\d{4})') .replace(/YY/, '(?\\d{2})') .replace(/MM/, '(?\\d{1,2})') .replace(/DD/, '(?\\d{1,2})'); const regex = new RegExp(`^${formatPattern}$`); const match = input.match(regex); if (!match || !match.groups) return null; const year = match.groups.year.length === 2 ? Number(`20${match.groups.year}`) : Number(match.groups.year); const month = Number(match.groups.month) - 1; const day = Number(match.groups.day); const date = new Date(year, month, day); if (existingDate) { date.setHours( existingDate.getHours(), existingDate.getMinutes(), existingDate.getSeconds(), ); } else { date.setHours(0, 0, 0); } return date; } private handleStartDateInput(event: CustomEvent): void { event.stopPropagation(); const newDate = this.parseDate(event.detail.value, this.startDate); if (newDate) { newDate.setHours(0, 0, 0); this.startDate = newDate; this.currentMonth = newDate.getMonth(); this.currentYear = newDate.getFullYear(); this.requestUpdate(); } } private handleEndDateInput(event: CustomEvent): void { event.stopPropagation(); const newDate = this.parseDate(event.detail.value, this.endDate); if (newDate) { newDate.setHours(23, 59, 59); this.endDate = newDate; this.currentMonth = newDate.getMonth(); this.currentYear = newDate.getFullYear(); this.requestUpdate(); } } /** * Render method */ render(): TemplateResult { return html`
Absolute Relative
${this.type == 'relative' ? this.renderRelativeCalendar():''} ${this.type == 'absolute' ? this.renderAbsoluteCalendar():''} ${!this.range?'':html`
${this.allowClear? html` Reset`:nothing } Apply
`}
`; } /** * @returns HTML content for absolute calendar */ renderAbsoluteCalendar(){ return html`
${this.renderMonth( this.currentYear, this.currentMonth, this.getDaysArray(this.currentYear, this.currentMonth) )}
${this.range ? html`
`:''} ` } /** * @returns HTML content for relative calendar */ renderRelativeCalendar() { return html`
${this.hideDurationFields?.includes('minutes') ? '' : html`
Minutes
${this.renderTimeValues('minutes', [1, 5, 15, 30, 45])}
`} ${this.hideDurationFields?.includes('hours') ? '' : html`
Hours
${this.renderTimeValues('hours', [1, 2, 3, 6, 8, 12])}
`} ${this.hideDurationFields?.includes('days') ? '' : html`
Days
${this.renderTimeValues('days', [1, 2, 3, 4, 5, 6])}
`} ${this.hideDurationFields?.includes('weeks') ? '' : html`
Weeks
${this.renderTimeValues('weeks', [1, 2, 4, 6])}
`} ${this.hideDurationFields?.includes('months') ? '' : html`
Months
${this.renderTimeValues('months', [3, 6, 12, 15])}
`}
${this.range ? html`
Minutes Hours Days Weeks Months
` : ''} `; } /** * * @param unit * @param values * @returns html for the option */ renderTimeValues(unit: TimeUnits, values: any[]) { return values.map( value => html`
this.handleTimeValueClick(unit, value, e)} @keydown="${(e: KeyboardEvent) => {if (e.key === 'Enter' || e.key === ' ') this.handleTimeValueClick(unit, value, e);}}" >${value}
` ); } /** * * @param year * @param month * @param daysArray * @returns HTML for rendered month */ private renderMonth( year: number, month: number, daysArray: number[] ): TemplateResult { const firstDay = new Date(year, month, 1).getDay(); const lastDay = new Date(year, month + 1, 0).getDay(); const prevMonthDays = this.getDaysArray( month === 0 ? year - 1 : year, month === 0 ? 11 : month - 1 ); const nextMonthDays = this.getDaysArray( month === 11 ? year + 1 : year, month === 11 ? 0 : month + 1 ); const fillerDaysBefore = prevMonthDays.slice( prevMonthDays.length - firstDay ); const fillerDaysAfter = nextMonthDays.slice(0, 6 - lastDay); const allDays = [...fillerDaysBefore, ...daysArray, ...fillerDaysAfter]; const isSelectedDate = ( day: number, month: number, year: number, isCurrentMonth: boolean ) => { if (!isCurrentMonth) return ''; if (!this.range && this.value) { const isSelected = day === this.value.getDate() && month === this.value.getMonth() && year === this.value.getFullYear(); if (isSelected) return 'selected-date'; } const isStartDate = this.startDate && day === this.startDate.getDate() && month === this.startDate.getMonth() && year === this.startDate.getFullYear(); const isEndDate = this.endDate && day === this.endDate.getDate() && month === this.endDate.getMonth() && year === this.endDate.getFullYear(); return isStartDate ? 'range-start' : isEndDate ? 'range-end' : ''; }; const isInRange = ( day: number, month: number, year: number, isCurrentMonth: boolean ) => { if (!isCurrentMonth) return false; if (this.startDate && this.endDate) { const date = new Date(year, month, day); return date >= this.startDate && date <= this.endDate; } return false; }; const isCurrentDate = (day: number, month: number, year: number) => { const today = new Date(); return ( day === today.getDate() && month + 1 === today.getMonth() + 1 && year === today.getFullYear() ); }; const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; return html`
${this.showMonthDropdown || this.showYearDropdown ? html` ${this.showMonthDropdown ? html` ${new Date(year, month).toLocaleString('default', { month: 'long' })}
${this.monthNames.map((m, idx) => html` ${m} `)}
` : html` ${new Date(year, month).toLocaleString('default', { month: 'long' })} `} ${this.showYearDropdown ? html` ${year}
${this.yearOptions.map( y => html` ${y} ` )}
` : html` ${year} `} ` : html` ${new Date(year, month).toLocaleString('default', { month: 'long', })} ${year} `}
${dayNames.map(day => html`
${day}
`)} ${allDays.map((day, index) => { const isCurrentMonth = index >= fillerDaysBefore.length && index < fillerDaysBefore.length + daysArray.length; const datePlacement:''|'selected-date'|'range-start'|'range-end' = isSelectedDate(day, month, year, isCurrentMonth) const classMapObj:any={ 'day_date' : true, 'not-allowed' : !this.isAllowedDate(day, month, year), 'in-range' : isInRange(day, month, year, isCurrentMonth) , 'filler' : !isCurrentMonth, } if(datePlacement) classMapObj[datePlacement]=true return html`
${day} ${isCurrentDate(day, month, year) && isCurrentMonth?html`
`:nothing}
`; })}
`; } /** * Function to validate if date is not in allowedDates range * @param day * @param month * @param year * @returns */ isAllowedDate(day: number, month: number, year: number) { if (!this.allowedDatesLocal) { return true; } const dateToCheck = new Date(Date.UTC(year, month, day)); const startDate = this.getUTCDate(this.allowedDatesLocal.startDate) const endDate = this.getUTCDate(this.allowedDatesLocal.endDate) endDate.setUTCHours(23, 59, 59, 999); const isWithinRange = dateToCheck >= startDate && dateToCheck <= endDate; return isWithinRange; } clearDate(mode?: ClearMode) { const clearType = mode ?? (this.range ? this.type === 'relative' ? ClearMode.Relative : ClearMode.Range : ClearMode.Single); if (clearType === ClearMode.Single) { this.value = null; } else { this.valueAttribute = ''; this.startDate = null; this.endDate = null; this.selectedUnit = undefined!; this.selectedValue = undefined!; } this.requestUpdate(); this.emit('nile-clear'); } /** * @function handle_date-click/select * @param day * @param month * @param year */ selectDate(day: number, month: number, year: number): void { if (this.disabled) return; const selectedDate = new Date(year, month, day); if (this.range) { if (this.startDate && this.endDate) { this.startDate = null; this.endDate = null; } if (this.isSelectingStart) { this.startDate = selectedDate; if (this.endDate && selectedDate > this.endDate) { this.endDate = null; } this.isSelectingStart = false; } else { this.isSelectingStart = true; if (this.startDate && selectedDate < this.startDate) { this.startDate = selectedDate; this.endDate = null; this.isSelectingStart = false; } else { const endDate = selectedDate; endDate.setHours(23, 59, 59, 999); this.endDate = endDate; } } } else { if(String(this.value)==String(selectedDate) && this.doubleClickUnselect){ this.clearDate() return } this.value = selectedDate; this.emitChangedData({ value: this.value }); } } /** * Function to be called on initialization to set all other properties */ initializeValue() { if (this.range) { try { const rangeValue = JSON.parse(this.valueAttribute || ''); this.startDate = new Date(rangeValue.startDate); this.endDate = new Date(rangeValue.endDate); // Convert to local time this.startDate = new Date(this.startDate.getTime()); this.endDate = new Date(this.endDate.getTime()); this.value = null; } catch (e) { // console.error('Invalid range value'); } } else { if (this.valueAttribute) { let date: Date = new Date(this.valueAttribute); date = new Date(date.getTime() - date.getTimezoneOffset() * 60000); if (!isNaN(date.getTime())) { this.value = date; this.currentMonth = this.value.getMonth(); this.currentYear = this.value.getFullYear(); } } } } /** * Function to handle relative date selection */ handleDurationChange(event: CustomEvent) { event.stopPropagation() event.detail.value = event.detail.value.replace(/e/gi, ""); const duration=Number(event.detail.value); if(!duration) { return }; this.selectedValue = duration if (this.selectedUnit && this.selectedValue) { this.handleTimeValueClick(this.selectedUnit, this.selectedValue, event); } } handleUnitChange(event: CustomEvent) { event.stopPropagation() this.selectedUnit = event.detail.value; if (this.selectedUnit && this.selectedValue) { this.handleTimeValueClick(this.selectedUnit, this.selectedValue, event); } } handleTimeValueClick(unit: TimeUnits, value: number, event: any) { this.createRelativePeriod(unit, value) this.selectedUnit = unit; this.selectedValue = value; } createRelativePeriod(unit: String, value: number) { const endTime = new Date(); const startTime = new Date(); switch (unit) { case 'minutes': startTime.setMinutes(startTime.getMinutes() - value); break; case 'hours': startTime.setHours(startTime.getHours() - value); break; case 'days': startTime.setDate(startTime.getDate() - value); break; case 'weeks': startTime.setDate(startTime.getDate() - 7 * value); // Subtract weeks as days break; case 'months': startTime.setMonth(startTime.getMonth() - value); break; } this.startDate = new Date(startTime.getTime()); this.endDate = new Date(endTime.getTime()); return { startDate: this.startDate, endDate: this.endDate, }; } /** * Function to handle start time selecion */ private handleStartTimeInput(event: CustomEvent): void { event.stopPropagation() if (!this.startDate) { this.startDate = null; return; } const time = this.parseTime(event.detail.value, this.startDate); if (time) { this.startDate = time; } else { this.startDate.setHours(0, 0, 0); } this.requestUpdate(); } /** * Function to handle end time selecion */ private handleEndTimeInput(event: CustomEvent): void { event.stopPropagation() if (!this.endDate) { this.endDate = null; return; } const time = this.parseTime(event.detail.value, this.endDate); if (time) { this.endDate = time; } else { this.endDate.setHours(0, 0, 0); } this.requestUpdate(); } // Parse time string to a Date object private parseTime(input: string, date: Date): Date | null { if (!this.isValidTimeInput(input)) { return null; } const [hour, minute, second] = input.split(':').map(Number); const newDate = new Date(date.getTime()); newDate.setHours(hour, minute, second); return newDate; } // Validate time in HH:MM:SS format private isValidTimeInput(input: string): boolean { const regex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/; return regex.test(input); } private formatTime(date: Date | null): string { if (!date) return ''; const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; } private nextMonth(): void { if (this.disabled) return; if (this.currentMonth === 11) { const newYear = this.currentYear + 1; if(this.endYear !== undefined && newYear > this.endYear) return; this.currentMonth = 0; this.currentYear = newYear; } else { this.currentMonth++; } } private prevMonth(): void { if (this.disabled) return; if (this.currentMonth === 0) { const newYear = this.currentYear - 1; if(this.startYear !== undefined && newYear < this.startYear) return; this.currentMonth = 11; this.currentYear = newYear; } else { this.currentMonth--; } } private confimRange() { if (!(this.startDate && this.endDate)) return; this.emitChangedData( { startDate: this.startDate, endDate: this.endDate, }); } onTypeChange(event: CustomEvent) { if (this.disabled) return; this.type = event.detail.value; this.emit('nile-type-change', { value: this.type }) } getUTCDate(dateStr:any){ return new Date( Date.UTC( dateStr.slice(0, 4), dateStr.slice(5, 7) - 1, dateStr.slice(8, 10) ) ) } emitChangedData(data:{startDate:Date,endDate:Date}|{value:any}|null){ this.emit('nile-changed',data); // deprecated. Use nile-change instead this.emit('nile-change',data) } private getDaysArray(year: number, month: number): number[] { const daysInMonth = new Date(year, month + 1, 0).getDate(); return Array.from({ length: daysInMonth }, (_, i) => i + 1); } } export default NileCalendar; declare global { interface HTMLElementTagNameMap { 'nile-calendar': NileCalendar; } }