blob: b5eab5a65c58f42f6f5a14bdf4e5ee8375c2240c [file] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap datepicker.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import {
Calendar,
type DateAny,
type DateMode,
type DatesArr,
type MonthsCount,
type Options,
type PositionToInput,
type Range,
type WeekDayID
} from 'vanilla-calendar-pro'
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import { isDisabled } from './util/index.js'
/**
* Constants
*/
const NAME = 'datepicker'
const DATA_KEY = 'bs.datepicker'
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"]'
const HIDE_DELAY = 100 // ms delay before hiding after selection
// The date, weekday, month-count and placement options are handed straight to
// Vanilla Calendar Pro, so they use its own literal unions rather than the wider
// `string` / `number`. That keeps a bad value a compile error here instead of a
// silent no-op inside the calendar.
type DatepickerConfig = {
datepickerTheme: string | null
dateMin: DateAny | null
dateMax: DateAny | null
dateFormat: Intl.DateTimeFormatOptions | ((date: Date, locale: string | undefined) => string) | null
displayElement: string | HTMLElement | boolean | null
displayMonthsCount: MonthsCount
firstWeekday: WeekDayID
inline: boolean
locale: string
positionElement: string | HTMLElement | null
selectedDates: string[]
selectionMode: DateMode
placement: PositionToInput
vcpOptions: Options
}
const Default: DatepickerConfig = {
datepickerTheme: null, // 'light', 'dark', 'auto' - explicit theme for datepicker popover only
dateMin: null,
dateMax: null,
dateFormat: null, // Intl.DateTimeFormat options, or function(date, locale) => string
displayElement: null, // Element to show formatted date (defaults to element for buttons)
displayMonthsCount: 1, // Number of months to display side-by-side
firstWeekday: 1, // Monday
inline: false, // Render calendar inline (no popup)
locale: 'default',
positionElement: null, // Element to position calendar relative to (defaults to input)
selectedDates: [],
selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged'
placement: 'left', // 'left', 'center', 'right', 'auto'
vcpOptions: {} // Pass-through for any VCP option
}
const DefaultType = {
datepickerTheme: '(null|string)',
dateMin: '(null|string|number|object)',
dateMax: '(null|string|number|object)',
dateFormat: '(null|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',
vcpOptions: 'object'
}
/**
* Class definition
*/
class Datepicker extends BaseComponent {
protected declare _element: HTMLElement & { value: string }
protected declare _config: DatepickerConfig
protected declare _calendar: Calendar | null
protected declare _isShown: boolean
protected declare _isInput: boolean
protected declare _isInline: boolean
protected declare _boundInput: HTMLInputElement | null
protected declare _positionElement: HTMLElement
protected declare _displayElement: HTMLElement | false | null
protected declare _themeObserver: MutationObserver | null
protected declare _onFocusIn: (event: Event) => void
constructor(element?: string | Element | null, config?: Partial<DatepickerConfig> | null) {
super(element, config)
this._calendar = null
this._isShown = false
this._initCalendar()
}
// Getters
static override get Default(): DatepickerConfig {
return Default
}
static override get DefaultType(): Record<string, string> {
return DefaultType
}
static override get NAME(): string {
return NAME
}
// Public
toggle(): Promise<void> {
if (this._config.inline) {
return Promise.resolve() // Inline calendars are always visible
}
return this._isShown ? this.hide() : this.show()
}
async show(): Promise<void> {
if (this._config.inline) {
return // Inline calendars are always visible
}
if (!this._calendar || isDisabled(this._element) || this._isShown) {
return
}
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent.defaultPrevented) {
return
}
this._calendar.show()
this._isShown = true
EventHandler.trigger(this._element, EVENT_SHOWN)
}
async hide(): Promise<void> {
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
}
this._calendar.hide()
this._isShown = false
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
override dispose(): void {
if (this._themeObserver) {
this._themeObserver.disconnect()
this._themeObserver = null
}
if (this._onFocusIn) {
EventHandler.off(document, EVENT_FOCUSIN, this._onFocusIn)
}
if (this._calendar) {
this._calendar.destroy()
}
this._calendar = null
super.dispose()
}
getSelectedDates(): string[] {
const dates = this._calendar?.context?.selectedDates
return dates ? [...dates] : []
}
setSelectedDates(dates: DatesArr): void {
if (this._calendar) {
this._calendar.set({ selectedDates: dates })
}
}
// Private
protected _initCalendar(): void {
this._isInput = this._element.tagName === 'INPUT'
this._isInline = this._config.inline
// For inline mode, look for a hidden input child to bind to
if (this._isInline && !this._isInput) {
this._boundInput = this._element.querySelector<HTMLInputElement>('input[type="hidden"], input[name]')
}
this._positionElement = this._resolvePositionElement()
this._displayElement = this._resolveDisplayElement()
const calendarOptions = this._buildCalendarOptions()
// Create calendar on the position element (for correct popup positioning)
// but value updates still go to this._element (the input)
this._calendar = new Calendar(this._positionElement, calendarOptions)
this._calendar.init()
// 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()
}
// Populate input/display with preselected dates
this._updateDisplayWithSelectedDates()
}
protected _updateDisplayWithSelectedDates(): void {
const { selectedDates } = this._config
if (!selectedDates || selectedDates.length === 0) {
return
}
const formattedDate = this._formatDateForInput(selectedDates)
if (this._isInput) {
this._element.value = formattedDate
}
if (this._boundInput) {
this._boundInput.value = selectedDates.join(',')
}
if (this._displayElement) {
this._displayElement.textContent = formattedDate
}
}
protected _resolvePositionElement(): HTMLElement {
let { positionElement } = this._config
if (typeof positionElement === 'string') {
positionElement = document.querySelector<HTMLElement>(positionElement)
}
// Use input's parent if in form-adorn
if (!positionElement && this._isInput && !this._isInline) {
const parent = this._element.closest<HTMLElement>('.form-adorn')
if (parent) {
positionElement = parent
}
}
return positionElement || this._element
}
protected _resolveDisplayElement(): HTMLElement | false | null {
const { displayElement } = this._config
if (typeof displayElement === 'string') {
return document.querySelector<HTMLElement>(displayElement)
}
// For buttons/non-inputs (not inline), look for a [data-bs-datepicker-display] child
if (displayElement === true || (displayElement === null && !this._isInput && !this._isInline)) {
const displayChild = this._element.querySelector<HTMLElement>('[data-bs-datepicker-display]')
return displayChild || this._element
}
return displayElement
}
protected _getThemeAncestor(): Element | null {
return this._element.closest('[data-bs-theme]')
}
protected _getEffectiveTheme(): string | null {
// Priority: explicit datepickerTheme config > inherited from ancestor > none
const { datepickerTheme } = this._config
if (datepickerTheme) {
return datepickerTheme
}
const ancestor = this._getThemeAncestor()
return ancestor?.getAttribute('data-bs-theme') || null
}
protected _syncThemeAttribute(element: HTMLElement | undefined): void {
if (!element) {
return
}
const theme = this._getEffectiveTheme()
if (theme) {
// Copy theme to popover (needed because VCP appends to body, breaking CSS inheritance)
element.setAttribute('data-bs-theme', theme)
} else {
// No theme - remove attribute to allow natural inheritance
element.removeAttribute('data-bs-theme')
}
}
protected _setupThemeObserver(): void {
// Watch for theme changes on ancestor elements
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']
})
}
// VCP ignores focus and mounts the popup on `<body>`.
protected _setupDismissOnFocus(): void {
if (this._isInline) {
return
}
this._onFocusIn = event => {
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
}
this.hide()
}
EventHandler.on(document, EVENT_FOCUSIN, this._onFocusIn)
}
protected _buildCalendarOptions(): Options {
// Get theme for VCP - use 'system' for auto-detection if no explicit theme
const theme = this._getEffectiveTheme()
// VCP uses 'system' for auto, Bootstrap uses 'auto'
const vcpTheme = !theme || theme === 'auto' ? 'system' : theme
const calendarOptions: Options = {
...this._config.vcpOptions,
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,
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: () => {
if (!this._calendar) {
return
}
this._isShown = true
this._syncThemeAttribute(this._calendar.context.mainElement)
},
onHide: () => {
this._isShown = false
}
}
// Navigate to the month of the first selected date
if (this._config.selectedDates.length > 0) {
const firstDate = this._parseDate(this._config.selectedDates[0])
// `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
}
if (this._config.dateMax) {
calendarOptions.dateMax = this._config.dateMax
}
return calendarOptions
}
protected _handleDateClick(self: Calendar, event: MouseEvent): void {
const selectedDates = [...self.context.selectedDates]
if (selectedDates.length > 0) {
const formattedDate = this._formatDateForInput(selectedDates)
if (this._isInput) {
this._element.value = formattedDate
}
if (this._boundInput) {
this._boundInput.value = selectedDates.join(',')
}
if (this._displayElement) {
this._displayElement.textContent = formattedDate
}
}
EventHandler.trigger(this._element, EVENT_CHANGE, {
dates: selectedDates,
event
})
this._maybeHideAfterSelection(selectedDates)
}
protected _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) {
setTimeout(() => this.hide(), HIDE_DELAY)
}
}
protected _parseDate(dateStr: string): Date {
const [year, month, day]: any[] = dateStr.split('-')
return new Date(year, month - 1, day)
}
protected _formatDate(dateStr: string): string {
const date = this._parseDate(dateStr)
const locale = this._config.locale === 'default' ? undefined : this._config.locale
const { dateFormat } = this._config
// 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)
}
protected _formatDateForInput(dates: string[]): string {
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
const separator = this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', '
return dates.map(d => this._formatDate(d)).join(separator)
}
protected _parseInputValue(): void {
// Try to parse the input value as a date
const value = this._element.value.trim()
if (!value) {
return
}
const date = new Date(value)
if (!Number.isNaN(date.getTime())) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const formatted = `${year}-${month}-${day}`
this._calendar!.set({ selectedDates: [formatted] })
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
// Only handle if not an input (inputs use focus)
// Skip inline datepickers (they're always visible)
if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true') {
return
}
event.preventDefault()
Datepicker.getOrCreateInstance(this).toggle()
})
EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () {
// Handle focus for input elements
if (this.tagName !== 'INPUT') {
return
}
Datepicker.getOrCreateInstance(this).show()
})
// Auto-initialize datepickers that need to render on load:
// - inline datepickers (always visible)
// - datepickers with preselected dates (so the value populates without focus)
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
const selector = `${SELECTOR_DATA_TOGGLE}[data-bs-inline="true"], ${SELECTOR_DATA_TOGGLE}[data-bs-selected-dates]`
for (const element of document.querySelectorAll(selector)) {
Datepicker.getOrCreateInstance(element)
}
})
export default Datepicker
export type { DatepickerConfig }