import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import { capitalize } from '../../common/extensions/string-extensions'; import { MobileModeConfig } from '../../common/mobile-mode-config'; import TemporalUtils from '../../common/utils/temporal-utils'; import Button from '../button/button'; import { ButtonLayout, ButtonSize } from '../button/button-layout'; import FormItemWrapper from '../form/form-item-wrapper'; import Modal, { ModalMobileMode } from '../modal/modal'; import './css/datetime-picker.css'; type ViewMode = 'days' | 'months' | 'years'; interface DatetimePickerArgs extends FormItemWrapperArgs { value: Temporal.PlainDateTime; disabled?: boolean; placeholder?: string; showTime?: boolean; startDate?: Temporal.PlainDateTime; endDate?: Temporal.PlainDateTime; changed: (newValue: Temporal.PlainDateTime) => void; inline?: boolean; mobileModal?: boolean; } const MINUTE_STEP = 5; const HOURS: number[] = []; for (let i = 0; i < 24; i++) { HOURS.push(i); } const MINUTES: number[] = []; for (let i = 0; i < 60; i += MINUTE_STEP) { MINUTES.push(i); } const _SVG_CALENDAR = ''; const SVG_LEFT = ''; const SVG_RIGHT = ''; const SVG_X = ''; const pad2 = (n: number): string => { return n < 10 ? `0${n}` : `${n}`; }; interface LocaleDateFormat { order: ('day' | 'month' | 'year')[]; separator: string; // core separator with spaces stripped (e.g. "." for Slovak "dd. mm. yyyy") } interface LocaleData { months: string[]; monthsShort: string[]; weekdaysMin: string[]; dateFormat: LocaleDateFormat; } const localeCache: Record = {}; const getCachedLocaleData = (locale: string): LocaleData => { if (localeCache[locale]) { return localeCache[locale]; } const months: string[] = []; const monthsShort: string[] = []; const weekdaysMin: string[] = []; for (let i = 0; i < 12; i++) { // eslint-disable-next-line no-restricted-syntax -- no Temporal API for locale month names const d = new Date(Date.UTC( 2024, i, 15, )); months.push(d.toLocaleDateString(locale, { month: 'long', timeZone: 'UTC' })[capitalize]()); monthsShort.push(d.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' })[capitalize]()); } for (let i = 0; i < 7; i++) { // eslint-disable-next-line no-restricted-syntax -- 2024-01-01 is Monday; no Temporal equivalent const d = new Date(Date.UTC( 2024, 0, 1 + i, )); let short = d.toLocaleDateString(locale, { weekday: 'short', timeZone: 'UTC' })[capitalize](); if (short.length > 2) { short = short.substring(0, 2); } weekdaysMin.push(short); } // Detect date field order and separator via Intl API const fmtParts = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) // eslint-disable-next-line no-restricted-syntax .formatToParts(new Date(Date.UTC( 2024, 0, 15, ))); const order: ('day' | 'month' | 'year')[] = []; let separator = ''; for (const part of fmtParts) { if (part.type === 'day' || part.type === 'month' || part.type === 'year') { order.push(part.type); } else if (part.type === 'literal' && !separator) { separator = part.value.replace(/\s/g, ''); } } localeCache[locale] = { months, monthsShort, weekdaysMin, dateFormat: { order, separator }, }; return localeCache[locale]; }; @Component class DatetimePickerComponent extends TsxComponent implements DatetimePickerArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: Temporal.PlainDateTime; @Prop() disabled!: boolean; @Prop() placeholder!: string; @Prop() mandatory!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() maxWidth?: number; @Prop() marginType?: MarginType; @Prop() changed: (newValue: Temporal.PlainDateTime) => void; @Prop() showTime!: boolean; @Prop() showClearValueButton!: boolean; @Prop() startDate?: Temporal.PlainDateTime; @Prop() endDate?: Temporal.PlainDateTime; @Prop() inline!: boolean; @Prop() mobileModal!: boolean; isOpen = false; viewMode: ViewMode = 'days'; viewYear = 2024; viewMonth = 1; editHour = 0; editMinute = 0; dropdownVertical: 'down' | 'up' = 'down'; // Editable input state editText = ''; isEditing = false; private outsideClickHandler: ((e: MouseEvent) => void) | null = null; get useMobileModal(): boolean { return this.mobileModal !== false && MobileModeConfig.shouldDisplayInModal(); } get locale(): string { return PowerduckState.getCurrentLanguage(); } get displayText(): string { if (this.value == null) { return ''; } const d = this.value; const ld = getCachedLocaleData(this.locale); const { order, separator } = ld.dateFormat; const parts: Record = { day: pad2(d.day), month: pad2(d.month), year: d.year.toString(), }; let str = order.map(f => parts[f]).join(separator); if (this.showTime) { str += ` ${pad2(d.hour)}:${pad2(d.minute)}`; } return str; } raiseChangeEvent(newValue: Temporal.PlainDateTime) { this.populateValidationDeclaration(); if (this.changed != null) { this.changed(newValue); } } scrollTimeIntoView() { this.$nextTick(() => { const el = this.$el as HTMLElement; if (el == null) { return; } const hourEl = el.querySelector('.dtp-time-scroll-item--sel-h'); const minEl = el.querySelector('.dtp-time-scroll-item--sel-m'); hourEl?.scrollIntoView({ block: 'nearest' }); minEl?.scrollIntoView({ block: 'nearest' }); }); } open() { if (this.disabled) { return; } this.isOpen = true; this.viewMode = 'days'; if (this.value != null) { this.viewYear = this.value.year; this.viewMonth = this.value.month; this.editHour = this.value.hour; this.editMinute = this.value.minute; } else { const now = Temporal.Now.plainDateTimeISO(); this.viewYear = now.year; this.viewMonth = now.month; this.editHour = now.hour; this.editMinute = 0; } if (this.useMobileModal) { this.$nextTick(() => { this.scrollTimeIntoView(); (this.$refs.dtpModal as any)?.show({ onHidden: () => this.onModalHidden(), }); }); } else { this.$nextTick(() => { this.scrollTimeIntoView(); this.outsideClickHandler = (e: MouseEvent) => { if (this.$el && !this.$el.contains(e.target as Node)) { this.close(); } }; document.addEventListener('mousedown', this.outsideClickHandler); this.adjustDropdownPosition(); }); } } close() { if (this.isEditing) { this.commitEditText(); this.isEditing = false; } if (this.useMobileModal) { // Let the modal animate out; onModalHidden sets isOpen = false after animation (this.$refs.dtpModal as any)?.hide(); return; } this.isOpen = false; if (this.outsideClickHandler) { document.removeEventListener('mousedown', this.outsideClickHandler); this.outsideClickHandler = null; } } onModalHidden() { this.isOpen = false; } toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } adjustDropdownPosition() { const root = this.$el as HTMLElement; const dropdown = root?.querySelector('.dtp-dropdown') as HTMLElement; if (!dropdown || this.inline) { return; } dropdown.style.left = ''; dropdown.style.right = ''; const rect = dropdown.getBoundingClientRect(); if (rect.right > window.innerWidth) { dropdown.style.left = 'auto'; dropdown.style.right = '0'; } // Measure space within the intersection of the clipping ancestor and // the viewport — a clipper that extends past the viewport bottom must // not be trusted as available space. const SAFETY_MARGIN_PX = 8; const wrapRect = root.getBoundingClientRect(); const dropdownHeight = dropdown.offsetHeight || 320; const clipper = this.findClippingAncestor(root); const clipperRect = clipper ? clipper.getBoundingClientRect() : null; const effectiveBottom = clipperRect ? Math.min(clipperRect.bottom, window.innerHeight) : window.innerHeight; const effectiveTop = clipperRect ? Math.max(clipperRect.top, 0) : 0; const spaceBelow = effectiveBottom - wrapRect.bottom; const spaceAbove = wrapRect.top - effectiveTop; const requiredHeight = dropdownHeight + SAFETY_MARGIN_PX; const fitsBelow = requiredHeight <= spaceBelow; const fitsAbove = requiredHeight <= spaceAbove; if (fitsAbove && (!fitsBelow || spaceAbove > spaceBelow)) { this.dropdownVertical = 'up'; } else if (fitsBelow) { this.dropdownVertical = 'down'; } else { this.dropdownVertical = spaceAbove > spaceBelow ? 'up' : 'down'; } } private findClippingAncestor(start: HTMLElement): HTMLElement | null { let el = start.parentElement; while (el && el !== document.body && el !== document.documentElement) { const cs = getComputedStyle(el); if (cs.overflowY === 'hidden' || cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'clip') { return el; } el = el.parentElement; } return null; } // ── Editable input methods ─────────────────────────────────────── parseInputText(text: string): Temporal.PlainDateTime | null { if (!text?.trim()) { return null; } text = text.trim(); const ld = getCachedLocaleData(this.locale); const { order, separator } = ld.dateFormat; let match: RegExpMatchArray | null; // Locale-aware format: build regex from Intl-detected order and separator // Allow optional spaces around separator (e.g. "15. 01. 2024" or "15.01.2024" both work) const escapedSep = separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const sepPat = `\\s*${escapedSep}\\s*`; // Optional trailing separator (e.g. Hungarian "2024. 01. 15.") const trailPat = `(?:\\s*${escapedSep})?`; const localeRegex = new RegExp(`^(\\d{1,4})${sepPat}(\\d{1,2})${sepPat}(\\d{1,4})${trailPat}(?:\\s+(\\d{1,2}):(\\d{2}))?$`); match = text.match(localeRegex); if (match) { const fields: Record = {}; order.forEach((field, i) => { fields[field] = +match![i + 1]; }); return this.buildDate( fields.year, fields.month, fields.day, match[4] ? +match[4] : undefined, match[5] ? +match[5] : undefined, ); } // ISO fallback: YYYY-MM-DD or YYYY-MM-DDTHH:MM (always supported) match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{2}))?$/); if (match) { return this.buildDate( +match[1], +match[2], +match[3], match[4] ? +match[4] : undefined, match[5] ? +match[5] : undefined, ); } return null; } private buildDate( year: number, month: number, day: number, hour?: number, minute?: number, ): Temporal.PlainDateTime | null { if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900 || year > 2100) { return null; } const h = hour ?? (this.showTime ? this.editHour : 0); const m = minute ?? (this.showTime ? this.editMinute : 0); if (h < 0 || h > 23 || m < 0 || m > 59) { return null; } try { return TemporalUtils.fromString(`${year}-${pad2(month)}-${pad2(day)}T${pad2(h)}:${pad2(m)}:00`); } catch { return null; } } commitEditText() { if (!this.editText?.trim()) { this.raiseChangeEvent(null); return; } const parsed = this.parseInputText(this.editText); if (parsed != null) { this.viewYear = parsed.year; this.viewMonth = parsed.month; if (this.showTime) { this.editHour = parsed.hour; this.editMinute = parsed.minute; } this.raiseChangeEvent(parsed); } // If parsing failed, silently revert (displayText will show on next render) } handleInputFocus(e: FocusEvent) { if (this.useMobileModal) { return; } // `isEditing` represents "user is typing freeform text", not "input has focus". // Setting it on focus alone caused the input to render the stale `editText` ("") // while the user was picking via the calendar in Safari (where ``s don't // take focus on mouse click, so no blur fires to reset the flag). We now flip // `isEditing` only in `handleInputInput` when the user actually types. this.editText = this.displayText; if (!this.isOpen) { this.open(); } setTimeout(() => { (e.target as HTMLInputElement)?.select(); }, 0); } handleInputBlur(e: FocusEvent) { if (this.useMobileModal) { return; } // Always commit edit text on blur (handles clearing via empty input) if (this.isEditing) { this.commitEditText(); this.isEditing = false; } // If focus moved to another element inside this component (e.g. calendar day), keep open const relatedTarget = e.relatedTarget as Node; if (relatedTarget && this.$el?.contains(relatedTarget)) { return; } // Focus left the component — close this.close(); } handleInputKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { e.preventDefault(); // Enter must behave exactly like the OK button this.close(); (e.target as HTMLInputElement)?.blur(); } else if (e.key === 'Escape') { e.preventDefault(); this.isEditing = false; this.close(); (e.target as HTMLInputElement)?.blur(); } } handleInputInput(e: Event) { if (!this.isEditing) { this.isEditing = true; if (!this.isOpen) { this.open(); } } this.editText = (e.target as HTMLInputElement).value; } // ── Original methods ───────────────────────────────────────────── isDisabledDate( year: number, month: number, day: number, ): boolean { if (this.startDate != null) { if (year < this.startDate.year) { return true; } if (year === this.startDate.year && month < this.startDate.month) { return true; } if (year === this.startDate.year && month === this.startDate.month && day < this.startDate.day) { return true; } } if (this.endDate != null) { if (year > this.endDate.year) { return true; } if (year === this.endDate.year && month > this.endDate.month) { return true; } if (year === this.endDate.year && month === this.endDate.month && day > this.endDate.day) { return true; } } return false; } /** * Explicit picks (day / time item / Today / Clear) supersede any in-progress freeform * typing in the input, so we exit editing mode here. The input then renders the latest * `displayText`, and `close() → commitEditText()` becomes a no-op instead of overwriting * the pick with stale `editText`. */ private exitEditingMode() { this.isEditing = false; this.editText = ''; } selectDay( year: number, month: number, day: number, ) { if (this.isDisabledDate( year, month, day, )) { return; } const h = this.showTime ? this.editHour : 0; const m = this.showTime ? this.editMinute : 0; const val = TemporalUtils.fromString(`${year}-${pad2(month)}-${pad2(day)}T${pad2(h)}:${pad2(m)}:00`); this.viewYear = year; this.viewMonth = month; this.exitEditingMode(); this.raiseChangeEvent(val); if (!this.showTime && !this.inline) { this.close(); } } selectHour(h: number) { this.editHour = h; this.exitEditingMode(); this.emitTimeChange(); this.scrollTimeIntoView(); } selectMinute(m: number) { this.editMinute = m; this.exitEditingMode(); this.emitTimeChange(); this.scrollTimeIntoView(); } emitTimeChange() { if (this.value == null) { return; } const v = this.value; const val = TemporalUtils.fromString(`${v.year}-${pad2(v.month)}-${pad2(v.day)}T${pad2(this.editHour)}:${pad2(this.editMinute)}:00`); this.raiseChangeEvent(val); } selectToday() { const now = Temporal.Now.plainDateTimeISO(); if (this.showTime) { this.editHour = now.hour; this.editMinute = Math.floor(now.minute / MINUTE_STEP) * MINUTE_STEP; } this.viewYear = now.year; this.viewMonth = now.month; this.viewMode = 'days'; this.selectDay( now.year, now.month, now.day, ); } clearValue() { this.exitEditingMode(); this.raiseChangeEvent(null); } prevMonth() { if (this.viewMonth === 1) { this.viewMonth = 12; this.viewYear--; } else { this.viewMonth--; } } nextMonth() { if (this.viewMonth === 12) { this.viewMonth = 1; this.viewYear++; } else { this.viewMonth++; } } mounted() { if (this.inline && this.value != null) { this.viewYear = this.value.year; this.viewMonth = this.value.month; this.editHour = this.value.hour; this.editMinute = this.value.minute; } } beforeUnmount() { if (this.outsideClickHandler) { document.removeEventListener('mousedown', this.outsideClickHandler); this.outsideClickHandler = null; } } render() { const showDropdown = this.isOpen || this.inline; const ld = getCachedLocaleData(this.locale); const today = Temporal.Now.plainDateISO(); // ── Calendar content (days / months / years) ────────────────────── let calContent: VNode; if (this.viewMode === 'days') { const year = this.viewYear; const month = this.viewMonth; const ym = Temporal.PlainYearMonth.from({ year, month }); const dim = ym.daysInMonth; const firstDow = Temporal.PlainDate.from({ year, month, day: 1 }).dayOfWeek; const offset = firstDow - 1; const prevMonth = month === 1 ? 12 : month - 1; const prevYear = month === 1 ? year - 1 : year; const prevDim = Temporal.PlainYearMonth.from({ year: prevYear, month: prevMonth }).daysInMonth; const nextMonth = month === 12 ? 1 : month + 1; const nextYear = month === 12 ? year + 1 : year; const dayButtons: VNode[] = []; for (let i = offset - 1; i >= 0; i--) { const d = prevDim - i; const disabled = this.isDisabledDate( prevYear, prevMonth, d, ); const btn = ( { if (!disabled) { this.selectDay( prevYear, prevMonth, d, ); } }} > {d} ); dayButtons.push(btn); } for (let d = 1; d <= dim; d++) { const isToday = today.year === year && today.month === month && today.day === d; const isSelected = this.value != null && this.value.year === year && this.value.month === month && this.value.day === d; const disabled = this.isDisabledDate( year, month, d, ); const cls = ['dtp-cal-day']; if (isToday) { cls.push('dtp-cal-day--today'); } if (isSelected) { cls.push('dtp-cal-day--selected'); } if (disabled) { cls.push('dtp-cal-day--disabled'); } const btn = ( { if (!disabled) { this.selectDay( year, month, d, ); } }} > {d} ); dayButtons.push(btn); } const totalCells = offset + dim; const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); for (let d = 1; d <= remaining; d++) { const disabled = this.isDisabledDate( nextYear, nextMonth, d, ); const btn = ( { if (!disabled) { this.selectDay( nextYear, nextMonth, d, ); } }} > {d} ); dayButtons.push(btn); } // Year range for dropdown const minYear = 1900; const maxYear = Math.max(year, today.year) + 10; const yearOptions: VNode[] = []; for (let y = minYear; y <= maxYear; y++) { yearOptions.push({y}); } calContent = ( this.prevMonth()} domPropsInnerHTML={SVG_LEFT} /> {ld.months[month - 1]} { this.viewMonth = Number.parseInt((e.target as HTMLSelectElement).value); }} > {ld.months.map((name, i) => ( {name} ))} {year} { this.viewYear = Number.parseInt((e.target as HTMLSelectElement).value); }} > {yearOptions} this.nextMonth()} domPropsInnerHTML={SVG_RIGHT} /> {ld.weekdaysMin.map(w => {w})} {dayButtons} ); } else if (this.viewMode === 'months') { const monthCells = ld.monthsShort.map((name, i) => { const isCurrent = today.month === i + 1 && today.year === this.viewYear; const isSelected = this.viewMonth === i + 1; const cls = ['dtp-grid-cell']; if (isCurrent) { cls.push('dtp-grid-cell--current'); } if (isSelected) { cls.push('dtp-grid-cell--selected'); } return ( { this.viewMonth = i + 1; this.viewMode = 'days'; }} > {name} ); }); calContent = ( { this.viewYear--; }} domPropsInnerHTML={SVG_LEFT} /> { this.viewMode = 'years'; }}>{this.viewYear} { this.viewYear++; }} domPropsInnerHTML={SVG_RIGHT} /> {monthCells} ); } else { const startYear = this.viewYear - 5; const yearCells: VNode[] = []; for (let y = startYear; y < startYear + 12; y++) { const isCurrent = today.year === y; const isSelected = this.viewYear === y; const cls = ['dtp-grid-cell']; if (isCurrent) { cls.push('dtp-grid-cell--current'); } if (isSelected) { cls.push('dtp-grid-cell--selected'); } const btn = ( { this.viewYear = y; this.viewMode = 'months'; }} > {y} ); yearCells.push(btn); } calContent = ( { this.viewYear -= 12; }} domPropsInnerHTML={SVG_LEFT} /> {startYear} {' '} – {' '} {startYear + 11} { this.viewYear += 12; }} domPropsInnerHTML={SVG_RIGHT} /> {yearCells} ); } // ── Time panel (desktop — scrollable columns on right) ──────────── let timePanel: VNode = null; let timeMobile: VNode = null; if (this.showTime && showDropdown) { const hourItems = HOURS.map(h => ( this.selectHour(h)} > {pad2(h)} )); const minuteItems = MINUTES.map(m => ( this.selectMinute(m)} > {pad2(m)} )); timePanel = ( {PowerduckState.getResourceValue('datePickerTime')} HH {hourItems} : MM {minuteItems} ); timeMobile = ( HH this.selectHour(Number.parseInt((e.target as HTMLInputElement).value))} /> {pad2(this.editHour)} MM this.selectMinute(Number.parseInt((e.target as HTMLInputElement).value))} /> {pad2(this.editMinute)} ); } // ── Footer with Today + OK ──────────────────────────────────────── const footer = showDropdown ? ( ) : null; // ── Dropdown (desktop) ─────────────────────────────────────────── const dropdown = showDropdown && !this.useMobileModal ? ( { // Safari does not focus s on mouse click on desktop. Without this guard // the input would blur on mousedown with `relatedTarget === null`, `handleInputBlur` // would treat that as "focus left the picker" and call `close()` — unmounting the // dropdown before the click on the day/time/footer button could land. Preventing // default on mousedown suppresses the focus shift in every browser while still // letting `click` fire normally. Native form controls (`` for month/year // navigation) need their default focus to open their native popups, so we exempt them. const target = e.target as HTMLElement | null; if (target == null) { return; } if (target.closest('select, input, textarea') != null) { return; } e.preventDefault(); }} > {calContent} {timeMobile} {timePanel} {footer} ) : null; // ── Mobile modal ───────────────────────────────────────────────── let modalPicker: VNode = null; if (this.useMobileModal) { modalPicker = ( {showDropdown && ( {calContent} {timeMobile} {timePanel} {footer} )} ); } // ── Input field ─────────────────────────────────────────────────── let inputSection: VNode = null; if (!this.inline) { const containerCls = ['dtp-input-container']; if (this.isOpen) { containerCls.push('dtp-input-container--focused'); } if (this.disabled) { containerCls.push('dtp-input-container--disabled'); } const isMobile = this.useMobileModal; inputSection = ( { if (!this.isOpen) { this.open(); } }} onFocus={(e: FocusEvent) => this.handleInputFocus(e)} onBlur={(e: FocusEvent) => this.handleInputBlur(e)} onKeydown={(e: KeyboardEvent) => this.handleInputKeydown(e)} onInput={(e: Event) => this.handleInputInput(e)} /> {this.showClearValueButton && this.value != null && ( { e.stopPropagation(); this.clearValue(); }} domPropsInnerHTML={SVG_X} /> )} ); } // ── Wrapper ─────────────────────────────────────────────────────── return ( {inputSection} {dropdown} {modalPicker} ); } } const DatetimePicker = toNative(DatetimePickerComponent); export default DatetimePicker;