/** * 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 { TIMEZONES, isValidTimezone, isClockTime, utcFromFields, localDateFromFields, formatYear4, DEFAULT_START_YEAR, DEFAULT_END_YEAR } from './timezones'; import { setAnimation } from '../utilities/animation-registry'; import type { PropertyValueMap } from 'lit'; type NileCalendarDateRange={ startDate: string, endDate: string } type TimeUnits= 'minutes'|'hours'|'days'|'weeks'|'months'; /** A wall clock — digits on a clock face. Local time*/ export type CalendarWallClock = { year: number; /** 1–12 (January = 1), NOT the 0–11 month of JavaScript `Date`; subtract 1 before `new Date(...)`. */ month: number; day: number; hours: number; minutes: number; seconds: number; ms: number; }; /* one exact moment on the timeline Eg: 2026-03-01T10:00:00Z, …+05:30 */ const INSTANT_VALUE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})$/; /** an instant plus the zone it was picked in. Eg: 2026-03-01T10:00:00.000[GMT+05:30 Asia/Kolkata] */ const BRACKETED_VALUE_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?\[GMT([+-])(\d{2}):(\d{2})\s+([^\]]+)\]$/; export function instantFromValueString(raw: string | null | undefined): number | null { if (typeof raw !== 'string') return null; const trimmed = raw.trim(); if (!INSTANT_VALUE_RE.test(trimmed)) return null; const ms = new Date(trimmed).getTime(); return isNaN(ms) ? null : ms; } /** digits on a clock face, 2026-03-01T10:00:00 */ const OFFSETLESS_VALUE_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?$/; /** Digits as written, with no zone conversion. */ export function wallClockFromOffsetLess( raw: string | null | undefined ): CalendarWallClock | null { if (typeof raw !== 'string') return null; const m = OFFSETLESS_VALUE_RE.exec(raw.trim()); if (!m) return null; const year = Number(m[1]); const month0 = Number(m[2]) - 1; const day = Number(m[3]); if (month0 < 0 || month0 > 11 || day < 1) return null; if (!isClockTime(Number(m[4]), Number(m[5]), Number(m[6] ?? 0), Number(m[7] ?? 0))) return null; const lastDay = new Date(utcFromFields(year, month0 + 1, 0)).getUTCDate(); const t = utcFromFields( year, month0, Math.min(day, lastDay), Number(m[4]), Number(m[5]), Number(m[6] ?? 0), Number(m[7] ?? 0) ); if (isNaN(t)) return null; const u = new Date(t); return { year: u.getUTCFullYear(), month: u.getUTCMonth() + 1, day: u.getUTCDate(), hours: u.getUTCHours(), minutes: u.getUTCMinutes(), seconds: u.getUTCSeconds(), ms: u.getUTCMilliseconds(), }; } export function wallClockInZone(epochMs: number, zone: string): CalendarWallClock { const formatter = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); const parts: Record = {}; for (const part of formatter.formatToParts(new Date(epochMs))) { if (part.type !== 'literal') parts[part.type] = Number(part.value); } return { year: parts.year, month: parts.month, day: parts.day, hours: parts.hour === 24 ? 0 : parts.hour, minutes: parts.minute, seconds: parts.second, ms: ((epochMs % 1000) + 1000) % 1000, }; } export function instantFromBracketedValue( raw: string | null | undefined ): { epochMs: number; timezone: string } | null { if (typeof raw !== 'string') return null; const m = BRACKETED_VALUE_RE.exec(raw.trim()); if (!m) return null; const [, y, mo, d, h, mi, s, ms, sign, oh, om, rawZone] = m; const zone = rawZone.trim(); if (!isValidTimezone(zone)) return null; const wall = { year: Number(y), month: Number(mo), day: Number(d), hours: Number(h), minutes: Number(mi), seconds: Number(s), ms: Number(ms ?? 0), }; // ISO end-of-day: exactly 24:00:00(.000) is the next day's 00:00:00; any other hour 24 or above is not a time const endOfDay = wall.hours === 24; if (wall.hours > 24 || (endOfDay && (wall.minutes !== 0 || wall.seconds !== 0 || wall.ms !== 0))) return null; const offsetMs = (sign === '-' ? -1 : 1) * (Number(oh) * 3600000 + Number(om) * 60000); const wallMs = utcFromFields(wall.year, wall.month - 1, wall.day, wall.hours, wall.minutes, wall.seconds, wall.ms); const candidate = wallMs - offsetMs; const readBack = wallClockInZone(candidate, zone); // the digits the zone must read back: the written ones, or the rolled-over next day for 24:00:00 const expected = endOfDay ? wallClockInZone(wallMs, 'UTC') : wall; const digitsAgree = readBack.year === expected.year && readBack.month === expected.month && readBack.day === expected.day && readBack.hours === expected.hours && readBack.minutes === expected.minutes && readBack.seconds === expected.seconds && readBack.ms === expected.ms; if (!digitsAgree) return null; return { epochMs: candidate, timezone: zone }; } /** * Nile icon component. * * @tag nile-calendar * * @csspart calendar-container - The outer container. * @csspart calendar-switcher - The Absolute / Relative tab switcher. * @csspart button-container - The default variant's button row. * @csspart calendar-wrapper - Wraps the month grid. * @csspart trigger - The month and year dropdown triggers. * @csspart calendar-day-N - A day cell, where N is the day of the month. * @csspart current-date-dot - The marker on today's date. * * Parts that exist only in `variant="date-time-picker"`: * @csspart calendar-config - The row holding the type switcher. * @csspart calendar - One month's header and grid. * @csspart calendar-header - The month header row. * @csspart calendar-header-prev - The previous-month icon. * @csspart calendar-header-next - The next-month icon. * @csspart calendar-header-center - The month and year area. * @csspart days-container - The grid of day cells. * @csspart day-name - A weekday label (Sun, Mon, ...). * @csspart calendar-day-label - The number inside a day cell. * @csspart tp-chevron - The expand / collapse control. * @csspart tp-scroll-area - The scrolling area under the grid. * @csspart tp-scroll-content - The content inside the scrolling area. * @csspart tp-time-fields - The row of time inputs. * @csspart tp-time-field - One time input (hours, minutes, seconds, milliseconds). * @csspart tp-stepper - The up/down stepper inside a time input. * @csspart tp-stepper-btn - Either stepper button. * @csspart tp-stepper-btn-up - The increase button. * @csspart tp-stepper-btn-down - The decrease button. * @csspart tp-timezone - The timezone section. * @csspart tp-timezone-header - The timezone label row. * @csspart tp-timezone-label - The "Timezone" label. * @csspart tp-timezone-local-link - The "Use local timezone" link. * @csspart tp-timezone-select - The timezone select. * @csspart tp-footer - The footer. * @csspart tp-footer-actions - The Reset and Apply group. * @csspart tp-set-to-now-button - The "Set to now" button. * @csspart clear-button - The Reset button. * @csspart apply-button - The Apply button. */ @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, attribute: true, reflect: true }) showTypes = 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'; @property({ type: Boolean, reflect: true }) structuredInput = false; @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; @property({ type: String, attribute: true }) variant: 'default' | 'date-time-picker' = 'default'; @property({ type: String, attribute: true }) timezone: string = 'UTC'; /** the timezone the consumer configured; Reset restores this (internal changes never overwrite it) */ private configuredTimezone = 'UTC'; /** the zone the component itself is assigning, so updated() can tell it from a consumer change */ private internalTimezone: string | null = null; private setTimezoneInternally(tz: string): void { if (tz !== this.timezone) this.internalTimezone = tz; this.timezone = tz; } @property({ type: Array, attribute: 'hide-time-fields' }) hideTimeFields: string[] = []; /** every field shown, minus the keys in hideTimeFields (a non-array value counts as an empty list) */ private get resolvedTimeFields(): Record { const merged: Record = { hours: true, minutes: true, seconds: true, milliseconds: true, timezone: true, }; const hidden = Array.isArray(this.hideTimeFields) ? this.hideTimeFields : []; for (const key of hidden) merged[key] = false; return merged; } private get tpFewFieldsNoTz(): boolean { const f = this.resolvedTimeFields; if (f.timezone !== false) return false; const timeCount = ['hours', 'minutes', 'seconds', 'milliseconds'].filter( k => f[k] !== false ).length; return timeCount <= 2; } @state() expanded = false; @state() tpLockedHeight: string | null = null; @state() tpHours: number | null = null; @state() tpMinutes: number | null = null; @state() tpSeconds: number | null = null; @state() tpMilliseconds: number | null = null; @state() tpFocusedTimeField: string | null = null; /** the day the keyboard last moved to; owns the grid's single tab stop (roving tabindex) */ @state() tpFocusDate: { year: number; month: number; day: number } | null = null; @state() tpEditingText = ''; private seedInstant: number | null = null; private seedFieldsKey: string | null = null; private seedZone: string | null = null; connectedCallback() { super.connectedCallback(); // before initializeValue: a zoned/bracketed value seeds the zone internally and must not become the configured one if (!this.hasUpdated) this.configuredTimezone = this.timezone; this.initializeValue(); // firstUpdated runs once; a re-attach (DOM move) must restore the structuredInput listeners disconnectedCallback removed (no-op unless structuredInput) if (this.hasUpdated) this.bindStructuredInput(); // disconnectedCallback dropped the observer; an already-rendered picker panel needs it back (updated() won't run on a bare re-attach) if (this.hasUpdated && this.variant === 'date-time-picker') this.observeCollapsedBase(); this.emit('nile-init'); } disconnectedCallback() { super.disconnectedCallback(); this.releasePanelObserver(); this.unbindStructuredInput(); this.emit('nile-destroy'); } protected firstUpdated(): void { this.bindStructuredInput(); } protected updated(changedProperties: PropertyValueMap | Map): void { super.updated(changedProperties); if (changedProperties.has('timezone')) { if (this.timezone !== this.internalTimezone) this.configuredTimezone = this.timezone; this.internalTimezone = null; } if ( changedProperties.has('timezone') && !changedProperties.has('valueAttribute') && this.seedInstant !== null ) { if (this.seedZone !== null) { if (this.timezone !== this.seedZone) this.clearInstantSeed(); } else if (this.seedFieldsKey === this.instantSeedFieldsKey()) { if (isValidTimezone(this.timezone)) { this.seedFromInstant(this.seedInstant, this.timezone); } else { this.clearInstantSeed(); this.initializeValue(); } } else { this.clearInstantSeed(); } } if ( changedProperties.has('structuredInput') || changedProperties.has('variant') || changedProperties.has('range') || changedProperties.has('showManualInputs') || changedProperties.has('type') || changedProperties.has('dateFormat') ) { this.bindStructuredInput(); } if (changedProperties.has('valueAttribute')) { if(!this.valueAttribute){ this.value=null; this.startDate=null; this.endDate=null; this.isSelectingStart=true; if (this.variant === 'date-time-picker') this.resetTimeUnits(); } const date = this.dateFromValueString(this.valueAttribute || ''); if (!isNaN(date.getTime())) { this.value = date; this.currentMonth = this.value.getMonth(); this.currentYear = this.value.getFullYear(); } this.initializeValue(); } if (this.variant === 'date-time-picker') { this.applyMonthYearDropdownAnimation(); this.captureCollapsedHeight(); } else { this.releasePanelObserver(); // the default variant has no picker panel to watch (a variant switch removed it) } } private tpResizeObserver?: ResizeObserver; /** the `.base--date-time-picker` element the observer currently watches */ private tpObservedBase: HTMLElement | null = null; private releasePanelObserver(): void { this.tpResizeObserver?.disconnect(); this.tpResizeObserver = undefined; this.tpObservedBase = null; } /** (re)points the observer at the rendered panel; a no-op while it already watches that element */ private observeCollapsedBase(): HTMLElement | null { const base = this.shadowRoot?.querySelector( '.base--date-time-picker' ) as HTMLElement | null; if (!base) { this.releasePanelObserver(); return null; } if (this.tpObservedBase !== base) { this.tpResizeObserver?.disconnect(); this.tpResizeObserver = new ResizeObserver(() => this.measurePanel(base)); this.tpResizeObserver.observe(base); this.tpObservedBase = base; } return base; } private captureCollapsedHeight(): void { const base = this.observeCollapsedBase(); if (base) this.measurePanel(base); } private measurePanel(base: HTMLElement): void { if (this.expanded) return; const h = `${base.offsetHeight}px`; if (h !== this.tpLockedHeight) this.tpLockedHeight = h; } private applyMonthYearDropdownAnimation(): void { const apply = (root: ShadowRoot | null | undefined) => { root?.querySelectorAll('.month-dropdown, .year-dropdown').forEach(dd => { setAnimation(dd, 'dropdown.show', { keyframes: [ { opacity: 0, scale: 0.9, translate: '0 -14px' }, { opacity: 1, scale: 1, translate: '0 0' }, ], options: { duration: 280, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, }); setAnimation(dd, 'dropdown.hide', { keyframes: [ { opacity: 1, scale: 1, translate: '0 0' }, { opacity: 0, scale: 0.9, translate: '0 -14px' }, ], options: { duration: 160, easing: 'ease' }, }); }); }; apply(this.shadowRoot); const inner = this.shadowRoot?.querySelector( '.tp-inner-calendar' ) as (HTMLElement & { updateComplete?: Promise; shadowRoot: ShadowRoot | null }) | null; if (inner) { const innerApply = () => apply(inner.shadowRoot); if (inner.updateComplete) inner.updateComplete.then(innerApply); else innerApply(); } } private get monthNames() { return Array.from({ length: 12 }, (_, i) => new Date(0, i).toLocaleString('default', { month: 'long' }) ); } private get yearOptions(): number[] { const start = this.startYear ?? DEFAULT_START_YEAR; const end = this.endYear ?? DEFAULT_END_YEAR; 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 }); } /** brings the currently-selected month/year option into view when a dropdown opens */ private scrollSelectedIntoView(selector: string) { const selected = this.shadowRoot?.querySelector(selector) as HTMLElement | null; if (!selected) return; const container = selected.closest( '.month-scroll-wrapper, .year-scroll-wrapper' ) as HTMLElement | null; if (!container) { selected.scrollIntoView({ block: 'center' }); return; } const target = container.scrollTop + (selected.getBoundingClientRect().top - container.getBoundingClientRect().top) - (container.clientHeight - selected.clientHeight) / 2; const prefersReducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; if (prefersReducedMotion) { container.scrollTop = target; return; } this.smoothScrollTo(container, target, 450); } private smoothScrollTo(el: HTMLElement, to: number, duration: number) { const start = el.scrollTop; const change = to - start; if (change === 0) return; const startTime = performance.now(); const ease = (t: number) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; const step = (now: number) => { const t = Math.min(1, (now - startTime) / duration); el.scrollTop = start + change * ease(t); if (t < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); } @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; } } /** * The year range month navigation enforces. An explicit `startYear` / `endYear` always wins; the date-time-picker * variant falls back to the dropdown's 2000-2050 for an unset side, while the default calendar leaves it open as published. */ private get minYear(): number | undefined { return this.variant === 'date-time-picker' ? this.startYear ?? DEFAULT_START_YEAR : this.startYear; } private get maxYear(): number | undefined { return this.variant === 'date-time-picker' ? this.endYear ?? DEFAULT_END_YEAR : this.endYear; } private isPrevDisabled(): boolean { return ( this.currentMonth === 0 && this.minYear !== undefined && this.currentYear <= this.minYear ); } private isNextDisabled(): boolean { return ( this.currentMonth === 11 && this.maxYear !== undefined && this.currentYear >= this.maxYear ); } 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; // the date-time-picker variant pads years 0-99 to four digits; the default calendar prints the year as published formatted = formatted.replace(/YYYY/g, this.variant === 'date-time-picker' ? formatYear4(yyyy) : 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 dtp = this.variant === 'date-time-picker'; const typedYear = match.groups.year; let year = typedYear.length === 2 ? Number(`20${typedYear}`) : Number(typedYear); // date-time-picker only: an existing bound keeps its century while the visible two digits are unchanged; with no existing bound a typed YY is 2000-2099 (the default calendar reads it as published) if (dtp && typedYear.length === 2 && existingDate && Math.abs(existingDate.getFullYear()) % 100 === Number(typedYear)) { year = existingDate.getFullYear(); } const month = Number(match.groups.month) - 1; const day = Number(match.groups.day); // new Date(y, ...) remaps years 0-99 to 1900-1999 as published; only the variant keeps the literal year const date = dtp ? localDateFromFields(year, month, day) : 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(); } } private maskValuesMap = new WeakMap(); private boundMaskInputs: Element[] = []; private unbindStructuredInput(): void { for (const inp of this.boundMaskInputs) { inp.removeEventListener('keydown', this.onMaskKeydown as EventListener, true); inp.removeEventListener('focusout', this.onMaskBlur as EventListener); } this.boundMaskInputs = []; } private bindStructuredInput(): void { this.unbindStructuredInput(); if (!this.structuredInput) return; const inputs = this.shadowRoot?.querySelectorAll('.calender-input nile-input'); inputs?.forEach(inp => { inp.addEventListener('keydown', this.onMaskKeydown as EventListener, true); inp.addEventListener('focusout', this.onMaskBlur as EventListener); this.boundMaskInputs.push(inp); }); } private dateMaskSegments(): Array<{ kind: string; len: number; min: number; max: number; after: string }> { const spec = (token: string): { kind: string; len: number; min: number; max: number } => { switch (token) { case 'YYYY': return { kind: 'year', len: 4, min: 0, max: 9999 }; case 'YY': return { kind: 'year', len: 2, min: 0, max: 99 }; case 'MM': return { kind: 'month', len: 2, min: 1, max: 12 }; default: return { kind: 'day', len: 2, min: 1, max: 31 }; // DD } }; const fmt = this.dateFormat || 'MM/DD/YYYY'; const re = /YYYY|YY|MM|DD/g; const found: Array<{ token: string; index: number }> = []; let m: RegExpExecArray | null; while ((m = re.exec(fmt)) !== null) found.push({ token: m[0], index: m.index }); return found.map((f, i) => { const sep = fmt.slice( f.index + f.token.length, i + 1 < found.length ? found[i + 1].index : fmt.length ); return { ...spec(f.token), after: i < found.length - 1 ? sep : '' }; }); } private timeMaskSegments(): Array<{ kind: string; len: number; min: number; max: number; after: string }> { return [ { kind: 'hour', len: 2, min: 0, max: 23, after: ':' }, { kind: 'minute', len: 2, min: 0, max: 59, after: ':' }, { kind: 'second', len: 2, min: 0, max: 59, after: '' }, ]; } private buildMaskLayout( segs: Array<{ kind: string; len: number; min: number; max: number; after: string }> ) { const tokens: Array<{ slot: number } | { lit: string }> = []; const slots: Array<{ kind: string; min: number; max: number; len: number; segStart: number }> = []; for (const seg of segs) { const segStart = slots.length; for (let k = 0; k < seg.len; k++) { tokens.push({ slot: slots.length }); slots.push({ kind: seg.kind, min: seg.min, max: seg.max, len: seg.len, segStart }); } for (const ch of seg.after) tokens.push({ lit: ch }); } return { tokens, slots, length: tokens.length }; } private maskLayoutFor(kind: 'date' | 'time') { return this.buildMaskLayout(kind === 'time' ? this.timeMaskSegments() : this.dateMaskSegments()); } private computeMaskDisplay(values: string[], layout: ReturnType) { let lastFilled = -1; values.forEach((v, i) => { if (v !== '') lastFilled = i; }); let s = ''; const slotPos = new Array(layout.slots.length).fill(-1); for (const t of layout.tokens) { if ('slot' in t) { if (t.slot > lastFilled) break; // stop at the trailing empties slotPos[t.slot] = s.length; if (values[t.slot]) s += values[t.slot]; } else { s += t.lit; } } return { display: lastFilled === -1 ? '' : s, slotPos, lastFilled }; } private initMaskValues(text: string, layout: ReturnType): string[] { const digits = (text || '').replace(/\D/g, '').slice(0, layout.slots.length); return layout.slots.map((_s, i) => digits[i] ?? ''); } /** clamp a filled segment into range (year is left unclamped) */ private clampMaskSegment( values: string[], layout: ReturnType, slotIndex: number ): void { const seg = layout.slots[slotIndex]; if (seg.kind === 'year') return; const segSlots: number[] = []; layout.slots.forEach((s, i) => { if (s.segStart === seg.segStart) segSlots.push(i); }); const chars = segSlots.map(i => values[i]); if (chars.some(c => c === '')) return; // segment not full yet const joined = chars.join(''); const n = Number(joined); let corrected: string | null = null; if (n > seg.max) corrected = String(Number(joined[0])).padStart(seg.len, '0'); else if (n < seg.min) corrected = String(seg.min).padStart(seg.len, '0'); if (corrected !== null) segSlots.forEach((s, k) => { values[s] = corrected![k]; }); } private maskValuesFor( native: HTMLInputElement, layout: ReturnType ): string[] { let values = this.maskValuesMap.get(native); if (!values || values.length !== layout.slots.length) { values = this.initMaskValues(native.value, layout); } else if (this.computeMaskDisplay(values, layout).display !== native.value) { values = this.initMaskValues(native.value, layout); } this.maskValuesMap.set(native, values); return values; } private onMaskKeydown = (e: KeyboardEvent): void => { if (this.disabled || e.ctrlKey || e.metaKey || e.altKey) return; const nav = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Tab', 'Enter', 'Shift', 'Control', 'Meta', 'Alt', 'CapsLock']; if (nav.includes(e.key)) return; const nileInput = e.currentTarget as (HTMLElement & { input?: HTMLInputElement; value?: string }) | null; const native = nileInput?.input; if (!nileInput || !native) return; const kind: 'date' | 'time' = nileInput.classList.contains('time-input') ? 'time' : 'date'; const layout = this.maskLayoutFor(kind); const values = this.maskValuesFor(native, layout); const { slotPos } = this.computeMaskDisplay(values, layout); const selStart = native.selectionStart ?? 0; const selEnd = native.selectionEnd ?? selStart; const writeMask = (display: string, caret: number) => { native.value = display; nileInput.value = display; try { native.setSelectionRange(caret, caret); } catch { /* noop */ } }; const caretAfter = (target: number) => { const after = this.computeMaskDisplay(values, layout); const np = after.slotPos[target + 1]; if (np !== undefined && np !== -1) return np; return after.display.length; }; if (e.key >= '0' && e.key <= '9') { e.preventDefault(); let target = slotPos.findIndex(p => p !== -1 && p >= selStart); if (target === -1) target = values.findIndex(v => v === ''); // append at the first gap if (target === -1) return; // every slot full and caret past the end values[target] = e.key; this.clampMaskSegment(values, layout, target); writeMask(this.computeMaskDisplay(values, layout).display, caretAfter(target)); return; } if (e.key === 'Backspace') { e.preventDefault(); if (selStart !== selEnd) { for (let i = 0; i < values.length; i++) { if (slotPos[i] !== -1 && slotPos[i] >= selStart && slotPos[i] < selEnd) values[i] = ''; } writeMask(this.computeMaskDisplay(values, layout).display, selStart); return; } let target = -1; for (let i = 0; i < values.length; i++) { if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] < selStart) target = i; } if (target === -1) return; values[target] = ''; const after = this.computeMaskDisplay(values, layout); writeMask(after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : Math.max(0, selStart - 1)); return; } if (e.key === 'Delete') { e.preventDefault(); let target = -1; for (let i = 0; i < values.length; i++) { if (values[i] !== '' && slotPos[i] !== -1 && slotPos[i] >= selStart) { target = i; break; } } if (target === -1) return; values[target] = ''; const after = this.computeMaskDisplay(values, layout); writeMask(after.display, after.slotPos[target] !== -1 ? after.slotPos[target] : selStart); return; } if (e.key.length === 1) e.preventDefault(); // block any other printable key }; private onMaskBlur = (e: FocusEvent): void => { const nileInput = e.currentTarget as (HTMLElement & { input?: HTMLInputElement; value?: string; emit?: Function }) | null; const native = nileInput?.input; if (!nileInput || !native) return; const kind: 'date' | 'time' = nileInput.classList.contains('time-input') ? 'time' : 'date'; const layout = this.maskLayoutFor(kind); const values = this.maskValuesFor(native, layout); if (!values.some(v => v !== '')) { native.value = ''; nileInput.value = ''; nileInput.emit?.('nile-change', { value: '' }); return; } // left-pad any partially-typed segment, then clamp each into range const segStarts = [...new Set(layout.slots.map(s => s.segStart))]; for (const start of segStarts) { const idxs = layout.slots.map((s, i) => (s.segStart === start ? i : -1)).filter(i => i !== -1); const digits = idxs.map(i => values[i]).filter(c => c !== ''); if (digits.length > 0 && digits.length < idxs.length) { const padded = digits.join('').padStart(idxs.length, '0'); idxs.forEach((i, k) => { values[i] = padded[k]; }); } } segStarts.forEach(start => this.clampMaskSegment(values, layout, start)); const { display } = this.computeMaskDisplay(values, layout); native.value = display; nileInput.value = display; nileInput.emit?.('nile-change', { value: display }); }; /** * Render method */ render(): TemplateResult { if (this.variant === 'date-time-picker') { return this.renderDateTimePicker(); } 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.variant === 'date-time-picker' ? html`
${this.hideDurationFields?.includes('minutes') ? '' : html`Minutes`} ${this.hideDurationFields?.includes('hours') ? '' : html`Hours`} ${this.hideDurationFields?.includes('days') ? '' : html`Days`} ${this.hideDurationFields?.includes('weeks') ? '' : html`Weeks`} ${this.hideDurationFields?.includes('months') ? '' : html`Months`}
` : 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; } // Date.UTC remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year const dateToCheck = new Date(this.variant === 'date-time-picker' ? utcFromFields(year, month, day) : 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; } private dayCellLabel(day: number, month: number, year: number): string { return localDateFromFields(year, month, day, 12).toLocaleDateString('default', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', }); } clearDate(mode?: ClearMode) { const clearType = mode ?? (this.range ? this.type === 'relative' ? ClearMode.Relative : ClearMode.Range : ClearMode.Single); this.clearInstantSeed(); 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; // new Date(y, ...) remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year const selectedDate = this.variant === 'date-time-picker' ? localDateFromFields(year, month, day) : 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() { this.clearInstantSeed(); if (this.range) { try { const rangeValue = JSON.parse(this.valueAttribute || ''); this.startDate = this.rangeBoundFromValue(rangeValue.startDate, false); this.endDate = this.rangeBoundFromValue(rangeValue.endDate, true); // Convert to local time this.startDate = new Date(this.startDate.getTime()); this.endDate = new Date(this.endDate.getTime()); // date-time-picker variant only: open on the seeded range's start month (the default variant keeps its month) if (this.variant === 'date-time-picker' && this.startDate && !isNaN(this.startDate.getTime())) { this.currentMonth = this.startDate.getMonth(); this.currentYear = this.startDate.getFullYear(); } this.value = null; } catch (e) { // console.error('Invalid range value'); } } else { if (this.variant === 'date-time-picker' && this.trySeedFromInstantValue()) { return; } const zoned = this.variant === 'date-time-picker' ? this.parseZonedValue(this.valueAttribute) : null; if (zoned) { this.setTimezoneInternally(zoned.timezone); const w = zoned.wall; this.value = localDateFromFields(w.year, w.month - 1, w.day, 12, 0, 0, 0); this.currentMonth = w.month - 1; this.currentYear = w.year; this.tpHours = w.hours; this.tpMinutes = w.minutes; this.tpSeconds = w.seconds; this.tpMilliseconds = w.ms; return; } if (this.variant === 'date-time-picker') { const wall = wallClockFromOffsetLess(this.valueAttribute); if (wall) { this.value = localDateFromFields(wall.year, wall.month - 1, wall.day, 12, 0, 0, 0); this.currentMonth = wall.month - 1; this.currentYear = wall.year; this.tpHours = wall.hours; this.tpMinutes = wall.minutes; this.tpSeconds = wall.seconds; this.tpMilliseconds = wall.ms; return; } } if (this.variant === 'date-time-picker') { const preset = this.parsePresetValue(this.valueAttribute); if (preset) { this.value = preset; this.currentMonth = preset.getMonth(); this.currentYear = preset.getFullYear(); // seed the time-picker spinners from the preset value's wall-clock time this.tpHours = preset.getHours(); this.tpMinutes = preset.getMinutes(); this.tpSeconds = preset.getSeconds(); this.tpMilliseconds = preset.getMilliseconds(); } else if (this.valueAttribute) { // an unparseable value must not leave the previous selection showing (the empty-value path above already clears it) this.value = null; this.resetTimeUnits(); } return; } if (this.valueAttribute) { const date = this.dateFromValueString(this.valueAttribute); if (!isNaN(date.getTime())) { this.value = date; this.currentMonth = this.value.getMonth(); this.currentYear = this.value.getFullYear(); } } } } /** Forget any retained instant, in every category. */ private clearInstantSeed(): void { this.seedInstant = null; this.seedFieldsKey = null; this.seedZone = null; } private instantSeedFieldsKey(): string | null { const v = this.value; if (!v) return null; return ( `${v.getFullYear()}-${v.getMonth()}-${v.getDate()}|` + `${this.tpHours}:${this.tpMinutes}:${this.tpSeconds}.${this.tpMilliseconds}` ); } private seedFromInstant(epochMs: number, zone: string): void { const w = wallClockInZone(epochMs, zone); this.value = localDateFromFields(w.year, w.month - 1, w.day, 12, 0, 0, 0); this.currentMonth = w.month - 1; this.currentYear = w.year; this.tpHours = w.hours; this.tpMinutes = w.minutes; this.tpSeconds = w.seconds; this.tpMilliseconds = w.ms; this.seedInstant = epochMs; this.seedFieldsKey = this.instantSeedFieldsKey(); } private trySeedFromInstantValue(): boolean { const epochMs = instantFromValueString(this.valueAttribute); if (epochMs !== null) { if (!isValidTimezone(this.timezone)) return false; this.seedFromInstant(epochMs, this.timezone); this.seedZone = null; return true; } const bracketed = instantFromBracketedValue(this.valueAttribute); if (bracketed) { this.setTimezoneInternally(bracketed.timezone); this.seedFromInstant(bracketed.epochMs, bracketed.timezone); this.seedZone = bracketed.timezone; return true; } return false; } /** * Seeds `value` from a string with the published conversion: `new Date(raw)` shifted by the host offset. Every variant * keeps it, because the default calendar must behave exactly as the published component; the date-time-picker variant * re-derives its value from its own parsers in `initializeValue`. */ private dateFromValueString(raw: string): Date { const parsed = new Date(raw); return new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60000); } /** * One bound of a `range` value. In the date-time-picker variant `YYYY-MM-DD` is a calendar day, not a UTC instant: it * lands on that local day, at 00:00:00.000 for the start and 23:59:59.999 for the end (what clicking the day produces). * Every other string, and every bound of the default calendar, goes through `new Date(raw)` exactly as published. */ private rangeBoundFromValue(raw: unknown, isEnd: boolean): Date { if (this.variant === 'date-time-picker' && typeof raw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw)) { const day = this.parsePresetValue(raw); if (day) { if (isEnd) day.setHours(23, 59, 59, 999); return day; } } return new Date(raw as string); } private parsePresetValue(raw: string | null): Date | null { if (!raw) return null; const dateOnly = /^\d{4}-\d{2}-\d{2}$/.exec(raw); if (dateOnly) { const year = Number(raw.slice(0, 4)); const month = Number(raw.slice(5, 7)) - 1; const day = Number(raw.slice(8, 10)); if (month < 0 || month > 11 || day < 1) return null; const lastDay = localDateFromFields(year, month + 1, 0).getDate(); const date = localDateFromFields(year, month, Math.min(day, lastDay)); return isNaN(date.getTime()) ? null : date; } // only an instant with an unusable zone or an undocumented string gets here; the documented formats are parsed before this const date = new Date(raw); return isNaN(date.getTime()) ? null : date; } private parseZonedValue( raw: string | null ): { wall: CalendarWallClock; timezone: string } | null { if (!raw) return null; const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?\[GMT[+-]\d{2}:\d{2}\s+([^\]]+)\]$/.exec( raw.trim() ); if (!m) return null; const [, y, mo, d, h, mi, s, ms, rawZone] = m; const month0 = Number(mo) - 1; const hours = Number(h); const minutes = Number(mi); const seconds = Number(s); if (month0 < 0 || month0 > 11 || hours > 23 || minutes > 59 || seconds > 59) { return null; } const day = Number(d); if (day < 1) return null; // clamp instead of letting an out-of-range day roll into the next month const lastDay = localDateFromFields(Number(y), month0 + 1, 0).getDate(); const wall: CalendarWallClock = { year: Number(y), month: month0 + 1, day: Math.min(day, lastDay), hours, minutes, seconds, ms: Number(ms ?? 0), }; const zone = rawZone.trim(); const timezone = isValidTimezone(zone) ? zone : 'UTC'; return { wall, timezone }; } /** * Function to handle relative date selection */ handleDurationChange(event: CustomEvent) { event.stopPropagation() if (this.variant === 'date-time-picker') { const input = event.target as (HTMLElement & { value: string }) | null; const cleaned = String(event.detail?.value ?? input?.value ?? '').replace(/\D/g, ''); if (input && input.value !== cleaned) input.value = cleaned; const duration = Number(cleaned); if (!duration) { this.selectedValue = undefined!; this.startDate = null; this.endDate = null; this.requestUpdate(); return; } this.selectedValue = duration; if (this.selectedUnit && this.selectedValue) { this.handleTimeValueClick(this.selectedUnit, this.selectedValue, event); } return; } 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(); // only the date-time-picker variant uses exact elapsed time, month-end clamping and the too-large-duration guard; the // default calendar keeps the published wall-clock arithmetic (and its behaviour for an overflowing duration) const dtp = this.variant === 'date-time-picker'; switch (unit) { case 'minutes': // elapsed duration: wall-clock arithmetic (setMinutes) is off by an hour across a host DST change if (dtp) startTime.setTime(endTime.getTime() - value * 60000); else startTime.setMinutes(startTime.getMinutes() - value); break; case 'hours': if (dtp) startTime.setTime(endTime.getTime() - value * 3600000); else 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': if (dtp) { // clamp to the target month's last day: setMonth alone rolls 31 Mar - 1 month into 3 Mar const day = startTime.getDate(); startTime.setDate(1); startTime.setMonth(startTime.getMonth() - value); const lastDay = localDateFromFields(startTime.getFullYear(), startTime.getMonth() + 1, 0).getDate(); startTime.setDate(Math.min(day, lastDay)); } else { startTime.setMonth(startTime.getMonth() - value); } break; } // a duration too large for a Date leaves an Invalid Date; storing it would keep Apply enabled and make consumers throw (RangeError) when they format it, so in the variant it is no selection if (dtp && (isNaN(startTime.getTime()) || isNaN(endTime.getTime()))) { this.startDate = null; this.endDate = null; return { startDate: null, endDate: null }; } 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.maxYear !== undefined && newYear > this.maxYear) 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.minYear !== undefined && newYear < this.minYear) 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; if (this.variant === 'date-time-picker' && this.type !== 'absolute') this.expanded = false; this.emit('nile-type-change', { value: this.type }) } getUTCDate(dateStr:any){ // Date.UTC remaps years 0-99 to 1900-1999 as published; only the date-time-picker variant keeps the literal year if (this.variant === 'date-time-picker') { return new Date( utcFromFields( Number(dateStr.slice(0, 4)), Number(dateStr.slice(5, 7)) - 1, Number(dateStr.slice(8, 10)) ) ) } 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); } private renderDateTimePicker(): TemplateResult { // the collapsed height is held while the month grid is expanded (the measured value reaches the stylesheet as a custom property) const heightLocked = this.type === 'absolute' && this.expanded && !!this.tpLockedHeight; return html`
Absolute Relative
${this.type === 'relative' ? this.renderRelativeCalendar() : this.renderDateTimePickerAbsolute()}
${this.type === 'absolute' ? html` Set to now ` : nothing}
`; } private renderDateTimePickerAbsolute(): TemplateResult { return html`
${this.expanded ? this.renderExpandedCalendar() : this.renderCollapsedMonth(this.currentYear, this.currentMonth)}
${this.renderTimeFields()} ${this.resolvedTimeFields.timezone ? this.renderTimezone() : ''}
`; } private renderExpandedCalendar(): TemplateResult { return this.renderVariantMonth( this.currentYear, this.currentMonth, this.getFullMonthDays(this.currentYear, this.currentMonth) ); } private renderCollapsedMonth(year: number, month: number): TemplateResult { return this.renderVariantMonth( year, month, this.getCollapsedWindowDays(year, month) ); } private renderVariantMonth( year: number, month: number, cells: Array<{ day: number; month: number; year: number; inMonth: boolean }> ): TemplateResult { const isSelectedDate = ( day: number, month: number, year: number, isCurrentMonth: boolean ) => { if (!isCurrentMonth) return ''; if (this.value) { const isSelected = day === this.value.getDate() && month === this.value.getMonth() && year === this.value.getFullYear(); if (isSelected) return 'selected-date'; } return ''; }; 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']; // roving tabindex: one reachable day (the keyboard's last day, else the selection, else today, else the first allowed day) const key = (c: { day: number; month: number; year: number }) => `${c.year}-${c.month}-${c.day}`; const reachable = cells.filter(c => c.inMonth && this.isAllowedDate(c.day, c.month, c.year)); const wanted = [ this.tpFocusDate && this.tpFocusDate, this.value && { year: this.value.getFullYear(), month: this.value.getMonth(), day: this.value.getDate() }, (() => { const t = new Date(); return { year: t.getFullYear(), month: t.getMonth(), day: t.getDate() }; })(), ]; let tabStop: string | null = null; for (const w of wanted) { if (w && reachable.some(c => key(c) === key(w))) { tabStop = key(w); break; } } if (tabStop === null && reachable.length) tabStop = key(reachable[0]); 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}
`)} ${cells.map(cell => { const isCurrentMonth = cell.inMonth; const datePlacement:''|'selected-date' = isSelectedDate(cell.day, cell.month, cell.year, isCurrentMonth) const classMapObj:any={ 'day_date' : true, 'not-allowed' : !this.isAllowedDate(cell.day, cell.month, cell.year), 'filler' : !isCurrentMonth, } if(datePlacement) classMapObj[datePlacement]=true const cellAllowed = this.isAllowedDate(cell.day, cell.month, cell.year); const cellToday = isCurrentDate(cell.day, cell.month, cell.year) && isCurrentMonth; return html`
${cell.day} ${isCurrentDate(cell.day, cell.month, cell.year) && isCurrentMonth?html`
`:nothing}
`; })}
`; } private getCollapsedWindowDays( year: number, month: number ): Array<{ day: number; month: number; year: number; inMonth: boolean }> { const today = new Date(); const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month; const selectionInMonth = this.value instanceof Date && this.value.getFullYear() === year && this.value.getMonth() === month; const centerOn = (dayOfMonth: number) => { const anchor = localDateFromFields(year, month, dayOfMonth); const s = new Date(anchor); s.setDate(anchor.getDate() - anchor.getDay() - 7); return s; }; const start = selectionInMonth ? centerOn(this.value!.getDate()) : isCurrentMonth ? centerOn(today.getDate()) : (() => { const first = localDateFromFields(year, month, 1); const s = new Date(first); s.setDate(1 - first.getDay()); return s; })(); return Array.from({ length: 21 }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return { day: d.getDate(), month: d.getMonth(), year: d.getFullYear(), inMonth: d.getMonth() === month && d.getFullYear() === year, }; }); } private getFullMonthDays( year: number, month: number ): Array<{ day: number; month: number; year: number; inMonth: boolean }> { const first = localDateFromFields(year, month, 1); const leading = first.getDay(); const daysInMonth = localDateFromFields(year, month + 1, 0).getDate(); const totalCells = Math.ceil((leading + daysInMonth) / 7) * 7; const start = localDateFromFields(year, month, 1 - leading); return Array.from({ length: totalCells }, (_, i) => { const d = new Date(start); d.setDate(start.getDate() + i); return { day: d.getDate(), month: d.getMonth(), year: d.getFullYear(), inMonth: d.getMonth() === month && d.getFullYear() === year, }; }); } /** * Arrow keys move by a day / week, Home and End to the week's first and last day, PageUp and PageDown by a month * (Shift: a year). The target is clamped to the month's length and to the year range; a day that the collapsed * three-week window does not show expands the calendar to reveal it. */ private handleDayNavigationKey(e: KeyboardEvent, cell: { day: number; month: number; year: number }): void { if (this.disabled || e.ctrlKey || e.metaKey || e.altKey) return; const from = localDateFromFields(cell.year, cell.month, cell.day); let target: Date | null = null; switch (e.key) { case 'ArrowLeft': target = localDateFromFields(cell.year, cell.month, cell.day - 1); break; case 'ArrowRight': target = localDateFromFields(cell.year, cell.month, cell.day + 1); break; case 'ArrowUp': target = localDateFromFields(cell.year, cell.month, cell.day - 7); break; case 'ArrowDown': target = localDateFromFields(cell.year, cell.month, cell.day + 7); break; case 'Home': target = localDateFromFields(cell.year, cell.month, cell.day - from.getDay()); break; case 'End': target = localDateFromFields(cell.year, cell.month, cell.day + (6 - from.getDay())); break; case 'PageUp': case 'PageDown': { const step = (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1); const month = cell.month + step; const last = localDateFromFields(cell.year, month + 1, 0).getDate(); target = localDateFromFields(cell.year, month, Math.min(cell.day, last)); break; } default: return; } e.preventDefault(); this.moveDayFocus(target); } private moveDayFocus(target: Date): void { const year = target.getFullYear(); const month = target.getMonth(); const lo = this.minYear; const hi = this.maxYear; if ((lo !== undefined && year < lo) || (hi !== undefined && year > hi)) return; this.tpFocusDate = { year, month, day: target.getDate() }; this.currentYear = year; this.currentMonth = month; const sel = `[data-tp-date="${year}-${month}-${target.getDate()}"]`; const focusCell = () => this.shadowRoot?.querySelector(sel)?.focus(); this.updateComplete.then(() => { if (this.shadowRoot?.querySelector(sel)) { focusCell(); return; } // not in the collapsed window: reveal the whole month this.expanded = true; this.updateComplete.then(focusCell).catch(() => { /* focus is best-effort */ }); }).catch(() => { /* focus is best-effort */ }); } private tpSelectDate(day: number, month: number, year: number): void { if (this.disabled) return; if (!this.isAllowedDate(day, month, year)) return; const selectedDate = localDateFromFields(year, month, day); // compare the calendar day only: seeded values are stored at noon, a clicked day at midnight const sameDay = !!this.value && this.value.getFullYear() === year && this.value.getMonth() === month && this.value.getDate() === day; if (sameDay && this.doubleClickUnselect) { this.handleReset(); return; } if (!sameDay) { this.resetTimeUnits(); } this.value = selectedDate; } private resetTimeUnits(): void { this.tpHours = null; this.tpMinutes = null; this.tpSeconds = null; this.tpMilliseconds = null; this.tpEditingText = ''; } private renderTimeFields(): TemplateResult { const fields = this.resolvedTimeFields; return html`
${fields.hours ? this.renderTimeField('Hours (24H)', 'HH', 23, this.tpHours, v => { this.tpHours = v; }) : ''} ${fields.minutes ? this.renderTimeField('Minutes', 'MM', 59, this.tpMinutes, v => { this.tpMinutes = v; }) : ''} ${fields.seconds ? this.renderTimeField('Seconds', 'SS', 59, this.tpSeconds, v => { this.tpSeconds = v; }) : ''} ${fields.milliseconds ? this.renderTimeField('Milliseconds', 'MS', 999, this.tpMilliseconds, v => { this.tpMilliseconds = v; }) : ''}
`; } private renderTimeField( label: string, placeholder: string, max: number, value: number | null, setter: (v: number | null) => void ): TemplateResult { const maxDigits = String(max).length; const normalize = (raw: string): { text: string; value: number | null } => { let digits = (raw || '').replace(/\D/g, ''); while (digits.length > maxDigits && digits[0] === '0') digits = digits.slice(1); if (digits.length > maxDigits) digits = digits.slice(0, maxDigits); if (digits === '') return { text: '', value: null }; const num = Number(digits); if (num > max) { const first = Number(digits[0]); return { text: String(first).padStart(maxDigits, '0'), value: first }; } return { text: digits, value: num }; }; const apply = (e: CustomEvent) => { e.stopPropagation(); const input = e.target as any; const { text, value: next } = normalize(input.value); setter(next); this.tpEditingText = text; if (input.value !== text) input.value = text; }; const step = (delta: 1 | -1, focusEl?: HTMLElement | null) => { if (this.disabled) return; // A stepper click keeps DOM focus on whichever field was focused before, so move it here — the // shared editing text must be attributed to the right input. if (focusEl) { const input = focusEl.closest('nile-input') as any; // `nile-input.focus()` dereferences its inner , which does not exist until the input has rendered // (a calendar in a closed, not-yet-attached panel never renders it). Nothing to focus then; still step. if (input?.input) input.focus(); } const span = max + 1; const next = value === null ? (delta === 1 ? 0 : max) : ((value + delta) % span + span) % span; setter(next); this.tpFocusedTimeField = label; this.tpEditingText = String(next).padStart(maxDigits, '0'); }; const focused = this.tpFocusedTimeField === label; const displayValue = focused ? this.tpEditingText : value !== null || this.value ? String(value ?? 0).padStart(maxDigits, '0') : ''; return html`
`; } private get timezoneOptions(): string[] { const zones: string[] = typeof (Intl as any).supportedValuesOf === 'function' ? (Intl as any).supportedValuesOf('timeZone') : TIMEZONES; return zones.includes('UTC') ? zones : ['UTC', ...zones]; } private renderTimezone(): TemplateResult { return html`
Timezone Use local timezone
${this.timezoneOptions.map( tz => html`${tz}` )}
`; } private handleTimezoneChange(event: CustomEvent): void { event.stopPropagation(); const selected = (event.detail && (event.detail.value ?? event.detail)) || (event.target as { value?: unknown } | null)?.value; if (typeof selected === 'string' && selected) { this.setTimezoneInternally(selected); } } private handleUseLocalTimezone(): void { if (this.disabled) return; const local = Intl.DateTimeFormat().resolvedOptions().timeZone; if (local) { this.setTimezoneInternally(local); } } private toggleExpanded(): void { if (this.disabled) return; this.expanded = !this.expanded; } private composeLocalTimezoneValue(): Date | null { if (!this.value) return null; if ( this.seedInstant !== null && this.seedFieldsKey === this.instantSeedFieldsKey() && (this.seedZone === null || this.seedZone === this.timezone) ) { return new Date(this.seedInstant); } return this.wallClockToInstant( this.value.getFullYear(), this.value.getMonth(), this.value.getDate(), this.tpHours ?? 0, this.tpMinutes ?? 0, this.tpSeconds ?? 0, this.tpMilliseconds ?? 0 ); } private composeValueISO(epochMs: number): string { const zone = this.timezone || 'UTC'; const p = (n: number, len = 2) => String(n).padStart(len, '0'); const ms = ((epochMs % 1000) + 1000) % 1000; let y: number, mo: number, d: number, h: number, mi: number, s: number; let offMs: number; try { const dtf = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); const map: Record = {}; for (const part of dtf.formatToParts(new Date(epochMs))) { if (part.type !== 'literal') map[part.type] = Number(part.value); } y = map.year; mo = map.month; d = map.day; h = map.hour; mi = map.minute; s = map.second; offMs = this.zoneOffsetMs(epochMs - ms); } catch { const local = new Date(epochMs); y = local.getFullYear(); mo = local.getMonth() + 1; d = local.getDate(); h = local.getHours(); mi = local.getMinutes(); s = local.getSeconds(); offMs = 0; } // the bracket only carries HH:mm; historical sub-minute offsets (LMT) round offMs = Math.round(offMs / 60000) * 60000; const sign = offMs >= 0 ? '+' : '-'; const abs = Math.abs(offMs); const off = `${sign}${p(Math.floor(abs / 3600000))}:${p(Math.floor((abs % 3600000) / 60000))}`; return ( `${p(y, 4)}-${p(mo)}-${p(d)}T${p(h)}:${p(mi)}:${p(s)}.${p(ms, 3)}` + `[GMT${off} ${zone}]` ); } private wallClockToInstant( y: number, monthIndex: number, day: number, h: number, m: number, s: number, ms: number ): Date { const guess = utcFromFields(y, monthIndex, day, h, m, s, 0); try { const offset1 = this.zoneOffsetMs(guess); let instant = guess - offset1; const offset2 = this.zoneOffsetMs(instant); if (offset2 !== offset1) instant = guess - offset2; return new Date(instant + ms); } catch { const composed = new Date(this.value!.getTime()); composed.setHours(h, m, s, ms); return composed; } } private zoneOffsetMs(utcMs: number): number { const dtf = new Intl.DateTimeFormat('en-US', { timeZone: this.timezone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); const map: Record = {}; for (const p of dtf.formatToParts(new Date(utcMs))) { if (p.type !== 'literal') map[p.type] = Number(p.value); } const asUTC = utcFromFields( map.year, map.month - 1, map.day, map.hour, map.minute, map.second ); return asUTC - utcMs; } private hasSelection(): boolean { if (this.type === 'relative') { return Boolean(this.startDate && this.endDate); } return Boolean(this.value); } private handleSetToNow(): void { if (this.disabled) return; const now = new Date(); this.tpHours = now.getHours(); this.tpMinutes = now.getMinutes(); this.tpSeconds = now.getSeconds(); this.tpMilliseconds = now.getMilliseconds(); this.value = new Date(now.getFullYear(), now.getMonth(), now.getDate()); this.currentMonth = now.getMonth(); this.currentYear = now.getFullYear(); const local = Intl.DateTimeFormat().resolvedOptions().timeZone; if (local) { this.setTimezoneInternally(local); } } private handleApply(): void { if (this.disabled || !this.hasSelection()) return; if (this.type === 'relative') { this.emitDateTimePickerChange({ startDate: this.startDate!, endDate: this.endDate!, timezone: 'UTC', }); return; } const localTimezoneValue = this.composeLocalTimezoneValue(); const unixEpoch = localTimezoneValue ? localTimezoneValue.getTime() : null; this.emitDateTimePickerChange({ value: unixEpoch === null ? null : this.composeValueISO(unixEpoch), localTimezoneValue, unixEpoch, timezone: this.timezone, }); } private handleReset(): void { if (this.disabled) return; this.clearInstantSeed(); this.value = null; this.startDate = null; this.endDate = null; this.selectedUnit = undefined!; this.selectedValue = undefined!; this.resetTimeUnits(); this.setTimezoneInternally(this.configuredTimezone); this.requestUpdate(); this.emit('nile-clear'); } private emitDateTimePickerChange( data: | { startDate: Date; endDate: Date; timezone: string } | { value: string | null; localTimezoneValue: Date | null; unixEpoch: number | null; timezone: string } | null ) { this.emit('nile-change', data); } } export default NileCalendar; declare global { interface HTMLElementTagNameMap { 'nile-calendar': NileCalendar; } }