/* eslint-disable ts/no-this-alias */ /* eslint-disable no-useless-call */ 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, Watch } from 'vue-facing-decorator'; import { globalState } from '../../app/global-state'; 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 DateUtils from '../../common/utils/date-utils'; import TemporalUtils from '../../common/utils/temporal-utils'; import FormItemWrapper from '../form/form-item-wrapper'; import Modal, { ModalMobileMode } from '../modal/modal'; import DateInputHelper from './ts/dateInputHelper'; import './css/daterange-picker.css'; // ── Types ────────────────────────────────────────────────────────────────── export interface DaterangeChangedArgs { startTime: Temporal.PlainDateTime; endTime: Temporal.PlainDateTime; } export interface DaterangePickerCellRenderDay { date: Temporal.PlainDateTime; day: number; extraClass: string; time: number; tooltip: string; type: 'toMonth' | 'nextMonth' | 'lastMonth'; valid: boolean; } export interface DaterangePickerCellRenderArgs { attributes: { [index: string]: string }; day: DaterangePickerCellRenderDay; } type CalendarPlacement = 'body' | 'inline' | 'input-container-leftalign' | 'input-container-rightalign'; type MobileVariant = 'paginate' | 'verticalScroll'; interface RenderCtx { mode: 'single' | 'range' | 'multi'; todayMs: number; startMs: number | null; endMs: number | null; hoverMs: number | null; minMs: number | null; maxMs: number | null; multiKeys: Set; /** Effective maxDays cap (0 if not applicable). */ maxDaysCap: number; } interface DaterangePickerArgs extends FormItemWrapperArgs { disabled?: boolean; value?: DaterangeChangedArgs; placeholder?: string; format?: string; enableTime?: boolean; autoClose?: boolean; commitPartialRangeOnClose?: boolean; separator?: string; inputReadOnly?: boolean; minDate?: Temporal.PlainDateTime; alwaysOpen?: boolean; singleDate?: boolean; pickerRootCssClass?: string; maxDate?: Temporal.PlainDateTime; /** * Month to open the calendar on when nothing is selected. Falls back to the * current month when omitted, so existing callers are unaffected. Used by the * shop product calendar to default to the first available (future) month. */ defaultAnchorDate?: Temporal.PlainDateTime; forceModalMode?: boolean; prevIcon?: string; nextIcon?: string; hideYearInMonthName?: boolean; singleMonth?: boolean; calendarPlacement?: CalendarPlacement; ensureMonthContinuity?: boolean; monthSelect?: boolean; yearSelect?: boolean; customInputText?: () => string; customTooltip?: (dayCount: number) => VNode | string; maxDays?: number; maxDaysTooltip?: (maxDays: number) => VNode | string; getCell?: (args: DaterangePickerCellRenderArgs) => VNode; changed?: (newValue: DaterangeChangedArgs) => void; showCustomClearButton?: boolean; // New features multiDate?: boolean; multiValue?: Temporal.PlainDateTime[]; multiChanged?: (dates: Temporal.PlainDateTime[]) => void; /** When the count of selected dates exceeds this value, input shows "Selected: N days" instead of expanded list. */ multiSummaryThreshold?: number; mobileVariant?: MobileVariant; confirmText?: string; cancelText?: string; /** * Called when the user presses Enter inside the input. Fires after the input * text is committed and the picker is closed. Useful for advancing focus to * the next field in a multi-step form. */ confirmedByEnter?: () => void; /** * Optional footer info row rendered below the calendar months (and time row * if enabled). Displayed as `(i) ` with a top-border separator. * Intended for currency disclaimers, e.g. "Ceny zobrazené v mene EUR". */ footerInfo?: VNode | string; } // ── Translations ─────────────────────────────────────────────────────────── // // Only the strings the browser cannot synthesize live here. Month names and // abbreviated weekday names are pulled from `Intl.DateTimeFormat` on first use // per locale and cached — same approach as `datetime-picker.tsx`. /** * Only the strings that have NO Intl-derived equivalent. Day, days, hour, * minute are pulled from Intl.DisplayNames + Intl.NumberFormat at runtime; * month names and weekday abbreviations come from Intl.DateTimeFormat. */ interface PickerLanguage { selected: string; apply: string; confirm: string; cancel: string; time: string; } const LANGUAGES: { [k: string]: PickerLanguage } = { default: { selected: 'Selected:', apply: 'Close', confirm: 'Confirm', cancel: 'Cancel', time: 'Time' }, cz: { selected: 'Vybráno:', apply: 'Zavřít', confirm: 'Potvrdit', cancel: 'Zrušit', time: 'Čas' }, de: { selected: 'Auswahl:', apply: 'Schließen', confirm: 'Bestätigen', cancel: 'Abbrechen', time: 'Zeit' }, hu: { selected: 'Kiválasztva:', apply: 'Bezárás', confirm: 'Megerősítés', cancel: 'Mégse', time: 'Idő' }, pl: { selected: 'Wybrany:', apply: 'Zamknij', confirm: 'Potwierdź', cancel: 'Anuluj', time: 'Czas' }, sk: { selected: 'Vybrané:', apply: 'Zavrieť', confirm: 'Potvrdiť', cancel: 'Zrušiť', time: 'Čas' }, }; /** Map our internal language key to a tag accepted by Intl.DateTimeFormat. */ const intlTagFor = (key: string): string => { if (key === 'cz') { return 'cs'; } if (key === 'default') { return 'en'; } return key; }; // ── Helpers ──────────────────────────────────────────────────────────────── const DATE_FORMAT_FOR_RANGE_PICKER = (() => { // eslint-disable-next-line no-restricted-syntax const dummyDate = new Date(Date.UTC( 2022, 11, 20, )); const formatted = dummyDate.toLocaleDateString(PowerduckState.getCurrentLanguage(), { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC', }); return formatted.replace('20', 'dd').replace('12', 'MM').replace('2022', 'yyyy'); })(); const DATE_FORMAT_FOR_RANGE_PICKER_WITH_TIME = `${DATE_FORMAT_FOR_RANGE_PICKER.split('. ').join('.')} HH:mm`; const SINGLE_MONTH_THRESHOLD_W = 700; const SINGLE_MONTH_THRESHOLD_H = 550; const MS_PER_DAY = 86_400_000; /** Reused for `RenderCtx.multiKeys` whenever we are NOT in multi mode. */ const EMPTY_NUMBER_SET: Set = new Set(); /** * Single Date reused inside cell loops to decompose ms cursor positions into * year/month/day without allocating a Date per cell. Safe because cell loops * are synchronous and never re-entered. */ // eslint-disable-next-line no-restricted-syntax -- mutation-target Date for ms→ymd decomposition const SHARED_TMP_DATE = new Date(0); // Per-instance suffix for keyboard-focus cell ids (aria-activedescendant target). let drpKbdInstanceSeq = 0; const compareDay = (a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number => Temporal.PlainDate.compare(a.toPlainDate(), b.toPlainDate()); const compareMonth = (a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number => { if (a.year !== b.year) { return a.year < b.year ? -1 : 1; } if (a.month !== b.month) { return a.month < b.month ? -1 : 1; } return 0; }; const nextMonthDate = (d: Temporal.PlainDateTime): Temporal.PlainDateTime => d.add({ months: 1 }); const prevMonthDate = (d: Temporal.PlainDateTime): Temporal.PlainDateTime => d.subtract({ months: 1 }); const sameDay = (a: Temporal.PlainDateTime | null, b: Temporal.PlainDateTime | null): boolean => { if (a == null || b == null) { return false; } return a.year === b.year && a.month === b.month && a.day === b.day; }; /** * Lazily resolve and cache the active language pack the same way * `datetime-picker.tsx` caches its locale data: the first call after a locale * change does the lookup once; every subsequent translate/getMonthName call * just reads from the cache. Avoids the PowerduckState round-trip and the * `DateInputHelper.getLocale()` chain on every cell render. */ interface PickerLocaleData { lang: PickerLanguage; startOfWeek: 'monday' | 'sunday'; /** 12 month names from Intl (1-indexed: monthNames[0] = January). */ monthNames: string[]; /** 7 weekday abbreviations already rotated to start with the configured week-start day. */ weekdaysOrdered: string[]; /** "Day" label from Intl.DisplayNames(dateTimeField), capitalised. */ dayLabel: string; /** "Hour" label from Intl.DisplayNames(dateTimeField), capitalised. */ hourLabel: string; /** "Minute" label from Intl.DisplayNames(dateTimeField), capitalised. */ minuteLabel: string; /** * Pre-built unit formatter for "N day(s)" — Intl.NumberFormat handles the * locale's plural rules (Slovak "1 deň" / "2 dni" / "5 dní") in one call. */ dayUnitFormatter: Intl.NumberFormat; } const localeDataCache: Record = {}; const resolveLocaleKey = (): string => { let lang = DateInputHelper.getLocale(); if (lang === 'cs') { lang = 'cz'; } return lang in LANGUAGES ? lang : 'default'; }; const buildLocaleData = (key: string): PickerLocaleData => { const intlTag = intlTagFor(key); const monthNames: string[] = []; for (let i = 0; i < 12; i++) { // eslint-disable-next-line no-restricted-syntax -- Intl needs a Date; result is cached forever. const d = new Date(Date.UTC( 2024, i, 15, )); monthNames.push(d.toLocaleDateString(intlTag, { month: 'long', timeZone: 'UTC' })); } // 2024-01-01 falls on a Monday; iterate 7 days to collect Mon→Sun short names. const weekdaysMonFirst: string[] = []; for (let i = 0; i < 7; i++) { // eslint-disable-next-line no-restricted-syntax -- same justification as above. const d = new Date(Date.UTC( 2024, 0, 1 + i, )); let short = d.toLocaleDateString(intlTag, { weekday: 'short', timeZone: 'UTC' }); // Strip trailing dots/whitespace that some locales include (e.g. "lun.") // and clamp to 2 chars to match the legacy header look ("PO ÚT ST …"). short = short.replace(/[.\s]+$/, ''); if (short.length > 2) { short = short.substring(0, 2); } weekdaysMonFirst.push(short); } const startOfWeek: 'monday' | 'sunday' = DateInputHelper.getStartOfWeek() === 0 ? 'sunday' : 'monday'; const weekdaysOrdered = startOfWeek === 'monday' ? weekdaysMonFirst : [ weekdaysMonFirst[6], ...weekdaysMonFirst.slice(0, 6), ]; // dateTimeField labels — Intl.DisplayNames returns the locale name for the // field itself ("day"/"deň"/"Tag"/…). [capitalize] matches the legacy UI casing. const fieldNames = new Intl.DisplayNames([intlTag], { type: 'dateTimeField' }); const dayLabel = (fieldNames.of('day') ?? 'day')[capitalize](); const hourLabel = (fieldNames.of('hour') ?? 'hour')[capitalize](); const minuteLabel = (fieldNames.of('minute') ?? 'minute')[capitalize](); // Pre-built once per locale; reuse for any N. NumberFormat handles plural // rules ("1 deň", "2 dni", "5 dní" for Slovak) in a single call. const dayUnitFormatter = new Intl.NumberFormat(intlTag, { style: 'unit', unit: 'day', unitDisplay: 'long', }); return { lang: LANGUAGES[key] ?? LANGUAGES.default, startOfWeek, monthNames, weekdaysOrdered, dayLabel, hourLabel, minuteLabel, dayUnitFormatter, }; }; const getLocaleData = (): PickerLocaleData => { const key = resolveLocaleKey(); const cached = localeDataCache[key]; if (cached) { return cached; } const data = buildLocaleData(key); localeDataCache[key] = data; return data; }; const translate = (key: keyof PickerLanguage): string => getLocaleData().lang[key] ?? LANGUAGES.default[key]; const getMonthName = (monthIdx: number): string => getLocaleData().monthNames[monthIdx - 1]; const getStartOfWeek = (): 'monday' | 'sunday' => getLocaleData().startOfWeek; /** Format "N day(s)" using the locale's plural rules via Intl.NumberFormat. */ const formatDayCount = (count: number): string => getLocaleData().dayUnitFormatter.format(count); const utcEpochAtMidnight = ( year: number, month: number, day: number, ): number => // eslint-disable-next-line no-restricted-syntax -- UTC epoch for grid keying Date.UTC( year, month - 1, day, ); /** * Format a same-month range using the locale's format string by replacing the * day token with "fromDay-toDay". Works for any format that uses lowercase `d` * for the day component. */ const formatDayRange = ( from: Temporal.PlainDateTime, to: Temporal.PlainDateTime, fmt: string, ): string => { const placeholder = '☃'; // unique sentinel — will not appear in any locale string const stripped = fmt.replace(/d+/g, placeholder); const formatted = DateUtils.formatDate(from, stripped); return formatted.replace(placeholder, `${from.day}-${to.day}`); }; // ── Component ────────────────────────────────────────────────────────────── @Component class DaterangePickerComponent extends TsxComponent implements DaterangePickerArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() cssClass!: string; @Prop() subtitle!: string; @Prop() value!: DaterangeChangedArgs; @Prop() placeholder!: string; @Prop() mandatory!: boolean; @Prop() customInputText?: () => string; @Prop() inputReadOnly?: boolean; @Prop() alwaysOpen!: boolean; @Prop() singleDate!: boolean; @Prop() pickerRootCssClass!: string; @Prop() forceModalMode?: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() getCell?: (args: DaterangePickerCellRenderArgs) => VNode; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() prependIconClicked: () => void; @Prop() appendIconClicked: () => void; @Prop() marginType?: MarginType; @Prop() calendarPlacement?: CalendarPlacement; @Prop() changed: (newValue: DaterangeChangedArgs) => void; @Prop() format?: string; @Prop() showClearValueButton!: boolean; @Prop() autoClose?: boolean; @Prop() commitPartialRangeOnClose?: boolean; @Prop() enableTime?: boolean; @Prop() separator?: string; @Prop() singleMonth?: boolean; @Prop() minDate?: Temporal.PlainDateTime; @Prop() maxDate?: Temporal.PlainDateTime; @Prop() defaultAnchorDate?: Temporal.PlainDateTime; @Prop() prevIcon?: string; @Prop() nextIcon?: string; @Prop() hideYearInMonthName?: boolean; @Prop() disabled!: boolean; @Prop() readOnly?: boolean; @Prop() monthSelect!: boolean; @Prop() yearSelect!: boolean; @Prop() customTooltip?: (dayCount: number) => VNode | string; @Prop() maxDays?: number; @Prop() maxDaysTooltip?: (maxDays: number) => VNode | string; @Prop() ensureMonthContinuity?: boolean; @Prop() multiDate?: boolean; @Prop() multiValue?: Temporal.PlainDateTime[]; @Prop() multiChanged?: (dates: Temporal.PlainDateTime[]) => void; @Prop() multiSummaryThreshold?: number; @Prop() mobileVariant?: MobileVariant; @Prop() confirmText?: string; @Prop() cancelText?: string; @Prop() confirmedByEnter?: () => void; @Prop() footerInfo?: VNode | string; // State _startDate: Temporal.PlainDateTime = null; _endDate: Temporal.PlainDateTime = null; _multiDates: Temporal.PlainDateTime[] = []; _multiDatesEntry: Temporal.PlainDateTime[] = []; // snapshot at modal open _anchorMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }); _hovering: Temporal.PlainDateTime = null; _isOpen: boolean = false; // Keyboard-navigation: epoch-ms of the day cell the keyboard "cursor" is on // while the calendar is open. Drives the `.kbd-focused` highlight and the // input's aria-activedescendant. Null = keyboard grid nav not engaged. _kbdFocusMs: number | null = null; private readonly _kbdUid: string = `drp-kbd-${(drpKbdInstanceSeq += 1)}`; _winWidth: number = 0; _winHeight: number = 0; _outsideHandler: ((e: MouseEvent) => void) | null = null; _resizeHandler: (() => void) | null = null; _scrollOrResizeHandler: (() => void) | null = null; _scrollRafId: number | null = null; _skipNextFocus: boolean = false; _activeClipper: HTMLElement | null = null; _dropdownAnchor: 'left' | 'right' = 'left'; _dropdownVertical: 'down' | 'up' | 'fixed' = 'down'; _editingText: string | null = null; _hoverCellAnchor: { left: number; top: number; width: number } | null = null; /** The currently-visible month inside the vertical-scroll modal (used by the selectors). */ _mvsAnchor: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }); /** Absolute first month rendered in the scroll list. */ _mvsStartMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }).subtract({ months: 1 }); /** Absolute last month rendered in the scroll list. */ _mvsEndMonth: Temporal.PlainDateTime = Temporal.Now.plainDateTimeISO().with({ day: 1 }).add({ months: 12 }); /** Reentrancy guard: don't run scroll handler while we're programmatically adjusting scrollTop. */ _mvsScrollLock: boolean = false; // ── Lifecycle ───────────────────────────────────────────────────────── beforeMount() { this.syncFromValue(); // Seed the always-open mobile vertical-scroll window from the same anchor // so the inline mobile calendar renders centered on the opening month. // SSR + client safe — Temporal math only, no DOM/window access. The open() // path recomputes these for modal/toggle usage. const anchor = this.getInitialAnchor(); this._mvsAnchor = anchor; this._mvsStartMonth = prevMonthDate(anchor); this._mvsEndMonth = anchor.add({ months: 12 }); } mounted() { if (!globalState.windowExists) { return; } this._winWidth = window.innerWidth; this._winHeight = window.innerHeight; this._resizeHandler = () => { this._winWidth = window.innerWidth; this._winHeight = window.innerHeight; }; window.addEventListener('resize', this._resizeHandler); if (this.alwaysOpen) { this._isOpen = true; } // On a verticalScroll mvs picker, scroll the month list to the selection's // start month (e.g. the user closed and re-opened the bottom sheet) or — // when nothing is selected — to the opt-in default anchor, so the user lands // on the first available month rather than the current month. const scrollTarget = this.value?.startTime ?? this.defaultAnchorDate; if (this.mobileVariant === 'verticalScroll' && MobileModeConfig.shouldDisplayInModal() && scrollTarget) { this.$nextTick(() => { this.scrollToMonth(scrollTarget.year, scrollTarget.month); }); } } @Watch('value', { deep: true }) private onValuePropChanged() { this.syncFromValue(); } @Watch('multiValue', { deep: true }) private onMultiValuePropChanged() { this.syncFromValue(); } @Watch('defaultAnchorDate') private onDefaultAnchorDateChanged() { // An anchor that arrives after mount (async price load on a picker that // is not key-remounted) still re-anchors while no value is selected. this.syncFromValue(); } beforeUnmount() { this.detachOutsideHandler(); if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } } // ── Public API ──────────────────────────────────────────────────────── isOpened(): boolean { return this._isOpen; } open(): void { if (this.disabled) { return; } if (this._isOpen) { return; } this._isOpen = true; // Snapshot the current selection BEFORE any user clicks so Cancel can // revert. Required for desktop AND modal — used to only run on modal. this.snapshotMulti(); const reference = (this._startDate ?? Temporal.Now.plainDateTimeISO()).with({ day: 1 }); this._mvsAnchor = reference; this._mvsStartMonth = prevMonthDate(reference); this._mvsEndMonth = reference.add({ months: 12 }); if (this.useModalMode()) { this.$nextTick(() => { (this.$refs.drpnModal as any)?.show({ onHidden: () => { this._isOpen = false; }, }); this.$nextTick(() => { this.scrollMvsToCurrent(); }); }); return; } this.$nextTick(() => { this.attachOutsideHandler(); this.adjustDropdownAnchor(); }); } /** * Opens the picker AND focuses the inner input — useful when programmatically * advancing focus from another field (e.g. an Enter-key chain). Avoids the * caller having to reach into the picker's DOM with querySelector. */ openDropdown(): void { if (this._isOpen) { // Already open — do not re-focus the inner input. A re-focus call here // steals focus from any element the user is currently interacting with // (notably the inline month/year loses focus). See GOBO-187. return; } this.open(); this.$nextTick(() => { (this.$refs.innerInput as HTMLInputElement | undefined)?.focus(); }); } private scrollMvsToCurrent() { const scroll = this.$refs.mvsScroll as HTMLElement | undefined; if (!scroll) { return; } const tgt = scroll.querySelector(`[data-anchor="${this._mvsAnchor.year}-${this._mvsAnchor.month}"]`) as HTMLElement | null; if (tgt) { scroll.scrollTop = tgt.offsetTop - 8; } } private adjustDropdownAnchor() { if (!globalState.windowExists) { return; } if (this.calendarPlacement === 'inline' || this.calendarPlacement === 'input-container-leftalign') { this._dropdownAnchor = 'left'; this._dropdownVertical = 'down'; return; } if (this.calendarPlacement === 'input-container-rightalign') { this._dropdownAnchor = 'right'; this._dropdownVertical = 'down'; return; } const wrap = (this.$el as HTMLElement)?.querySelector('.drpn-wrap') as HTMLElement | null; const dropdown = (this.$el as HTMLElement)?.querySelector('.drpn-dropdown') as HTMLElement | null; if (!wrap || !dropdown) { return; } // Measure once, share across both axes — avoids double DOM walks // (clipper detection) and redundant getBoundingClientRect() calls. const SAFETY_MARGIN_PX = 8; const wrapRect = wrap.getBoundingClientRect(); const dropdownWidth = dropdown.offsetWidth || 600; const dropdownHeight = dropdown.offsetHeight || 326; const clipper = this.findClippingAncestor(wrap); // ── Horizontal axis ──────────────────────────────────────────────── // When a clipper is found, measure inside it (the clipper may be // narrower than the viewport while the picker still sits inside the // viewport). Without this, the original viewport-only check failed // to flip when e.g. the inner .card.with-card-shadow was too narrow // for the calendar but the viewport itself was wide enough. if (clipper) { const clipperRect = clipper.getBoundingClientRect(); const spaceRight = clipperRect.right - wrapRect.left; // anchor 'left', extends right const spaceLeft = wrapRect.right - clipperRect.left; // anchor 'right', extends left if (dropdownWidth + SAFETY_MARGIN_PX <= spaceRight) { this._dropdownAnchor = 'left'; } else if (dropdownWidth + SAFETY_MARGIN_PX <= spaceLeft) { this._dropdownAnchor = 'right'; } else { // Won't fit either way inside the clipper — pick the side with more room. // If the vertical axis falls through to position:fixed, the fixed-mode // math uses _dropdownAnchor to compute `left` and clamps to viewport. this._dropdownAnchor = spaceRight >= spaceLeft ? 'left' : 'right'; } } else { // Page-level scroll context — viewport is the discriminator (status quo). const overflowsRight = wrapRect.left + dropdownWidth > window.innerWidth - SAFETY_MARGIN_PX; this._dropdownAnchor = overflowsRight ? 'right' : 'left'; } // Measure available 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 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; // When both fit, prefer the side with more breathing room so the // picker isn't pinned against the viewport edge. if (fitsAbove && (!fitsBelow || spaceAbove > spaceBelow)) { this._dropdownVertical = 'up'; this.clearFixedInlineStyles(dropdown); return; } if (fitsBelow) { this._dropdownVertical = 'down'; this.clearFixedInlineStyles(dropdown); return; } if (!clipper) { this._dropdownVertical = spaceBelow >= spaceAbove ? 'down' : 'up'; this.clearFixedInlineStyles(dropdown); return; } // position:fixed resolves against the nearest transformed ancestor in // some browsers — fall back to 'up' rather than mis-pinning. if (this.hasTransformedAncestor(wrap)) { this._dropdownVertical = 'up'; this.clearFixedInlineStyles(dropdown); return; } // Position: fixed — pin to viewport. Clamp `left` so the dropdown can't // extend past viewport edges (the existing math could push the right edge // off-screen if the picker sat far right). this._dropdownVertical = 'fixed'; const rawLeft = this._dropdownAnchor === 'right' ? wrapRect.right - dropdownWidth : wrapRect.left; const minLeft = SAFETY_MARGIN_PX; const maxLeft = window.innerWidth - dropdownWidth - SAFETY_MARGIN_PX; const left = Math.min(Math.max(rawLeft, minLeft), maxLeft); const maxAllowedTop = window.innerHeight - dropdownHeight - SAFETY_MARGIN_PX; const rawTop = wrapRect.bottom + 4; const top = Math.min(Math.max(rawTop, SAFETY_MARGIN_PX), maxAllowedTop); dropdown.style.left = `${left}px`; dropdown.style.top = `${top}px`; dropdown.style.right = 'auto'; dropdown.style.bottom = 'auto'; } 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; } private hasTransformedAncestor(start: HTMLElement): boolean { let el = start.parentElement; while (el && el !== document.body && el !== document.documentElement) { const cs = getComputedStyle(el); if (cs.transform !== 'none' || cs.perspective !== 'none' || cs.filter !== 'none') { return true; } el = el.parentElement; } return false; } private clearFixedInlineStyles(dropdown: HTMLElement) { dropdown.style.left = ''; dropdown.style.top = ''; dropdown.style.right = ''; dropdown.style.bottom = ''; } close(): void { if (this.alwaysOpen) { return; } // Clear keyboard grid focus so the next open starts fresh. this._kbdFocusMs = null; // Reset in-progress range: closing without picking the second date // (outside-click, Escape, programmatic) clears any partial selection // AND notifies the parent that no range is selected. Without this, // the abandoned _startDate would leak into the next open. if (this.getMode() === 'range' && this._startDate != null && this._endDate == null) { if (this.commitPartialRangeOnClose) { // Opt-in: commit the lone picked date as a same-day range instead // of discarding it, so the parent receives a "pick one day" signal // on close. The committed value round-trips back in via `value`, so // no stale _startDate leaks into the next open. this._endDate = this._startDate; } else { this._startDate = null; this._endDate = null; } this.raiseChanged(); } if (this.useModalMode()) { (this.$refs.drpnModal as any)?.hide(); return; } this._isOpen = false; this.detachOutsideHandler(); } toggle(): void { if (this._isOpen) { this.close(); } else { this.open(); } } openInModal(): void { this.open(); } clearValue(): void { this._startDate = null; this._endDate = null; this._multiDates = []; this.raiseChanged(); } // ── Open / close helpers ────────────────────────────────────────────── private attachOutsideHandler() { this.detachOutsideHandler(); const self = this; this._outsideHandler = (e: MouseEvent) => { const target = e.target as Node; if (self.$el && !self.$el.contains(target)) { self.close.call(self); } }; this._scrollOrResizeHandler = () => { // Debounce via rAF — scroll fires per pixel; we only need one // re-measure per frame, and we want it timed to the next paint. if (self._scrollRafId !== null) { return; } self._scrollRafId = window.requestAnimationFrame(() => { self._scrollRafId = null; if (self._isOpen) { self.adjustDropdownAnchor.call(self); } }); }; document.addEventListener('mousedown', this._outsideHandler); window.addEventListener('resize', this._scrollOrResizeHandler); const wrap = (this.$el as HTMLElement)?.querySelector('.drpn-wrap') as HTMLElement | null; if (wrap) { this._activeClipper = this.findClippingAncestor(wrap); if (this._activeClipper) { this._activeClipper.addEventListener( 'scroll', this._scrollOrResizeHandler, { passive: true }, ); } } } private detachOutsideHandler() { if (this._outsideHandler) { document.removeEventListener('mousedown', this._outsideHandler); this._outsideHandler = null; } if (this._scrollOrResizeHandler) { window.removeEventListener('resize', this._scrollOrResizeHandler); if (this._activeClipper) { this._activeClipper.removeEventListener('scroll', this._scrollOrResizeHandler); this._activeClipper = null; } this._scrollOrResizeHandler = null; } if (this._scrollRafId !== null) { window.cancelAnimationFrame(this._scrollRafId); this._scrollRafId = null; } } // ── Mode resolution ─────────────────────────────────────────────────── useModalMode(): boolean { if (this.forceModalMode) { return true; } if (this.alwaysOpen && this.calendarPlacement != null && this.calendarPlacement !== 'body') { return false; } if (MobileModeConfig.shouldDisplayInModal()) { return true; } return false; } getMode(): 'single' | 'range' | 'multi' { if (this.multiDate === true) { return 'multi'; } if (this.singleDate === true) { return 'single'; } return 'range'; } getEffectiveSingleMonth(): boolean { if (this.singleMonth === true) { return true; } if (!globalState.windowExists) { return false; } if (this._winWidth < 480) { return true; } if ( this._winWidth < SINGLE_MONTH_THRESHOLD_W && this._winHeight < SINGLE_MONTH_THRESHOLD_H && !this.enableTime ) { return true; } return false; } getMobileVariant(): MobileVariant { if (this.mobileVariant != null) { return this.mobileVariant; } return MobileModeConfig.shouldDisplayInModal() ? 'verticalScroll' : 'paginate'; } getFormat(): string { if (this.format) { return this.format; } return this.enableTime ? DATE_FORMAT_FOR_RANGE_PICKER_WITH_TIME : DATE_FORMAT_FOR_RANGE_PICKER; } getSeparator(): string { return this.separator ?? ' ~ '; } // ── Value sync ──────────────────────────────────────────────────────── /** * The month the calendar should open on when nothing is selected. Priority: * an active selection (single/range start, or the first multi date), then the * opt-in `defaultAnchorDate`, then today — so callers that pass neither prop * keep the current-month behaviour exactly. */ private getInitialAnchor(): Temporal.PlainDateTime { const base = this.value?.startTime ?? (this.getMode() === 'multi' ? this.multiValue?.[0] : null) ?? this.defaultAnchorDate ?? Temporal.Now.plainDateTimeISO(); return base.with({ day: 1 }); } private syncFromValue() { // Single source of truth for the opening month — covers the multi, // value-null and value-set paths uniformly. Assigned before the multi // early-return so an unchanged multiValue still re-anchors once a // `defaultAnchorDate` arrives. this._anchorMonth = this.getInitialAnchor(); if (this.getMode() === 'multi') { const incoming = this.multiValue ?? []; if (this.areMultiEqual(incoming, this._multiDates)) { return; } this._multiDates = incoming.slice(); return; } const v = this.value; if (v == null || v.startTime == null) { this._startDate = null; this._endDate = null; return; } this._startDate = v.startTime; this._endDate = v.endTime ?? v.startTime; } private areMultiEqual(a: Temporal.PlainDateTime[], b: Temporal.PlainDateTime[]): boolean { if (a === b) { return true; } if (!a || !b) { return false; } if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (!sameDay(a[i], b[i])) { return false; } } return true; } private snapshotMulti() { this._multiDatesEntry = (this._multiDates ?? []).slice(); } private raiseChanged() { if (this.getMode() === 'multi') { this.multiChanged?.(this._multiDates.slice()); return; } if (this.changed == null) { return; } if (this._startDate == null) { this.changed(null); return; } this.changed({ startTime: this._startDate, endTime: this._endDate ?? this._startDate, }); } // ── Selection logic ─────────────────────────────────────────────────── private isInvalidByBounds(date: Temporal.PlainDateTime): boolean { if (this.minDate && compareDay(date, this.minDate) < 0) { return true; } if (this.maxDate && compareDay(date, this.maxDate) > 0) { return true; } return false; } private isInvalidByMaxDays(date: Temporal.PlainDateTime): boolean { if (this.getMode() !== 'range') { return false; } if (!this.maxDays || this.maxDays <= 0) { return false; } if (this._startDate == null || this._endDate != null) { return false; } const days = Math.abs(this.daysBetween(date, this._startDate)) + 1; return days > this.maxDays; } private daysBetween(a: Temporal.PlainDateTime, b: Temporal.PlainDateTime): number { const ams = utcEpochAtMidnight( a.year, a.month, a.day, ); const bms = utcEpochAtMidnight( b.year, b.month, b.day, ); return Math.round((ams - bms) / MS_PER_DAY); } private isCellSelectable(date: Temporal.PlainDateTime): boolean { if (this.isInvalidByBounds(date)) { return false; } if (this.isInvalidByMaxDays(date)) { return false; } return true; } dayClicked(date: Temporal.PlainDateTime) { if (!this.isCellSelectable(date)) { return; } const mode = this.getMode(); if (mode === 'single') { this._startDate = date; this._endDate = date; this.raiseChanged(); if (this.autoClose !== false) { this.close(); } return; } if (mode === 'multi') { const idx = this._multiDates.findIndex(d => sameDay(d, date)); if (idx >= 0) { this._multiDates.splice(idx, 1); } else { if (this.maxDays && this._multiDates.length >= this.maxDays) { return; } this._multiDates.push(date); this._multiDates.sort((a, b) => compareDay(a, b)); } return; // Confirm/Cancel commits the change in multi mode } // Range mode if ((this._startDate && this._endDate) || (!this._startDate && !this._endDate)) { this._startDate = date; this._endDate = null; this._hovering = null; } else if (this._startDate) { let s = this._startDate; let e = date; if (compareDay(s, e) > 0) { const tmp = s; s = e; e = tmp; } this._startDate = s; this._endDate = e; this.raiseChanged(); if (this.autoClose !== false) { this.close(); } } } dayHovered(date: Temporal.PlainDateTime, cellEl?: HTMLElement) { if (this.getMode() !== 'range') { return; } if (!this.isCellSelectable(date)) { // Disabled cells (out of bounds OR past maxDays cap) MUST NOT participate // in the hover preview. Without this guard, the cell receives the // `hovering` / `hoveringCurrentFirst` / `hoveringCurrentBefore` classes // and shop-side SCSS paints a dark filled circle on a date the user // cannot pick. See GOBO-187 (bug 2). this._hovering = null; this._hoverCellAnchor = null; return; } if (this._startDate && !this._endDate) { this._hovering = date; if (cellEl) { const root = (this.$el as HTMLElement)?.querySelector('.drpn-content') as HTMLElement | null; if (root) { const cellRect = cellEl.getBoundingClientRect(); const rootRect = root.getBoundingClientRect(); this._hoverCellAnchor = { left: cellRect.left - rootRect.left + cellRect.width / 2, top: cellRect.top - rootRect.top, width: cellRect.width, }; } } } } clearHover() { this._hovering = null; this._hoverCellAnchor = null; } // ── Cell state classification ───────────────────────────────────────── /** * Build a per-render context with all values pre-computed once so each * day-cell classification is just integer comparisons + Set lookups. * Cell counts run 84+ per render; we don't want Temporal allocations or * O(N) array scans inside that loop. */ private buildRenderCtx(): RenderCtx { const todayPlain = Temporal.Now.plainDateTimeISO(); const todayMs = utcEpochAtMidnight( todayPlain.year, todayPlain.month, todayPlain.day, ); const startMs = this._startDate ? utcEpochAtMidnight( this._startDate.year, this._startDate.month, this._startDate.day, ) : null; const endMs = this._endDate ? utcEpochAtMidnight( this._endDate.year, this._endDate.month, this._endDate.day, ) : null; const hoverMs = this._hovering ? utcEpochAtMidnight( this._hovering.year, this._hovering.month, this._hovering.day, ) : null; const minMs = this.minDate ? utcEpochAtMidnight( this.minDate.year, this.minDate.month, this.minDate.day, ) : null; const maxMs = this.maxDate ? utcEpochAtMidnight( this.maxDate.year, this.maxDate.month, this.maxDate.day, ) : null; const mode = this.getMode(); // Only iterate `_multiDates` when actually in multi mode. Range/single // modes never query the Set; sharing a single empty sentinel saves a // Set allocation per render. let multiKeys: Set = EMPTY_NUMBER_SET; if (mode === 'multi' && this._multiDates.length > 0) { multiKeys = new Set(); for (const d of this._multiDates) { multiKeys.add(utcEpochAtMidnight( d.year, d.month, d.day, )); } } // maxDays validity in range mode: only when start picked but end is not const maxDaysCap = (mode === 'range' && this.maxDays && this.maxDays > 0 && startMs != null && endMs == null) ? this.maxDays : 0; return { mode, todayMs, startMs, endMs, hoverMs, minMs, maxMs, multiKeys, maxDaysCap, }; } private classifyDay( date: Temporal.PlainDateTime, anchorMonth: Temporal.PlainDateTime, ctx: RenderCtx, cellMs: number, ) { // Cheap integer comparisons; no Temporal allocations. const sameMonth = date.year === anchorMonth.year && date.month === anchorMonth.month; const type: 'toMonth' | 'lastMonth' | 'nextMonth' = sameMonth ? 'toMonth' : (date.year < anchorMonth.year || (date.year === anchorMonth.year && date.month < anchorMonth.month)) ? 'lastMonth' : 'nextMonth'; const isToday = cellMs === ctx.todayMs; const isMulti = ctx.mode === 'multi'; const isInMulti = isMulti && ctx.multiKeys.has(cellMs); const isStart = !isMulti && ctx.startMs != null && cellMs === ctx.startMs; const isEnd = !isMulti && ctx.endMs != null && cellMs === ctx.endMs; const inRange = !isMulti && ctx.startMs != null && ctx.endMs != null && cellMs >= ctx.startMs && cellMs <= ctx.endMs; // Hover preview (range only, partial selection). // // When `maxDaysCap` is active (range mode + maxDays > 0 + start picked + // end not picked) we clip the *preview range* at the inclusive cap // (`startMs ± (maxDaysCap-1) days`). Without the clip the hover // highlight extends across cells that are visibly `.invalid` (greyed // out by the same cap), giving the user the false impression they are // about to commit a range past the cap. Past-cap cells therefore still // get only the static `.invalid` look — no preview overlay on top. let hovering = false; let hoveringCurrentFirst = false; let hoveringCurrentLast = false; let hoveringCurrentBefore = false; if (ctx.mode === 'range' && ctx.hoverMs != null) { const startSelected = ctx.startMs != null && ctx.endMs == null; const noneSelected = ctx.startMs == null && ctx.endMs == null; const bothSelected = ctx.startMs != null && ctx.endMs != null; // Compute the effective preview destination, clipped at the cap. let effectiveHoverMs = ctx.hoverMs; if (startSelected && ctx.maxDaysCap > 0) { const capRangeMs = (ctx.maxDaysCap - 1) * MS_PER_DAY; const capForward = ctx.startMs! + capRangeMs; const capBackward = ctx.startMs! - capRangeMs; if (effectiveHoverMs > capForward) { effectiveHoverMs = capForward; } else if (effectiveHoverMs < capBackward) { effectiveHoverMs = capBackward; } } // "Current" (the cell under cursor) — only mark cells the user can // actually commit. If the cursor is past the cap, the would-be // commit endpoint is the cap day, not the cursor cell, so we // suppress the hovering-current marker on the past-cap cell. const cursorWithinCap = effectiveHoverMs === ctx.hoverMs; if (cellMs === ctx.hoverMs && cursorWithinCap) { if (noneSelected || bothSelected) { hoveringCurrentFirst = true; } else if (ctx.hoverMs < ctx.startMs!) { hoveringCurrentBefore = true; } else { hoveringCurrentLast = true; } } if (startSelected) { const lo = Math.min(effectiveHoverMs, ctx.startMs!); const hi = Math.max(effectiveHoverMs, ctx.startMs!); if (cellMs >= lo && cellMs <= hi) { hovering = true; } } } // Validity: bounds + maxDays cap let valid = true; if (ctx.minMs != null && cellMs < ctx.minMs) { valid = false; } else if (ctx.maxMs != null && cellMs > ctx.maxMs) { valid = false; } else if (ctx.maxDaysCap > 0 && ctx.startMs != null) { const days = Math.abs(Math.round((cellMs - ctx.startMs) / MS_PER_DAY)) + 1; if (days > ctx.maxDaysCap) { valid = false; } } // Cap indicator: the inclusive last-day the user can pick under the // active maxDays cap. Marked with `.max-day-cap` so CSS can render a // subtle "fence-post" boundary (dashed right border) telling the user // "this is the furthest you can go". let isMaxDayCap = false; if (ctx.maxDaysCap > 0 && ctx.startMs != null) { const capForward = ctx.startMs + (ctx.maxDaysCap - 1) * MS_PER_DAY; if (cellMs === capForward) { isMaxDayCap = true; } } return { type, isToday, isInMulti, isStart, isEnd, inRange, hovering, hoveringCurrentFirst, hoveringCurrentLast, hoveringCurrentBefore, isMaxDayCap, valid, }; } // ── Render: input ───────────────────────────────────────────────────── /** * Format and cache the start date once per render-cycle. The cache key is * the format string + the date's epoch ms — both stable within a render, * so back-to-back calls (input + topbar) reuse one formatDate result. */ private _startFmtCache: { ms: number; fmt: string; out: string } | null = null; private getStartFormatted(): string { if (this._startDate == null) { return ''; } const ms = utcEpochAtMidnight( this._startDate.year, this._startDate.month, this._startDate.day, ) + (this.enableTime ? this._startDate.hour * 3600000 + this._startDate.minute * 60000 : 0); const fmt = this.getFormat(); const cached = this._startFmtCache; if (cached && cached.ms === ms && cached.fmt === fmt) { return cached.out; } const out = DateUtils.formatDate(this._startDate, fmt); this._startFmtCache = { ms, fmt, out }; return out; } private _endFmtCache: { ms: number; fmt: string; out: string } | null = null; private getEndFormatted(): string { if (this._endDate == null) { return ''; } const ms = utcEpochAtMidnight( this._endDate.year, this._endDate.month, this._endDate.day, ) + (this.enableTime ? this._endDate.hour * 3600000 + this._endDate.minute * 60000 : 0); const fmt = this.getFormat(); const cached = this._endFmtCache; if (cached && cached.ms === ms && cached.fmt === fmt) { return cached.out; } const out = DateUtils.formatDate(this._endDate, fmt); this._endFmtCache = { ms, fmt, out }; return out; } /** Memoizes formatMultiDates output keyed by date list signature + format. */ private _multiFmtCache: { sig: string; fmt: string; out: string } | null = null; private getMultiFormatted(): string { const fmt = this.getFormat(); // Build a cheap signature from epoch days; scales O(N) but only on cache miss. const sig = this._multiDates .map(d => utcEpochAtMidnight( d.year, d.month, d.day, )) .sort((a, b) => a - b) .join(','); const cached = this._multiFmtCache; if (cached && cached.sig === sig && cached.fmt === fmt) { return cached.out; } const out = this.formatMultiDates(this._multiDates, fmt); this._multiFmtCache = { sig, fmt, out }; return out; } getDisplayText(): string { if (this.customInputText != null) { return this.customInputText() ?? ''; } const mode = this.getMode(); if (mode === 'multi') { const count = this._multiDates.length; if (count === 0) { return ''; } if (this.multiSummaryThreshold != null && count > this.multiSummaryThreshold) { return `${translate('selected')} ${formatDayCount(count)}`; } return this.getMultiFormatted(); } if (this._startDate == null) { return ''; } const startStr = this.getStartFormatted(); if (mode === 'single' || this.singleDate) { return startStr; } // Range mode: don't leak in-progress selection. Between the first and // second click _startDate is set but _endDate is null — returning // startStr here would make the input look "selected" with a single // date. Hydration via syncFromValue() always fills _endDate (defaults // to _startDate), so this branch only fires during in-progress picks. if (this._endDate == null) { return ''; } return startStr + this.getSeparator() + this.getEndFormatted(); } private formatMultiDates(dates: Temporal.PlainDateTime[], fmt: string): string { // Annotate with epoch ms once; sort + grouping become integer ops. const annotated = dates.map(d => ({ date: d, ms: utcEpochAtMidnight( d.year, d.month, d.day, ), })).sort((a, b) => a.ms - b.ms); const groups: Array<{ from: Temporal.PlainDateTime; to: Temporal.PlainDateTime; toMs: number }> = []; for (const item of annotated) { const last = groups[groups.length - 1]; if (last && (item.ms - last.toMs) === MS_PER_DAY) { last.to = item.date; last.toMs = item.ms; } else { groups.push({ from: item.date, to: item.date, toMs: item.ms }); } } return groups.map((g) => { if (g.from === g.to || (g.from.year === g.to.year && g.from.month === g.to.month && g.from.day === g.to.day)) { return DateUtils.formatDate(g.from, fmt); } if (g.from.year === g.to.year && g.from.month === g.to.month) { return formatDayRange( g.from, g.to, fmt, ); } return `${DateUtils.formatDate(g.from, fmt)} - ${DateUtils.formatDate(g.to, fmt)}`; }).join(', '); } private renderInput(): VNode { const displayText = this.getDisplayText(); const value = this._editingText != null ? this._editingText : displayText; const handleOpen = () => { // Reset the focus-skip flag so a stale value (set by a prior mousedown // that didn't lead to a click) can't suppress the next legitimate // Tab/keyboard focus. this._skipNextFocus = false; if (!this.disabled) { this.toggle(); } }; const isModal = this.useModalMode(); const isReadOnly = this.readOnly === true || this.inputReadOnly === true || isModal; return (
{ this._skipNextFocus = true; }} onClick={handleOpen} > this.onInputType(e.target.value)} onFocus={() => { // Click-induced focus: the wrapper's onClick handles toggle. // We must NOT also open here, or the click bubbling to onClick // would then toggle-close (visible as a flash of the dropdown). const skip = this._skipNextFocus; this._skipNextFocus = false; if (skip) { return; } // Tab/keyboard-induced focus: open as before. if (!this.disabled && !this._isOpen) { this.open(); } }} onBlur={() => this.commitInputText()} aria-expanded={this._isOpen ? 'true' : 'false'} aria-activedescendant={this._isOpen && this._kbdFocusMs != null ? `${this._kbdUid}-${this._kbdFocusMs}` : undefined} onKeydown={(e: KeyboardEvent) => { // Arrow/Enter calendar navigation takes priority while open. if (this.handleCalendarKeydown(e)) { return; } if (e.key === 'Enter') { e.preventDefault(); this.commitInputText(); this.close(); this.confirmedByEnter?.(); } else if (e.key === 'Escape') { e.preventDefault(); this._editingText = null; this.close(); } else if (e.key === 'Tab' && !e.shiftKey) { // Forward-Tab matches Enter's "confirm and advance" semantic. // preventDefault so the browser's default focus-shift doesn't // race with the consumer's confirmedByEnter handler (which // typically opens the next picker and focuses its inner input). // Shift+Tab is left to the browser for natural reverse navigation. e.preventDefault(); this.commitInputText(); this.close(); this.confirmedByEnter?.(); } }} /> {this.showClearValueButton && this._startDate && ( { e.stopPropagation(); }} onClick={(e: any) => { e.stopPropagation(); this.clearValue(); }} > × )}
); } private onInputType(text: string) { if (this.useModalMode()) { return; } this._editingText = text; } private commitInputText() { if (this._editingText == null) { return; } const text = this._editingText.trim(); this._editingText = null; if (text === '') { this._startDate = null; this._endDate = null; this._multiDates = []; this.raiseChanged(); return; } const fmt = this.getFormat(); if (this.getMode() === 'multi') { const parts = text.split(',').map(s => s.trim()).filter(Boolean); const parsed: Temporal.PlainDateTime[] = []; for (const p of parts) { const rangeMatch = p.match(/^(\d+)-(\d+)(\D.*)$/); if (rangeMatch) { const [ , fromDay, toDay, rest, ] = rangeMatch; // Pad to match `dd` width — single-digit "1" alone won't // satisfy the strict `dd` token in DateUtils.formatDate. const padWidth = fmt.includes('dd') ? 2 : 1; const fromStr = fromDay.padStart(padWidth, '0') + rest; const toStr = toDay.padStart(padWidth, '0') + rest; const fromDate = this.parseDate(fromStr, fmt); const toDate = this.parseDate(toStr, fmt); if (fromDate != null && toDate != null) { let cur = fromDate; while (compareDay(cur, toDate) <= 0) { parsed.push(cur); cur = cur.add({ days: 1 }); } continue; } } const d = this.parseDate(p, fmt); if (d != null) { parsed.push(d); } } this._multiDates = parsed; this.raiseChanged(); return; } const sep = this.getSeparator(); const parts = text.split(sep); const startD = this.parseDate(parts[0], fmt); if (this.singleDate || this.getMode() === 'single') { if (startD != null) { this._startDate = startD; this._endDate = startD; this.raiseChanged(); } return; } const endD = parts[1] ? this.parseDate(parts[1], fmt) : null; if (startD != null && endD != null) { this._startDate = startD; this._endDate = endD; this.raiseChanged(); } } private parseDate(text: string, format: string): Temporal.PlainDateTime | null { if (!text || !text.trim()) { return null; } try { const result = DateUtils.getTemporalFromFormat(text.trim(), format); if (result != null) { return result; } } catch { /* noop */ } try { return TemporalUtils.fromString(text.trim()); } catch { return null; } } // ── Render: month grid ──────────────────────────────────────────────── private getWeekHead(): VNode[] { // `weekdaysOrdered` is pre-built by Intl on first locale lookup and // already rotated to match `startOfWeek`. Per render this is 7 plain // array reads + 7 VNode creations. return getLocaleData().weekdaysOrdered.map(label => {label}); } private renderMonthHeader(anchor: Temporal.PlainDateTime, position: 'left' | 'right' | 'mobile'): VNode { const showPrev = position === 'left' || position === 'mobile'; const showNext = position === 'right' || position === 'mobile' || this.getEffectiveSingleMonth(); const monthName = getMonthName(anchor.month); const yearName = anchor.year; const prevIcon = this.prevIcon ? : '<'; const nextIcon = this.nextIcon ? : '>'; const monthEl = this.monthSelect !== false ? this.renderMonthSelect(anchor) : {monthName}; const yearEl = !this.hideYearInMonthName && (this.yearSelect !== false ? this.renderYearSelect(anchor) : {yearName}); return ( {showPrev && ( this.gotoPrevMonth(position)}>{prevIcon} )} {monthEl} {yearEl ? [ ' ', yearEl, ] : null} {showNext && ( this.gotoNextMonth(position)}>{nextIcon} )} ); } private renderMonthSelect(anchor: Temporal.PlainDateTime): VNode { const minM = (this.minDate && this.minDate.year === anchor.year) ? this.minDate.month : 1; const maxM = (this.maxDate && this.maxDate.year === anchor.year) ? this.maxDate.month : 12; const options: VNode[] = []; for (let m = 1; m <= 12; m++) { options.push(); } return ( {getMonthName(anchor.month)} {' '} ); } private renderYearSelect(anchor: Temporal.PlainDateTime): VNode { const minY = this.minDate ? Math.max(1900, this.minDate.year) : 1900; const maxY = this.maxDate ? this.maxDate.year : Math.max(anchor.year + 10, Temporal.Now.plainDateTimeISO().year + 10); const options: VNode[] = []; for (let y = minY; y <= maxY; y++) { options.push(); } return ( {anchor.year} {' '} ); } private onMonthSelectChange(m: number) { this._anchorMonth = this._anchorMonth.with({ month: m, day: 1 }); } private onYearSelectChange(y: number) { this._anchorMonth = this._anchorMonth.with({ year: y }); } private gotoPrevMonth(_position: 'left' | 'right' | 'mobile') { this._anchorMonth = prevMonthDate(this._anchorMonth); } private gotoNextMonth(_position: 'left' | 'right' | 'mobile') { this._anchorMonth = nextMonthDate(this._anchorMonth); } // ── Keyboard calendar navigation (WCAG 2.1.1) ───────────────────────────── // The text input keeps DOM focus; the active day is tracked via `_kbdFocusMs` // and surfaced to assistive tech with aria-activedescendant + a visible // `.kbd-focused` ring — the same activedescendant pattern SmartDropdown uses. private getInitialKbdFocusMs(): number { if (this._startDate != null) { return utcEpochAtMidnight( this._startDate.year, this._startDate.month, this._startDate.day, ); } // QA_AT-197 — no selection: focus the first SELECTABLE day of the shown month, not the raw // month-start (day 1 is often out of bounds / greyed out — e.g. only "today" is available). // `minDate` is the picker's first-selectable bound (the shop sets it to the first available day). const monthStartMs = utcEpochAtMidnight( this._anchorMonth.year, this._anchorMonth.month, 1, ); const minMs = this.minDate ? utcEpochAtMidnight( this.minDate.year, this.minDate.month, this.minDate.day, ) : null; return minMs != null && minMs > monthStartMs ? minMs : monthStartMs; } /** Scrolls the displayed month(s) so the keyboard-focused day stays in view. */ private ensureKbdFocusVisible(): void { if (this._kbdFocusMs == null) { return; } SHARED_TMP_DATE.setTime(this._kbdFocusMs); const year = SHARED_TMP_DATE.getUTCFullYear(); const month = SHARED_TMP_DATE.getUTCMonth() + 1; const focusYm = (year * 12) + (month - 1); const anchorYm = (this._anchorMonth.year * 12) + (this._anchorMonth.month - 1); // Desktop renders _anchorMonth and the following month; keep focus inside. if (focusYm < anchorYm || focusYm > anchorYm + 1) { this._anchorMonth = Temporal.PlainDateTime.from({ year, month, day: 1 }); } } private moveKbdFocus(deltaDays: number): void { // GOBO-354 — focus alone must not paint the day highlight; the FIRST arrow // press reveals it on the initial day (selection, else first selectable — // QA_AT-197), and only subsequent presses move it. if (this._kbdFocusMs == null) { this._kbdFocusMs = this.getInitialKbdFocusMs(); } else { this._kbdFocusMs += deltaDays * MS_PER_DAY; } this.ensureKbdFocusVisible(); this.$nextTick(() => { const root = this.$el as HTMLElement | null; root?.querySelector(`[data-time="${this._kbdFocusMs}"]`)?.scrollIntoView({ block: 'nearest' }); }); } private selectKbdFocusedDay(): void { if (this._kbdFocusMs == null) { return; } SHARED_TMP_DATE.setTime(this._kbdFocusMs); const date = Temporal.PlainDateTime.from({ year: SHARED_TMP_DATE.getUTCFullYear(), month: SHARED_TMP_DATE.getUTCMonth() + 1, day: SHARED_TMP_DATE.getUTCDate(), }); this.dayClicked(date); } /** Handles arrow/enter while the calendar is open. Returns true if handled. */ private handleCalendarKeydown(e: KeyboardEvent): boolean { // Works both for the popup (driven by the input) and the inline / always-open // calendar (driven by the focusable calendar container — no input there). const inlineMode = this.alwaysOpen || this.calendarPlacement === 'inline'; if (!this._isOpen && !inlineMode) { return false; } switch (e.key) { case 'ArrowDown': e.preventDefault(); this.moveKbdFocus(7); return true; case 'ArrowUp': e.preventDefault(); this.moveKbdFocus(-7); return true; case 'ArrowLeft': // Popup mode only: don't hijack horizontal arrows until grid nav is // engaged, so the text cursor keeps working while typing a date. The // inline calendar has no input — engage immediately (GOBO-354). if (this._kbdFocusMs == null && !inlineMode) { return false; } e.preventDefault(); this.moveKbdFocus(-1); return true; case 'ArrowRight': if (this._kbdFocusMs == null && !inlineMode) { return false; } e.preventDefault(); this.moveKbdFocus(1); return true; case 'Enter': if (this._kbdFocusMs != null) { e.preventDefault(); this.selectKbdFocusedDay(); return true; } return false; default: return false; } } private renderMonthGrid( anchor: Temporal.PlainDateTime, layout: 'month1' | 'month2' | 'mobile', ctx: RenderCtx, ): VNode { const rows = this.buildMonthRows(anchor, ctx); return ( {this.renderMonthHeader(anchor, layout === 'month1' ? 'left' : layout === 'month2' ? 'right' : 'mobile')} {this.getWeekHead()}{rows}
); } /** * Build the 6×7 day-cell grid for `anchor`'s month using **ms-based cursor * advancement**. Saves ~42 Temporal allocations per month vs `cursor.add()`; * Temporal.PlainDateTime is now constructed only for in-month cells (~30 * per month, skipped entirely for hidden lastMonth/nextMonth placeholders). * * Shared by `renderMonthGrid` (desktop, two-month) and * `renderMobileMonthBody` (mobile vertical scroll). */ private buildMonthRows(anchor: Temporal.PlainDateTime, ctx: RenderCtx): VNode[] { const sow = getStartOfWeek(); const firstOfMonth = anchor.with({ day: 1 }); const dow = firstOfMonth.dayOfWeek; // Mon=1..Sun=7 const backDays = sow === 'monday' ? (dow === 7 ? 6 : dow - 1) : (dow % 7); // Sun=0 back, Mon=1, ... const anchorYear = anchor.year; const anchorMonth = anchor.month; // Single ms cursor — no per-cell Temporal allocations for advancement. let cursorMs = utcEpochAtMidnight( anchorYear, anchorMonth, 1, ) - backDays * MS_PER_DAY; const tmp = SHARED_TMP_DATE; const rows: VNode[] = []; let sawToMonth = false; for (let week = 0; week < 6; week++) { const cells: VNode[] = []; let allOutAfter = false; for (let d = 0; d < 7; d++) { tmp.setTime(cursorMs); const year = tmp.getUTCFullYear(); const month = tmp.getUTCMonth() + 1; const day = tmp.getUTCDate(); const inMonth = year === anchorYear && month === anchorMonth; if (inMonth) { sawToMonth = true; // Lazily construct PlainDateTime only for cells that need // it (selection state, click/hover handlers, getCell args). const cellDate = Temporal.PlainDateTime.from({ year, month, day }); const cls = this.classifyDay( cellDate, anchor, ctx, cursorMs, ); cells.push(this.renderDayCell( cellDate, cls, cursorMs, )); } else { // lastMonth/nextMonth cells are display:none in CSS — emit // a minimal placeholder; no Temporal allocation, no // classifyDay call, no inner VNode. cells.push(); if (week > 0 && sawToMonth) { const restOfMonth = year > anchorYear || (year === anchorYear && month > anchorMonth); if (restOfMonth) { allOutAfter = true; } } } cursorMs += MS_PER_DAY; } rows.push({cells}); if (allOutAfter && week >= 4) { break; } } return rows; } private renderDayCell( date: Temporal.PlainDateTime, cls: ReturnType, time: number, ): VNode { const classes = ['day']; classes.push(cls.type); classes.push(cls.valid ? 'valid' : 'invalid'); if (cls.isToday) { classes.push('real-today'); } if (cls.isStart || cls.isInMulti) { classes.push('checked', 'first-date-selected'); } if (cls.isEnd) { classes.push('checked', 'last-date-selected'); } if (cls.inRange) { classes.push('checked'); } if (cls.hovering) { classes.push('hovering'); } if (cls.hoveringCurrentFirst) { classes.push('hovering-current-first'); } if (cls.hoveringCurrentLast) { classes.push('hovering-current-last'); } if (cls.hoveringCurrentBefore) { classes.push('hovering-current-before'); } if (cls.isMaxDayCap) { classes.push('max-day-cap'); } const classStr = classes.join(' '); const timeStr = String(time); // Build the public `getCell` args object only when the prop is set — // otherwise we skip an object allocation per cell on every render. let inner: VNode; if (this.getCell) { const args: DaterangePickerCellRenderArgs = { attributes: { 'data-time': timeStr, 'class': classStr }, day: { date, day: date.day, extraClass: '', time, tooltip: '', type: cls.type, valid: cls.valid, }, }; inner = this.getCell(args); } else { inner =
{date.day}
; } const baseTdClass = cls.isStart ? 'drpn-td-first' : cls.isEnd ? 'drpn-td-last' : ''; const tdClass = this._kbdFocusMs === time ? `${baseTdClass} kbd-focused`.trim() : baseTdClass; return ( this.dayClicked(date)} onMouseenter={(e: MouseEvent) => this.dayHovered(date, e.currentTarget as HTMLElement)} onMouseleave={() => { /* leave handled by overall picker mouseleave */ }} > {inner} ); } // ── Topbar / footer ─────────────────────────────────────────────────── private renderTopbar(ctx: RenderCtx): VNode { if (ctx.mode === 'multi') { const count = this._multiDates.length; return (
{translate('selected')} {' '} {formatDayCount(count)}
); } if (!this._startDate) { return null; } const startStr = this.getStartFormatted(); const endStr = this._endDate ? this.getEndFormatted() : '...'; return (
{translate('selected')} {' '} {startStr} {!this.singleDate && [ {' '} {this.getSeparator()} {' '} , {endStr}, ]}
); } private renderFooter(forceShow: boolean): VNode { const mode = this.getMode(); if (mode === 'multi' || forceShow) { return ( ); } return null; } private renderFooterInfo(): VNode { if (!this.footerInfo) { return null; } return ( ); } private confirmClicked() { this.raiseChanged(); this.close(); } private cancelClicked() { // Revert if (this.getMode() === 'multi') { this._multiDates = (this._multiDatesEntry ?? []).slice(); } else { this.syncFromValue(); } this.close(); } // ── Tooltip ─────────────────────────────────────────────────────────── private renderTooltip(ctx: RenderCtx): VNode { if (ctx.mode !== 'range' || ctx.startMs == null || ctx.endMs != null || ctx.hoverMs == null) { return null; } const days = Math.abs(Math.round((ctx.hoverMs - ctx.startMs) / MS_PER_DAY)) + 1; const invalid = this.maxDays && days > this.maxDays; let content: VNode | string = ''; if (invalid && this.maxDaysTooltip) { content = this.maxDaysTooltip(this.maxDays); } else if (invalid) { // Cursor is past the cap. The literal cursor-to-anchor day count // (e.g. "32 dní" when hovering on day 32) is misleading because // the actual selectable count is clamped to maxDays. Show the cap // instead, prefixed with "max " so the user sees "max 31 dní" // rather than a count they cannot actually commit. Reuses the // consumer's `customTooltip` (locale-aware via PluralizationHelper) // or falls back to the picker's Intl-driven `formatDayCount`. const cappedText = this.customTooltip ? this.customTooltip(this.maxDays) : formatDayCount(this.maxDays); if (typeof cappedText === 'string') { content = `max ${cappedText}`; } else { content = ( max {' '} {cappedText} ); } } else if (this.customTooltip) { content = this.customTooltip(days); } else if (days > 1) { content = formatDayCount(days); } if (!content) { return null; } const style: Record = {}; if (this._hoverCellAnchor) { style.left = `${this._hoverCellAnchor.left}px`; style.top = `${Math.max(this._hoverCellAnchor.top - 24, 0)}px`; style.transform = 'translateX(-50%)'; } return
{content}
; } // ── Time picker ─────────────────────────────────────────────────────── private renderTime(name: 'time1' | 'time2', date: Temporal.PlainDateTime): VNode { const hour = date?.hour ?? 0; const minute = date?.minute ?? 0; return (
{translate('time')} : {' '} {String(hour).padStart(2, '0')} : {String(minute).padStart(2, '0')}
); } private onTimeChange( name: 'time1' | 'time2', kind: 'hour' | 'minute', val: number, ) { const target = name === 'time1' ? this._startDate : this._endDate; if (target == null) { return; } const updated = target.with({ [kind]: val }); if (name === 'time1') { this._startDate = updated; } else { this._endDate = updated; } // Emit the change so consumers (e.g. DataTable filters) re-apply // with the new time. Without this, slider movements update internal // state but the parent never sees them. Guarded so we only fire // once both dates are set — otherwise consumers like the DataTable // filter would receive a half-formed range and clear themselves. if (this._startDate != null && this._endDate != null) { this.raiseChanged(); } } // ── Render: dropdown content ────────────────────────────────────────── private renderDropdownContent(ctx?: RenderCtx): VNode { const renderCtx = ctx ?? this.buildRenderCtx(); const single = this.getEffectiveSingleMonth(); const month1 = this._anchorMonth.with({ day: 1 }); const month2 = nextMonthDate(month1); const showTopbar = renderCtx.mode !== 'single' || this.enableTime; // Inline / always-open calendar has no text input to drive the keyboard, so the // sized calendar content becomes the focusable grid: Tab focuses it, arrows move // the day, Enter selects (WCAG 2.1.1). The `.drpn-dropdown` wrapper is 0×0 in // inline mode so it can't host focus — `.drpn-content` is the real, sized box. const kbdGrid = this.alwaysOpen || this.calendarPlacement === 'inline'; return (
{ this.handleCalendarKeydown(e); } : undefined} > {showTopbar && this.renderTopbar(renderCtx)}
{this.renderMonthGrid( month1, 'month1', renderCtx, )} {!single &&
} {!single && this.renderMonthGrid( month2, 'month2', renderCtx, )}
{this.enableTime && (
{this.renderTime('time1', this._startDate)} {!this.singleDate && this.renderTime('time2', this._endDate ?? this._startDate)}
)} {this.renderTooltip(renderCtx)} {this.renderFooter(false)} {this.renderFooterInfo()}
); } // ── Mobile vertical-scroll content ──────────────────────────────────── private renderMobileVerticalScroll(ctx: RenderCtx): VNode { const months: VNode[] = []; // In the Airbnb-style verticalScroll layout, render the full // `_mvsStartMonth..._mvsEndMonth` window and let `getCell` handle // per-day availability via `valid: false`. Stopping at `maxDate` here // would shrink the pickable range for products whose maxDate sits // inside the current scroll window, breaking lazy-load. let cursor = this._mvsStartMonth; while (compareMonth(cursor, this._mvsEndMonth) <= 0) { if (!this.minDate || compareMonth(cursor, this.minDate) >= 0) { const monthCell = (
{getMonthName(cursor.month)} {' '} {cursor.year}
{this.renderMobileMonthBody(cursor, ctx)}
); months.push(monthCell); } cursor = nextMonthDate(cursor); } return (
{this.monthSelect !== false && this.renderMobileMonthSelector()} {this.yearSelect !== false && this.renderMobileYearSelector()}
{this.getWeekHead()}
this.onMvsScroll()}> {this.renderTopbar(ctx)} {months}
{this.renderFooter(true)} {this.renderFooterInfo()}
); } private renderMobileMonthSelector(): VNode { const minM = (this.minDate && this.minDate.year === this._mvsAnchor.year) ? this.minDate.month : 1; const maxM = (this.maxDate && this.maxDate.year === this._mvsAnchor.year) ? this.maxDate.month : 12; const opts: VNode[] = []; for (let m = 1; m <= 12; m++) { opts.push(); } return ( {getMonthName(this._mvsAnchor.month)} {' '} ); } private renderMobileYearSelector(): VNode { const minY = this.minDate ? Math.max(1900, this.minDate.year) : 1900; const maxY = this.maxDate ? this.maxDate.year : Math.max(this._mvsAnchor.year + 5, Temporal.Now.plainDateTimeISO().year + 5); const opts: VNode[] = []; for (let y = minY; y <= maxY; y++) { opts.push(); } return ( {this._mvsAnchor.year} {' '} ); } private scrollToMonth(year: number, month: number) { const target = Temporal.PlainDateTime.from({ year, month, day: 1 }); this._mvsAnchor = target; // Ensure the requested month is within the rendered window. if (compareMonth(target, this._mvsStartMonth) < 0) { this._mvsStartMonth = prevMonthDate(target); } if (compareMonth(target, this._mvsEndMonth) > 0) { this._mvsEndMonth = target.add({ months: 6 }); } this.$nextTick(() => { const scroll = this.$refs.mvsScroll as HTMLElement | undefined; if (!scroll) { return; } const monthEl = scroll.querySelector(`[data-anchor="${year}-${month}"]`) as HTMLElement | null; if (monthEl) { this._mvsScrollLock = true; scroll.scrollTop = monthEl.offsetTop - 8; setTimeout(() => { this._mvsScrollLock = false; }, 100); } }); } private onMvsScroll() { if (this._mvsScrollLock) { return; } const scroll = this.$refs.mvsScroll as HTMLElement | undefined; if (!scroll) { return; } // Update visible-month indicator (which month is currently in viewport) const monthEls = Array.from(scroll.querySelectorAll('.drpn-mvs-month')) as HTMLElement[]; const probeY = scroll.scrollTop + 32; // probe just below sticky weekday header for (const m of monthEls) { if (m.offsetTop + m.offsetHeight > probeY) { const anchor = m.getAttribute('data-anchor'); if (anchor) { const [ y, mo, ] = anchor.split('-').map(Number); if (!Number.isNaN(y) && !Number.isNaN(mo) && (this._mvsAnchor.year !== y || this._mvsAnchor.month !== mo)) { this._mvsAnchor = Temporal.PlainDateTime.from({ year: y, month: mo, day: 1 }); } } break; } } // Lazy-extend when the user scrolls near the bottom or top const distFromBottom = scroll.scrollHeight - (scroll.scrollTop + scroll.clientHeight); if (distFromBottom < 800) { // `maxDate` is honored at cell-level (greyed-out invalid days); // keep extending the rendered window so users can scroll past it. this._mvsEndMonth = this._mvsEndMonth.add({ months: 6 }); } if (scroll.scrollTop < 200) { const newStart = this._mvsStartMonth.subtract({ months: 6 }); if (!this.minDate || compareMonth(newStart, this.minDate) >= 0) { const before = scroll.scrollHeight; this._mvsStartMonth = newStart; this._mvsScrollLock = true; this.$nextTick(() => { const after = scroll.scrollHeight; scroll.scrollTop += after - before; setTimeout(() => { this._mvsScrollLock = false; }, 100); }); } } } private renderMobileMonthBody(anchor: Temporal.PlainDateTime, ctx: RenderCtx): VNode[] { // Mobile vertical-scroll uses the same grid logic as the desktop layout. return this.buildMonthRows(anchor, ctx); } // ── Top-level render ────────────────────────────────────────────────── getCssClass(): string { const isMobile = MobileModeConfig.shouldDisplayInModal(); return [ 'daterange-picker-input', 'drpn-root', this.customInputText != null ? 'daterange-picker-input-customtext' : '', isMobile ? 'pd-daterange-picker-modal-mode' : '', this.cssClass ?? '', this.prependIcon != null ? 'input-group-prepend-icon' : '', this.appendIcon != null ? 'input-group-append-icon' : '', ].filter(Boolean).join(' '); } render() { const inline = this.calendarPlacement === 'inline' && !this.useModalMode(); const showInlineDropdown = (this._isOpen && !this.useModalMode()) || this.alwaysOpen || inline; const dropdownPositionClass = (() => { switch (this.calendarPlacement) { case 'inline': return 'drpn-pos-inline'; case 'input-container-leftalign': return 'drpn-pos-left'; case 'input-container-rightalign': return 'drpn-pos-right'; default: { const horizontal = this._dropdownAnchor === 'right' ? 'drpn-pos-right' : 'drpn-pos-default'; if (this._dropdownVertical === 'up') { return `${horizontal} drpn-pos-up`; } if (this._dropdownVertical === 'fixed') { return `${horizontal} drpn-pos-fixed`; } return horizontal; } } })(); // Build the render-context once per render. Shared by every day cell, // the topbar, and the tooltip — saves ~84 Temporal sameDay/compareDay // allocations and an O(N) multi-array scan per day cell. const ctx = this.buildRenderCtx(); return (
this.clearHover()}> {this.renderInput()} {showInlineDropdown && (
e.stopPropagation()} > {this.mobileVariant === 'verticalScroll' && MobileModeConfig.shouldDisplayInModal() ? this.renderMobileVerticalScroll(ctx) : this.renderDropdownContent(ctx)}
)}
{this.useModalMode() && ( )}
); } } const DaterangePicker = toNative(DaterangePickerComponent); export default DaterangePicker;