/** * -------------------------------------------------------------------------- * Tabler datepicker.ts * Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE) * -------------------------------------------------------------------------- */ import type { Calendar, CalendarExtension, Options, Range } from 'vanilla-calendar-pro' import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions' import { getElement, isDisabled } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' /** * Constants */ const NAME = 'datepicker' const DATA_KEY = `bs.${NAME}` const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' const EVENT_CHANGE = `change${EVENT_KEY}` const EVENT_SHOW = `show${EVENT_KEY}` const EVENT_SHOWN = `shown${EVENT_KEY}` const EVENT_HIDE = `hide${EVENT_KEY}` const EVENT_HIDDEN = `hidden${EVENT_KEY}` const EVENT_FOCUSIN = `focusin${EVENT_KEY}` const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}` const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"], [data-tblr-toggle="datepicker"]' const SELECTOR_DISPLAY = '[data-bs-datepicker-display], [data-tblr-datepicker-display]' const SELECTOR_INPUT_WRAPPER = '.input-icon, .input-group' const SELECTOR_BOUND_INPUT = 'input[type="hidden"], input[name]' const HIDE_DELAY = 100 // ms delay before hiding after selection const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ const FORMAT_TOKENS = /YYYY|MM|M|DD|D/g const ISO_FORMAT = 'YYYY-MM-DD' // Vanilla Calendar Pro 3.4 moved several months, the time picker, week // numbers, popups and animation into extensions that must be registered when // the calendar is created, or its constructor throws. The library is loaded // as a whole on `window`, so every extension it exports is registered; // 3.3 exports none and gets no `extensions` option. const EXTENSION_NAMES = ['annotations', 'months', 'motion', 'time', 'weeks'] as const const writtenSelections = new WeakMap() // The public API is typed with local copies of the Vanilla Calendar Pro types. // `vanilla-calendar-pro` is an optional peer dependency, so the published // `dist/types` must not import it: a project that never loads the datepicker // would otherwise fail to type-check with TS2307. The unions match the // plugin's own, so a bad value is still a compile error. Members that need the // plugin's full types carry an internal JSDoc tag and are left out of // `dist/types` by `stripInternal` (the tag must not appear in plain comments, // or TypeScript strips the declaration that follows). type DateAny = Date | number | `${number}-${string}-${string}` | 'today' type DatesArr = Array type DateMode = 'single' | 'multiple' | 'multiple-ranged' type MonthsCount = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 type PositionToInput = 'auto' | 'left' | 'center' | 'right' | ['bottom' | 'top', 'left' | 'center' | 'right'] type WeekDayID = 0 | 1 | 2 | 3 | 4 | 5 | 6 /** * The part of a Vanilla Calendar Pro instance the `calendar` getter promises. * Cast it to `Calendar` from `vanilla-calendar-pro` for the full type. */ interface CalendarInstance { // Loosely typed on purpose: the plugin's context is large and version-specific. // eslint-disable-next-line @typescript-eslint/no-explicit-any context: Record init(): unknown update(resetOptions?: object): unknown set(options: object, resetOptions?: object): unknown show(): unknown hide(): unknown destroy(): unknown } type ComponentConfig = { /** 'light', 'dark' or 'auto' for the popup only; null inherits from the nearest `[data-bs-theme]` */ datepickerTheme: string | null dateMin: DateAny | null dateMax: DateAny | null /** input that receives the picked dates as `YYYY-MM-DD`, comma-separated; an inline calendar finds a hidden one inside */ boundInput: string | HTMLInputElement | null /** * How the date is written into the field: a pattern such as `'DD.MM.YYYY'` (tokens `YYYY`, `MM`, `M`, `DD`, `D`), * `'iso'`, `Intl.DateTimeFormat` options, or a function(date, locale) returning the text */ dateFormat: string | Intl.DateTimeFormatOptions | ((date: Date, locale: string | undefined) => string) | null /** element that shows the formatted date; a button uses itself or its `[data-bs-datepicker-display]` child */ displayElement: string | HTMLElement | boolean | null displayMonthsCount: MonthsCount /** 0 = Sunday, 1 = Monday */ firstWeekday: WeekDayID /** render the calendar in place instead of a popup */ inline: boolean locale: string /** element the popup is aligned with; defaults to the `.input-icon` / `.input-group` wrapper or the element */ positionElement: string | HTMLElement | null /** preselected dates as `YYYY-MM-DD` */ selectedDates: string[] selectionMode: DateMode placement: PositionToInput /** show the ISO week number in front of each row */ weekNumbers: boolean /** pass-through for any Vanilla Calendar Pro option */ vcpOptions: object } type ComponentConfigInput = Partial & Record type ChangeEventArgs = { dates: string[]; event: MouseEvent } const Default: ComponentConfig = { boundInput: null, datepickerTheme: null, dateMin: null, dateMax: null, dateFormat: null, displayElement: null, displayMonthsCount: 1, firstWeekday: 1, inline: false, locale: 'default', positionElement: null, selectedDates: [], selectionMode: 'single', placement: 'left', weekNumbers: false, vcpOptions: {}, } const DefaultType: Record = { boundInput: '(null|string|element)', datepickerTheme: '(null|string)', dateMin: '(null|string|number|object|date)', dateMax: '(null|string|number|object|date)', dateFormat: '(null|string|object|function)', displayElement: '(null|string|element|boolean)', displayMonthsCount: 'number', firstWeekday: 'number', inline: 'boolean', locale: 'string', positionElement: '(null|string|element)', selectedDates: 'array', selectionMode: 'string', placement: 'string', weekNumbers: 'boolean', vcpOptions: 'object', } const validDates = (dates: readonly unknown[]): string[] => dates.filter((date): date is string => typeof date === 'string' && DATE_PATTERN.test(date)) const pad = (value: number, length = 2): string => String(value).padStart(length, '0') const formatPattern = (date: Date, pattern: string): string => { const parts: Record = { YYYY: pad(date.getFullYear(), 4), MM: pad(date.getMonth() + 1), M: String(date.getMonth() + 1), DD: pad(date.getDate()), D: String(date.getDate()), } return pattern.replace(FORMAT_TOKENS, (token) => parts[token]) } // Reads text written with `pattern` back into `YYYY-MM-DD`, or null when it // does not match or names a day that does not exist. const parsePattern = (value: string, pattern: string): string | null => { const order: string[] = [] const source = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(FORMAT_TOKENS, (token) => { order.push(token[0]) return token === 'YYYY' ? '(\\d{4})' : token.length === 2 ? '(\\d{2})' : '(\\d{1,2})' }) const match = new RegExp(`^${source}$`).exec(value) if (!match) { return null } const parts: Record = { Y: 0, M: 1, D: 1 } order.forEach((key, index) => { parts[key] = Number(match[index + 1]) }) const { Y: year = 0, M: month = 1, D: day = 1 } = parts const date = new Date(year, month - 1, day) return date.getMonth() === month - 1 && date.getDate() === day ? `${pad(year, 4)}-${pad(month)}-${pad(day)}` : null } // Delegated Data API handlers run in the capture phase, before the plugin's // own click listener, which opens the popup from a `setTimeout`. Two nested // timeouts are the first point that is after it for certain. const afterPluginTimers = (callback: () => void): void => { setTimeout(() => setTimeout(callback)) } /** * Class definition * * Wraps Vanilla Calendar Pro (https://vanilla-calendar.pro), loaded separately * as `window.VanillaCalendarPro`. Without the plugin the component is inert. * Same options, methods and events as Bootstrap 6's Datepicker, so markup * written for one works with the other. */ class Datepicker extends BaseComponent { declare _element: HTMLElement & { value: string } declare _config: ComponentConfig /** @internal */ _calendar: Calendar | null = null _isShown = false // The plugin opens and closes the popup on its own too (its click and focus // listeners, Escape, a click outside), so `onShow` and `onHide` are what // tracks the state and fires the events; these flags tell them who asked. _isShowing = false _isHiding = false _isSilent = false _skipPluginShow = false _resolveShown: (() => void) | null = null _hideTimeout = 0 _isInput = false _isInline = false _boundInput: HTMLInputElement | null = null _boundInputInside = false _positionElement: HTMLElement | null = null _displayElement: HTMLElement | false | null = null _themeObserver: MutationObserver | null = null _onFocusIn: ((event: Event) => void) | null = null _onResize = (): void => this._alignToPositionElement() // The plugin builds a popup lazily, so its context has no selection until // the first show; this mirror answers `getSelectedDates()` before that. _selectedDates: string[] = [] constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) if (!this._element || !window.VanillaCalendarPro) { return } this._initCalendar() } // Getters static get Default(): ComponentConfig { return Default } static get DefaultType(): Record { return DefaultType } static get NAME(): string { return NAME } /** The Vanilla Calendar Pro instance, for options the component does not expose. */ get calendar(): CalendarInstance | null { return this._calendar } // Public toggle(): Promise { if (this._config.inline) { return Promise.resolve() // Inline calendars are always visible } return this._isShown ? this.hide() : this.show() } async show(): Promise { if (this._config.inline) { return // Inline calendars are always visible } // The first `calendar.show()` clicks the element to build the popup, and // that click comes back here through the Data API; `_isShowing` stops it. if (!this._calendar || isDisabled(this._element) || this._isShown || this._isShowing) { return } const showEvent = EventHandler.trigger(this._element, EVENT_SHOW) if (showEvent?.defaultPrevented) { return } this._isShowing = true this._skipPluginShow = false // Reset even if the plugin throws, or every later `show()` returns early try { this._calendar.show() // The first show builds the popup and opens it a tick later if (!this._isShown) { await new Promise((resolve) => { this._resolveShown = resolve afterPluginTimers(resolve) }) } } finally { this._resolveShown = null this._isShowing = false } } // `async` like `show()`, so a plugin error reaches the caller as a rejection // eslint-disable-next-line @typescript-eslint/require-await async hide(): Promise { if (this._config.inline) { return // Inline calendars are always visible } if (!this._calendar || !this._isShown) { return } const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) if (hideEvent?.defaultPrevented) { return } // On a click on a button trigger the plugin's own listener runs next and // queues a `show()`; without this the popup would open again right away. this._skipPluginShow = true afterPluginTimers(() => { this._skipPluginShow = false }) this._isHiding = true try { this._calendar.hide() } finally { this._isHiding = false } } dispose(): void { window.clearTimeout(this._hideTimeout) this._themeObserver?.disconnect() window.removeEventListener('resize', this._onResize) if (this._onFocusIn) { EventHandler.off(document, EVENT_FOCUSIN, this._onFocusIn) } if (this._calendar) { this._calendar.destroy() // The plugin swaps the element for a clone taken at init, which has the // value and listeners from back then. The live element goes back in; // an inline calendar keeps the clone (its original children), so only // the bound input moves back into it. const clone = this._calendar.context.mainElement as HTMLElement | undefined if (clone && clone !== this._element && clone.isConnected) { if (this._isInput) { clone.replaceWith(this._element) } else if (this._boundInput && this._boundInputInside) { const copy = SelectorEngine.findOne(SELECTOR_BOUND_INPUT, clone) if (copy) { copy.replaceWith(this._boundInput) } else { clone.append(this._boundInput) } } } } super.dispose() } getSelectedDates(): string[] { const dates = this._calendar?.context?.selectedDates as unknown[] | undefined return dates && dates.length > 0 ? validDates(dates) : [...this._selectedDates] } setSelectedDates(dates: DatesArr): void { this._select(dates.map((date) => this._toIsoDate(date))) this._writeSelection(this._selectedDates) } // Private _initCalendar(): void { this._isInput = this._element.tagName === 'INPUT' this._isInline = this._config.inline this._boundInput = this._resolveBoundInput() this._boundInputInside = Boolean(this._boundInput && this._element.contains(this._boundInput)) this._positionElement = this._resolvePositionElement() this._displayElement = this._resolveDisplayElement() this._selectedDates = [...this._config.selectedDates] // The calendar always attaches to the element itself. The plugin closes on // any click whose target is not exactly its input, so handing it a wrapper // makes a click on the field inside close and reopen the popup. The popup // is aligned with the position element after each show instead. this._calendar = new window.VanillaCalendarPro!.Calendar(this._element, this._buildCalendarOptions()) this._calendar.init() // An inline calendar renders into the element and drops its children, so // the bound input moves right after it to stay in the form. if (this._boundInput && !this._boundInput.isConnected) { this._element.after(this._boundInput) } // Watch for theme changes on ancestor elements (for live theme switching) this._setupThemeObserver() this._setupDismissOnFocus() // Set initial value if input has a value if (this._isInput && this._element.value) { this._parseInputValue() } // The field shows localized text the next page load cannot read back, so // a value the server rendered into the bound input selects the dates. const boundDates = this._boundInput?.value.split(',').filter((date) => DATE_PATTERN.test(date)) if (this._selectedDates.length === 0 && boundDates?.length) { this._select(boundDates) } // Populate input/display with preselected dates this._updateDisplayWithSelectedDates() } _updateDisplayWithSelectedDates(): void { if (this._selectedDates.length === 0) { return } this._writeSelection(this._selectedDates) } _writeSelection(selectedDates: string[]): void { const formattedDate = this._formatDateForInput(selectedDates) if (this._isInput) { this._element.value = formattedDate writtenSelections.set(this._element, { text: formattedDate, dates: [...selectedDates] }) } if (this._boundInput) { this._boundInput.value = selectedDates.join(',') } if (this._displayElement) { this._displayElement.textContent = formattedDate } } _resolveBoundInput(): HTMLInputElement | null { const { boundInput } = this._config if (boundInput) { return getElement(boundInput) as HTMLInputElement | null } // An inline calendar submits through a hidden input inside it return this._isInline && !this._isInput ? (SelectorEngine.findOne(SELECTOR_BOUND_INPUT, this._element) as HTMLInputElement | null) : null } _resolvePositionElement(): HTMLElement { let { positionElement } = this._config if (typeof positionElement === 'string') { positionElement = getElement(positionElement) } // Use the input's wrapper when it sits in an icon or group wrapper if (!positionElement && this._isInput && !this._isInline) { positionElement = this._element.closest(SELECTOR_INPUT_WRAPPER) } return positionElement || this._element } _resolveDisplayElement(): HTMLElement | false | null { const { displayElement } = this._config if (typeof displayElement === 'string') { return getElement(displayElement) } // For buttons/non-inputs (not inline), look for a display child if (displayElement === true || (displayElement === null && !this._isInput && !this._isInline)) { return SelectorEngine.findOne(SELECTOR_DISPLAY, this._element) || this._element } return displayElement } // The plugin positions the popup against the element; a wrapper such as // `.input-icon` or `.input-group` wants the popup aligned with its own edges. _alignToPositionElement(): void { const mainElement = this._calendar?.context?.mainElement if (!mainElement || this._isInline || !this._positionElement || this._positionElement === this._element) { return } const anchor = this._positionElement.getBoundingClientRect() const popup = mainElement.getBoundingClientRect() let { placement } = this._config if (placement === 'auto') { placement = anchor.left + popup.width > document.documentElement.clientWidth ? 'right' : 'left' } const left = placement === 'right' ? anchor.right - popup.width : placement === 'center' ? anchor.left + (anchor.width - popup.width) / 2 : anchor.left mainElement.style.left = `${left + window.scrollX}px` } _getThemeAncestor(): Element | null { return this._element.closest('[data-bs-theme]') } _getEffectiveTheme(): string | null { // Priority: explicit datepickerTheme config > inherited from ancestor > none const { datepickerTheme } = this._config if (datepickerTheme) { return datepickerTheme } return this._getThemeAncestor()?.getAttribute('data-bs-theme') || null } _syncThemeAttribute(element: HTMLElement | undefined): void { if (!element) { return } const theme = this._getEffectiveTheme() if (theme) { // Copy the theme to the popup: the plugin appends it to , which // breaks inheritance from a themed container. element.setAttribute('data-bs-theme', theme) } else { element.removeAttribute('data-bs-theme') } } _setupThemeObserver(): void { const ancestor = this._getThemeAncestor() if (!ancestor || this._config.datepickerTheme) { // No ancestor to watch, or explicit datepickerTheme overrides return } this._themeObserver = new MutationObserver(() => { this._syncThemeAttribute(this._calendar?.context?.mainElement) }) this._themeObserver.observe(ancestor, { attributes: true, attributeFilter: ['data-bs-theme'], }) } // The plugin ignores focus and mounts the popup on , so close it // ourselves when focus leaves both the element and the calendar. _setupDismissOnFocus(): void { if (this._isInline) { return } this._onFocusIn = (event: Event): void => { if (!this._isShown) { return } const { target } = event const mainElement = this._calendar?.context?.mainElement if (target instanceof Node && (this._element.contains(target) || mainElement?.contains(target))) { return } void this.hide() } EventHandler.on(document, EVENT_FOCUSIN, this._onFocusIn) } _silently(callback: () => void): void { this._isSilent = true callback() this._isSilent = false } _handlePluginShow(): void { const calendar = this._calendar if (!calendar) { return } if (!this._isSilent && !this._isShown) { // Opened by the plugin itself: right after `hide()` on a trigger click it // must stay closed, otherwise it gets the same cancelable `show` event. const prevented = this._skipPluginShow || (!this._isShowing && EventHandler.trigger(this._element, EVENT_SHOW)?.defaultPrevented) if (prevented) { this._silently(() => calendar.hide()) return } } const wasShown = this._isShown this._isShown = true this._syncThemeAttribute(calendar.context.mainElement) this._alignToPositionElement() window.removeEventListener('resize', this._onResize) window.addEventListener('resize', this._onResize) if (!wasShown) { EventHandler.trigger(this._element, EVENT_SHOWN) } this._resolveShown?.() } _handlePluginHide(): void { const calendar = this._calendar if (!calendar || this._isSilent || !this._isShown) { return } // Closed by the plugin itself (Escape, a click outside): `hide` is still // cancelable, the popup opens again before the browser paints. if (!this._isHiding && EventHandler.trigger(this._element, EVENT_HIDE)?.defaultPrevented) { this._silently(() => calendar.show()) return } this._isShown = false window.removeEventListener('resize', this._onResize) EventHandler.trigger(this._element, EVENT_HIDDEN) } // The extensions the loaded library exports, after the ones the caller // registered in `vcpOptions` themselves. /** @internal */ _calendarExtensions(own: readonly CalendarExtension[] = []): CalendarExtension[] { const library = window.VanillaCalendarPro as Record | undefined const extensions = [...own] for (const name of EXTENSION_NAMES) { const extension = library?.[name] as CalendarExtension | undefined if (extension && typeof extension === 'object' && !extensions.includes(extension)) { extensions.push(extension) } } return extensions } /** @internal */ _buildCalendarOptions(): Options { // The plugin uses 'system' for auto-detection, Bootstrap and Tabler use 'auto' const theme = this._getEffectiveTheme() const vcpTheme = !theme || theme === 'auto' ? 'system' : theme const vcpOptions = this._config.vcpOptions as Options const extensions = this._calendarExtensions(vcpOptions.extensions) const calendarOptions: Options = { ...vcpOptions, ...(extensions.length > 0 ? { extensions } : {}), inputMode: !this._isInline, positionToInput: this._config.placement, firstWeekday: this._config.firstWeekday, locale: this._config.locale, selectionDatesMode: this._config.selectionMode, selectedDates: this._config.selectedDates, displayMonthsCount: this._config.displayMonthsCount, enableWeekNumbers: this._config.weekNumbers || Boolean(vcpOptions.enableWeekNumbers), type: this._config.displayMonthsCount > 1 ? 'multiple' : 'default', selectedTheme: vcpTheme, themeAttrDetect: '[data-bs-theme]', onClickDate: (self, event) => this._handleDateClick(self, event), onInit: (self) => { this._syncThemeAttribute(self.context.mainElement) }, onShow: () => this._handlePluginShow(), onHide: () => this._handlePluginHide(), } // Navigate to the month of the first selected date const [firstSelected] = this._config.selectedDates if (firstSelected) { const firstDate = this._parseDate(firstSelected) // `getMonth()` is always 0-11, which TypeScript cannot narrow on its own calendarOptions.selectedMonth = firstDate.getMonth() as Range<12> calendarOptions.selectedYear = firstDate.getFullYear() } if (this._config.dateMin) { calendarOptions.dateMin = this._config.dateMin as Options['dateMin'] } if (this._config.dateMax) { calendarOptions.dateMax = this._config.dateMax as Options['dateMax'] } return calendarOptions } /** @internal */ _handleDateClick(self: Calendar, event: MouseEvent): void { const selectedDates = validDates(self.context.selectedDates) this._selectedDates = selectedDates // Written even when empty: clicking the picked day again deselects it, and // the field must not keep submitting the old date. this._writeSelection(selectedDates) const args: ChangeEventArgs = { dates: selectedDates, event } EventHandler.trigger(this._element, EVENT_CHANGE, args) this._maybeHideAfterSelection(selectedDates) } _maybeHideAfterSelection(selectedDates: string[]): void { if (this._isInline) { return } const shouldHide = (this._config.selectionMode === 'single' && selectedDates.length > 0) || (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) if (shouldHide) { window.clearTimeout(this._hideTimeout) this._hideTimeout = window.setTimeout(() => void this.hide(), HIDE_DELAY) } } _parseDate(dateStr: string): Date { const [year = 0, month = 1, day = 1] = dateStr.split('-').map(Number) return new Date(year, month - 1, day) } _formatDate(dateStr: string): string { const date = this._parseDate(dateStr) const locale = this._config.locale === 'default' ? undefined : this._config.locale const { dateFormat } = this._config // Token pattern, independent of the locale if (typeof dateFormat === 'string') { return formatPattern(date, this._formatPattern()!) } // Custom function formatter if (typeof dateFormat === 'function') { return dateFormat(date, locale) } // Intl.DateTimeFormat options object if (dateFormat && typeof dateFormat === 'object') { return new Intl.DateTimeFormat(locale, dateFormat).format(date) } // Default: locale-aware formatting return date.toLocaleDateString(locale) } _formatPattern(): string | null { const { dateFormat } = this._config if (typeof dateFormat !== 'string') { return null } return dateFormat.toLowerCase() === 'iso' ? ISO_FORMAT : dateFormat } _dateSeparator(): string { return this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', ' } _formatDateForInput(selectedDates: string[]): string { const dates = validDates(selectedDates) if (dates.length === 0) { return '' } if (dates.length === 1) { return this._formatDate(dates[0]) } // For date ranges, use en-dash; for multiple dates, use comma return dates.map((date) => this._formatDate(date)).join(this._dateSeparator()) } _parseInputValue(): void { const value = this._element.value.trim() if (!value) { return } const written = writtenSelections.get(this._element) if (written && written.text.trim() === value) { this._select(written.dates) return } // A pattern reads back what it wrote, e.g. a value the server sent again const pattern = this._formatPattern() if (pattern) { const parts = this._config.selectionMode === 'single' ? [value] : value.split(this._dateSeparator()) const dates = parts.map((part) => parsePattern(part.trim(), pattern)) if (dates.every(Boolean)) { this._select(dates as string[]) return } } // `YYYY-MM-DD` is read as a local date: `new Date('2020-06-20')` would be // UTC midnight and land on the previous day west of Greenwich. const date = DATE_PATTERN.test(value) ? this._parseDate(value) : new Date(value) if (Number.isNaN(date.getTime())) { return } this._select([this._toIsoDate(date)]) } _select(dates: string[]): void { this._selectedDates = [...dates] // The calendar opens on the month of the selection, not on today. this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) }) } _toIsoDate(value: Date | number | string): string { if (typeof value === 'string' && DATE_PATTERN.test(value)) { return value } const date = value instanceof Date ? value : typeof value === 'string' && DATE_PATTERN.test(value) ? this._parseDate(value) : new Date(value) if (Number.isNaN(date.getTime())) { return String(value) } const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } /** @internal */ _monthOf(value: string | undefined): Pick { if (!value || !DATE_PATTERN.test(value)) { return {} } const date = this._parseDate(value) // `getMonth()` is always 0-11, which TypeScript cannot narrow on its own return { selectedMonth: date.getMonth() as Range<12>, selectedYear: date.getFullYear() } } } /** * Data API implementation */ // js-docs-start datepicker-init EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) { // Inputs open on focus, and inline calendars are always visible if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true' || this.dataset.tblrInline === 'true') { return } event.preventDefault() void (Datepicker.getOrCreateInstance(this) as Datepicker).toggle() }) EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement) { if (this.tagName !== 'INPUT') { return } void (Datepicker.getOrCreateInstance(this) as Datepicker).show() }) // Render on load what cannot wait for a focus or a click: inline calendars, // and fields with preselected dates or a value, so they show it right away. initAll(SELECTOR_DATA_TOGGLE, Datepicker, (element) => { const { dataset } = element return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || 'bsBoundInput' in dataset || 'tblrBoundInput' in dataset || Boolean((element as HTMLInputElement).value) }) // js-docs-end datepicker-init export default Datepicker export type { ComponentConfig as DatepickerConfig, ChangeEventArgs as DatepickerChangeEventArgs }