mirror of https://github.com/twbs/bootstrap.git
444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
/*!
|
||
* Bootstrap datepicker.js v5.3.8 (https://getbootstrap.com/)
|
||
* Copyright 2011-2026 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||
*/
|
||
(function (global, factory) {
|
||
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('vanilla-calendar-pro'), require('./base-component.js'), require('./dom/event-handler.js'), require('./util/index.js')) :
|
||
typeof define === 'function' && define.amd ? define(['vanilla-calendar-pro', './base-component', './dom/event-handler', './util/index'], factory) :
|
||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Datepicker = factory(global["vanilla-calendar-pro"], global.BaseComponent, global.EventHandler, global.Index));
|
||
})(this, (function (vanillaCalendarPro, BaseComponent, EventHandler, index_js) { 'use strict';
|
||
|
||
/**
|
||
* --------------------------------------------------------------------------
|
||
* Bootstrap datepicker.js
|
||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||
* --------------------------------------------------------------------------
|
||
*/
|
||
|
||
|
||
/**
|
||
* 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_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
|
||
|
||
const Default = {
|
||
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 {
|
||
constructor(element, config) {
|
||
super(element, config);
|
||
this._calendar = null;
|
||
this._isShown = false;
|
||
this._initCalendar();
|
||
}
|
||
|
||
// Getters
|
||
static get Default() {
|
||
return Default;
|
||
}
|
||
static get DefaultType() {
|
||
return DefaultType;
|
||
}
|
||
static get NAME() {
|
||
return NAME;
|
||
}
|
||
|
||
// Public
|
||
toggle() {
|
||
if (this._config.inline) {
|
||
return; // Inline calendars are always visible
|
||
}
|
||
return this._isShown ? this.hide() : this.show();
|
||
}
|
||
show() {
|
||
if (this._config.inline) {
|
||
return; // Inline calendars are always visible
|
||
}
|
||
if (!this._calendar || index_js.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);
|
||
}
|
||
hide() {
|
||
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);
|
||
}
|
||
dispose() {
|
||
if (this._themeObserver) {
|
||
this._themeObserver.disconnect();
|
||
this._themeObserver = null;
|
||
}
|
||
if (this._calendar) {
|
||
this._calendar.destroy();
|
||
}
|
||
this._calendar = null;
|
||
super.dispose();
|
||
}
|
||
getSelectedDates() {
|
||
const dates = this._calendar?.context?.selectedDates;
|
||
return dates ? [...dates] : [];
|
||
}
|
||
setSelectedDates(dates) {
|
||
if (this._calendar) {
|
||
this._calendar.set({
|
||
selectedDates: dates
|
||
});
|
||
}
|
||
}
|
||
|
||
// Private
|
||
_initCalendar() {
|
||
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('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 vanillaCalendarPro.Calendar(this._positionElement, calendarOptions);
|
||
this._calendar.init();
|
||
|
||
// Watch for theme changes on ancestor elements (for live theme switching)
|
||
this._setupThemeObserver();
|
||
|
||
// Set initial value if input has a value
|
||
if (this._isInput && this._element.value) {
|
||
this._parseInputValue();
|
||
}
|
||
|
||
// Populate input/display with preselected dates
|
||
this._updateDisplayWithSelectedDates();
|
||
}
|
||
_updateDisplayWithSelectedDates() {
|
||
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;
|
||
}
|
||
}
|
||
_resolvePositionElement() {
|
||
let {
|
||
positionElement
|
||
} = this._config;
|
||
if (typeof positionElement === 'string') {
|
||
positionElement = document.querySelector(positionElement);
|
||
}
|
||
|
||
// Use input's parent if in form-adorn
|
||
if (!positionElement && this._isInput && !this._isInline) {
|
||
const parent = this._element.closest('.form-adorn');
|
||
if (parent) {
|
||
positionElement = parent;
|
||
}
|
||
}
|
||
return positionElement || this._element;
|
||
}
|
||
_resolveDisplayElement() {
|
||
const {
|
||
displayElement
|
||
} = this._config;
|
||
if (typeof displayElement === 'string') {
|
||
return document.querySelector(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('[data-bs-datepicker-display]');
|
||
return displayChild || this._element;
|
||
}
|
||
return displayElement;
|
||
}
|
||
_getThemeAncestor() {
|
||
return this._element.closest('[data-bs-theme]');
|
||
}
|
||
_getEffectiveTheme() {
|
||
// 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;
|
||
}
|
||
_syncThemeAttribute(element) {
|
||
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');
|
||
}
|
||
}
|
||
_setupThemeObserver() {
|
||
// 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']
|
||
});
|
||
}
|
||
_buildCalendarOptions() {
|
||
// 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 = {
|
||
...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: () => {
|
||
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]);
|
||
calendarOptions.selectedMonth = firstDate.getMonth();
|
||
calendarOptions.selectedYear = firstDate.getFullYear();
|
||
}
|
||
if (this._config.dateMin) {
|
||
calendarOptions.dateMin = this._config.dateMin;
|
||
}
|
||
if (this._config.dateMax) {
|
||
calendarOptions.dateMax = this._config.dateMax;
|
||
}
|
||
return calendarOptions;
|
||
}
|
||
_handleDateClick(self, event) {
|
||
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);
|
||
}
|
||
_maybeHideAfterSelection(selectedDates) {
|
||
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);
|
||
}
|
||
}
|
||
_parseDate(dateStr) {
|
||
const [year, month, day] = dateStr.split('-');
|
||
return new Date(year, month - 1, day);
|
||
}
|
||
_formatDate(dateStr) {
|
||
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);
|
||
}
|
||
_formatDateForInput(dates) {
|
||
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);
|
||
}
|
||
_parseInputValue() {
|
||
// 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 inline datepickers on DOMContentLoaded
|
||
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
|
||
for (const element of document.querySelectorAll(`${SELECTOR_DATA_TOGGLE}[data-bs-inline="true"]`)) {
|
||
Datepicker.getOrCreateInstance(element);
|
||
}
|
||
});
|
||
|
||
return Datepicker;
|
||
|
||
}));
|
||
//# sourceMappingURL=datepicker.js.map
|