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 ` ); 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 = ( ); 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 = ( ); 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(); } calContent = (
{ld.months[month - 1]} {year}
{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 ( ); }); calContent = (
{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 = ( ); yearCells.push(btn); } calContent = (
{startYear} {' '} – {' '} {startYear + 11}
{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 => ( )); const minuteItems = MINUTES.map(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
); } // ── Wrapper ─────────────────────────────────────────────────────── return (
{inputSection} {dropdown} {modalPicker}
); } } const DatetimePicker = toNative(DatetimePickerComponent); export default DatetimePicker;