diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
index 236c6ffa1df0..a535daf92269 100644
--- a/.bundlewatch.config.json
+++ b/.bundlewatch.config.json
@@ -18,43 +18,43 @@
},
{
"path": "./dist/css/bootstrap-utilities.css",
- "maxSize": "14.5 kB"
+ "maxSize": "14.25 kB"
},
{
"path": "./dist/css/bootstrap-utilities.min.css",
- "maxSize": "12.75 kB"
+ "maxSize": "12.5 kB"
},
{
"path": "./dist/css/bootstrap.css",
- "maxSize": "36.0 kB"
+ "maxSize": "37.75 kB"
},
{
"path": "./dist/css/bootstrap.min.css",
- "maxSize": "32.5 kB"
+ "maxSize": "34.0 kB"
},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "49.75 kB"
+ "maxSize": "67.75 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "26.0 kB"
+ "maxSize": "41.0 kB"
},
{
"path": "./dist/js/bootstrap.esm.js",
- "maxSize": "36.0 kB"
+ "maxSize": "39.0 kB"
},
{
"path": "./dist/js/bootstrap.esm.min.js",
- "maxSize": "22.25 kB"
+ "maxSize": "23.75 kB"
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "36.5 kB"
+ "maxSize": "39.5 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "19.75 kB"
+ "maxSize": "21.25 kB"
}
],
"ci": {
diff --git a/js/index.esm.js b/js/index.esm.js
index a4c7cdf3866c..01d298e05fce 100644
--- a/js/index.esm.js
+++ b/js/index.esm.js
@@ -9,6 +9,7 @@ export { default as Alert } from './src/alert.js'
export { default as Button } from './src/button.js'
export { default as Carousel } from './src/carousel.js'
export { default as Collapse } from './src/collapse.js'
+export { default as Datepicker } from './src/datepicker.js'
export { default as Dialog } from './src/dialog.js'
export { default as Dropdown } from './src/dropdown.js'
export { default as Offcanvas } from './src/offcanvas.js'
diff --git a/js/index.umd.js b/js/index.umd.js
index 4da18556e30e..73f12b424edd 100644
--- a/js/index.umd.js
+++ b/js/index.umd.js
@@ -9,6 +9,7 @@ import Alert from './src/alert.js'
import Button from './src/button.js'
import Carousel from './src/carousel.js'
import Collapse from './src/collapse.js'
+import Datepicker from './src/datepicker.js'
import Dialog from './src/dialog.js'
import Dropdown from './src/dropdown.js'
import Offcanvas from './src/offcanvas.js'
@@ -26,6 +27,7 @@ export default {
Button,
Carousel,
Collapse,
+ Datepicker,
Dialog,
Dropdown,
Offcanvas,
diff --git a/js/src/datepicker.js b/js/src/datepicker.js
new file mode 100644
index 000000000000..03821e974667
--- /dev/null
+++ b/js/src/datepicker.js
@@ -0,0 +1,451 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap datepicker.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { Calendar } 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_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 || 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 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()
+ }
+ }
+
+ _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
+ }
+ }
+
+ 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)
+ }
+})
+
+export default Datepicker
diff --git a/js/tests/unit/datepicker.spec.js b/js/tests/unit/datepicker.spec.js
new file mode 100644
index 000000000000..ad2710abf06b
--- /dev/null
+++ b/js/tests/unit/datepicker.spec.js
@@ -0,0 +1,1205 @@
+import EventHandler from '../../src/dom/event-handler.js'
+import Datepicker from '../../src/datepicker.js'
+import {
+ clearFixture, createEvent, getFixture
+} from '../helpers/fixture.js'
+
+describe('Datepicker', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ // Clean up any VCP calendar elements that may have been created
+ for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) {
+ calendarEl.remove()
+ }
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Datepicker.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Datepicker.Default).toEqual(jasmine.any(Object))
+ })
+
+ it('should have expected default values', () => {
+ expect(Datepicker.Default.dateMin).toBeNull()
+ expect(Datepicker.Default.dateMax).toBeNull()
+ expect(Datepicker.Default.selectionMode).toEqual('single')
+ expect(Datepicker.Default.firstWeekday).toEqual(1)
+ expect(Datepicker.Default.locale).toEqual('default')
+ expect(Datepicker.Default.placement).toEqual('left')
+ expect(Datepicker.Default.inline).toBeFalse()
+ expect(Datepicker.Default.displayMonthsCount).toEqual(1)
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type config', () => {
+ expect(Datepicker.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Datepicker.DATA_KEY).toEqual('bs.datepicker')
+ })
+ })
+
+ describe('NAME', () => {
+ it('should return plugin name', () => {
+ expect(Datepicker.NAME).toEqual('datepicker')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('#datepickerEl')
+ const datepickerBySelector = new Datepicker('#datepickerEl')
+ const datepickerByElement = new Datepicker(inputEl)
+
+ expect(datepickerBySelector._element).toEqual(inputEl)
+ expect(datepickerByElement._element).toEqual(inputEl)
+ })
+
+ it('should initialize VCP calendar instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._calendar).not.toBeNull()
+ })
+
+ it('should detect input element type', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._isInput).toBeTrue()
+ })
+
+ it('should detect button element type', () => {
+ fixtureEl.innerHTML = 'Select date '
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const datepicker = new Datepicker(buttonEl)
+
+ expect(datepicker._isInput).toBeFalse()
+ })
+
+ it('should use config from data attributes', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.selectionMode).toEqual('multiple-ranged')
+ expect(datepicker._config.firstWeekday).toEqual(0)
+ })
+
+ it('should merge passed config with data attributes', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ selectionMode: 'multiple'
+ })
+
+ expect(datepicker._config.selectionMode).toEqual('multiple')
+ expect(datepicker._config.firstWeekday).toEqual(0)
+ })
+ })
+
+ describe('show', () => {
+ it('should show the calendar popup', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ expect(datepicker._isShown).toBeTrue()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should trigger show.bs.datepicker event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('show.bs.datepicker', event => {
+ expect(event).toBeDefined()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should trigger shown.bs.datepicker event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', event => {
+ expect(event).toBeDefined()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should be cancelable via preventDefault', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('show.bs.datepicker', event => {
+ event.preventDefault()
+ })
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ throw new Error('shown event should not fire')
+ })
+
+ datepicker.show()
+
+ setTimeout(() => {
+ expect(datepicker._isShown).toBeFalse()
+ resolve()
+ }, 50)
+ })
+ })
+
+ it('should not show if already shown', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ datepicker._isShown = true
+
+ const spy = spyOn(EventHandler, 'trigger')
+ datepicker.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not show if disabled', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const spy = spyOn(EventHandler, 'trigger')
+ datepicker.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing for inline mode', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ const spy = spyOn(datepicker._calendar, 'show')
+ datepicker.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide the calendar popup', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ datepicker.hide()
+ })
+
+ inputEl.addEventListener('hidden.bs.datepicker', () => {
+ expect(datepicker._isShown).toBeFalse()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should trigger hide.bs.datepicker event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ datepicker.hide()
+ })
+
+ inputEl.addEventListener('hide.bs.datepicker', event => {
+ expect(event).toBeDefined()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should trigger hidden.bs.datepicker event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ datepicker.hide()
+ })
+
+ inputEl.addEventListener('hidden.bs.datepicker', event => {
+ expect(event).toBeDefined()
+ resolve()
+ })
+
+ datepicker.show()
+ })
+ })
+
+ it('should be cancelable via preventDefault', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ datepicker.hide()
+ })
+
+ inputEl.addEventListener('hide.bs.datepicker', event => {
+ event.preventDefault()
+ })
+
+ inputEl.addEventListener('hidden.bs.datepicker', () => {
+ throw new Error('hidden event should not fire')
+ })
+
+ datepicker.show()
+
+ setTimeout(() => {
+ expect(datepicker._isShown).toBeTrue()
+ resolve()
+ }, 50)
+ })
+ })
+
+ it('should not hide if not shown', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const spy = spyOn(EventHandler, 'trigger')
+ datepicker.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing for inline mode', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+ datepicker._isShown = true
+
+ const spy = spyOn(datepicker._calendar, 'hide')
+ datepicker.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('toggle', () => {
+ it('should show when hidden', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const showSpy = spyOn(datepicker, 'show')
+ datepicker.toggle()
+
+ expect(showSpy).toHaveBeenCalled()
+ })
+
+ it('should hide when shown', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ datepicker._isShown = true
+
+ const hideSpy = spyOn(datepicker, 'hide')
+ datepicker.toggle()
+
+ expect(hideSpy).toHaveBeenCalled()
+ })
+
+ it('should do nothing for inline mode', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ const showSpy = spyOn(datepicker, 'show')
+ const hideSpy = spyOn(datepicker, 'hide')
+ const result = datepicker.toggle()
+
+ expect(result).toBeUndefined()
+ expect(showSpy).not.toHaveBeenCalled()
+ expect(hideSpy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getSelectedDates / setSelectedDates', () => {
+ it('should return empty array when no dates selected', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker.getSelectedDates()).toEqual([])
+ })
+
+ it('should set selected dates', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+ const dates = ['2025-01-15']
+
+ datepicker.setSelectedDates(dates)
+
+ expect(datepicker.getSelectedDates()).toEqual(dates)
+ })
+
+ it('should return copy of dates array, not reference', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+ const dates = ['2025-01-15']
+
+ datepicker.setSelectedDates(dates)
+
+ const result = datepicker.getSelectedDates()
+ result.push('2025-01-20')
+
+ expect(datepicker.getSelectedDates()).toEqual(dates)
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy VCP instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const destroySpy = spyOn(datepicker._calendar, 'destroy')
+
+ datepicker.dispose()
+
+ expect(destroySpy).toHaveBeenCalled()
+ expect(datepicker._calendar).toBeNull()
+ })
+
+ it('should remove data from element', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(Datepicker.getInstance(inputEl)).toEqual(datepicker)
+
+ datepicker.dispose()
+
+ expect(Datepicker.getInstance(inputEl)).toBeNull()
+ })
+ })
+
+ describe('options', () => {
+ it('should respect dateMin option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.dateMin).toEqual('2025-01-01')
+ })
+
+ it('should respect dateMax option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.dateMax).toEqual('2025-12-31')
+ })
+
+ it('should respect selectionMode option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.selectionMode).toEqual('multiple-ranged')
+ })
+
+ it('should respect placement option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.placement).toEqual('right')
+ })
+
+ it('should respect inline option', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ expect(datepicker._config.inline).toBeTrue()
+ expect(datepicker._isInline).toBeTrue()
+ })
+
+ it('should respect displayMonthsCount option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.displayMonthsCount).toEqual(2)
+ })
+
+ it('should respect locale option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.locale).toEqual('de-DE')
+ })
+
+ it('should respect datepickerTheme option', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.datepickerTheme).toEqual('dark')
+ })
+ })
+
+ describe('theme detection', () => {
+ it('should detect theme from closest [data-bs-theme] ancestor', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._getEffectiveTheme()).toEqual('dark')
+ })
+
+ it('should use explicit datepickerTheme config over ancestor', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._getEffectiveTheme()).toEqual('light')
+ })
+
+ it('should return null when no theme detected', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._getEffectiveTheme()).toBeNull()
+ })
+
+ it('should set data-bs-theme on calendar element when ancestor has theme', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ // Manually sync theme (simulates what onInit does)
+ datepicker._syncThemeAttribute(datepicker._calendar.context.mainElement)
+
+ const calendarEl = datepicker._calendar.context.mainElement
+ expect(calendarEl.getAttribute('data-bs-theme')).toEqual('dark')
+ })
+
+ it('should not have data-bs-theme when no theme is set', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ // Manually sync theme (simulates what onInit does)
+ datepicker._syncThemeAttribute(datepicker._calendar.context.mainElement)
+
+ const calendarEl = datepicker._calendar.context.mainElement
+ expect(calendarEl.hasAttribute('data-bs-theme')).toBeFalse()
+ })
+ })
+
+ describe('form-adorn integration', () => {
+ it('should use .form-adorn parent as position element', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const adornEl = fixtureEl.querySelector('.form-adorn')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._positionElement).toEqual(adornEl)
+ })
+
+ it('should still use input element when not in form-adorn', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._positionElement).toEqual(inputEl)
+ })
+
+ it('should respect explicit positionElement option', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' '
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const customEl = fixtureEl.querySelector('#custom-position')
+ const datepicker = new Datepicker(inputEl, {
+ positionElement: '#custom-position'
+ })
+
+ expect(datepicker._positionElement).toEqual(customEl)
+ })
+ })
+
+ describe('button trigger', () => {
+ it('should use button element as display element by default', () => {
+ fixtureEl.innerHTML = 'Select date '
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const datepicker = new Datepicker(buttonEl)
+
+ expect(datepicker._displayElement).toEqual(buttonEl)
+ })
+
+ it('should use [data-bs-datepicker-display] child if present', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' Icon ',
+ ' Select date ',
+ ' '
+ ].join('')
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const displayEl = fixtureEl.querySelector('[data-bs-datepicker-display]')
+ const datepicker = new Datepicker(buttonEl)
+
+ expect(datepicker._displayElement).toEqual(displayEl)
+ })
+
+ it('should not set display element for inputs', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._displayElement).toBeNull()
+ })
+ })
+
+ describe('date formatting', () => {
+ it('should format single date with default locale', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const result = datepicker._formatDate('2025-01-15')
+
+ // Should be a string (format varies by system locale)
+ expect(typeof result).toEqual('string')
+ expect(result.length).toBeGreaterThan(0)
+ })
+
+ it('should format date with custom dateFormat options', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ dateFormat: { year: 'numeric', month: 'short', day: 'numeric' },
+ locale: 'en-US'
+ })
+
+ const result = datepicker._formatDate('2025-01-15')
+
+ expect(result).toEqual('Jan 15, 2025')
+ })
+
+ it('should format date with custom function', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ dateFormat: date => `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
+ })
+
+ const result = datepicker._formatDate('2025-01-15')
+
+ expect(result).toEqual('2025-1-15')
+ })
+
+ it('should use en-dash for date ranges', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ locale: 'en-US'
+ })
+
+ const result = datepicker._formatDateForInput(['2025-01-15', '2025-01-20'])
+
+ expect(result).toContain(' – ')
+ })
+
+ it('should use comma for multiple dates', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ locale: 'en-US'
+ })
+
+ const result = datepicker._formatDateForInput(['2025-01-15', '2025-01-20'])
+
+ expect(result).toContain(', ')
+ })
+ })
+
+ describe('inline mode', () => {
+ it('should set _isInline to true', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ expect(datepicker._isInline).toBeTrue()
+ })
+
+ it('should find hidden input for value binding', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const divEl = fixtureEl.querySelector('div')
+ const hiddenInput = fixtureEl.querySelector('input[type="hidden"]')
+ const datepicker = new Datepicker(divEl)
+
+ expect(datepicker._boundInput).toEqual(hiddenInput)
+ })
+
+ it('should not look for hidden input when element is an input', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._boundInput).toBeUndefined()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should toggle on click for buttons', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = 'Select date '
+
+ const buttonEl = fixtureEl.querySelector('button')
+
+ buttonEl.addEventListener('shown.bs.datepicker', () => {
+ const datepicker = Datepicker.getInstance(buttonEl)
+ expect(datepicker._isShown).toBeTrue()
+ resolve()
+ })
+
+ buttonEl.click()
+ })
+ })
+
+ it('should show on focus for inputs', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ inputEl.addEventListener('shown.bs.datepicker', () => {
+ const datepicker = Datepicker.getInstance(inputEl)
+ expect(datepicker._isShown).toBeTrue()
+ resolve()
+ })
+
+ // Trigger focusin event
+ const focusEvent = createEvent('focusin', { bubbles: true })
+ inputEl.dispatchEvent(focusEvent)
+ })
+ })
+
+ it('should not toggle on click for inputs', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const toggleSpy = spyOn(datepicker, 'toggle')
+
+ const clickEvent = createEvent('click', { bubbles: true })
+ inputEl.dispatchEvent(clickEvent)
+
+ expect(toggleSpy).not.toHaveBeenCalled()
+ })
+
+ it('should not toggle for inline datepickers', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ // Manually create instance (auto-init happens on DOMContentLoaded)
+ const datepicker = new Datepicker(divEl)
+
+ const toggleSpy = spyOn(datepicker, 'toggle')
+
+ const clickEvent = createEvent('click', { bubbles: true })
+ divEl.dispatchEvent(clickEvent)
+
+ expect(toggleSpy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return datepicker instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(Datepicker.getInstance(inputEl)).toEqual(datepicker)
+ expect(Datepicker.getInstance(inputEl)).toBeInstanceOf(Datepicker)
+ })
+
+ it('should return null when there is no datepicker instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ expect(Datepicker.getInstance(inputEl)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return datepicker instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(Datepicker.getOrCreateInstance(inputEl)).toEqual(datepicker)
+ expect(Datepicker.getInstance(inputEl)).toEqual(Datepicker.getOrCreateInstance(inputEl, {}))
+ expect(Datepicker.getOrCreateInstance(inputEl)).toBeInstanceOf(Datepicker)
+ })
+
+ it('should return new instance when there is no datepicker instance', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ expect(Datepicker.getInstance(inputEl)).toBeNull()
+ expect(Datepicker.getOrCreateInstance(inputEl)).toBeInstanceOf(Datepicker)
+ })
+
+ it('should return new instance when there is no datepicker instance with given configuration', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ expect(Datepicker.getInstance(inputEl)).toBeNull()
+ const datepicker = Datepicker.getOrCreateInstance(inputEl, {
+ selectionMode: 'multiple-ranged'
+ })
+ expect(datepicker).toBeInstanceOf(Datepicker)
+ expect(datepicker._config.selectionMode).toEqual('multiple-ranged')
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ selectionMode: 'multiple-ranged'
+ })
+ expect(Datepicker.getInstance(inputEl)).toEqual(datepicker)
+
+ const datepicker2 = Datepicker.getOrCreateInstance(inputEl, {
+ selectionMode: 'single'
+ })
+ expect(datepicker).toBeInstanceOf(Datepicker)
+ expect(datepicker2).toEqual(datepicker)
+
+ // Config should not change
+ expect(datepicker2._config.selectionMode).toEqual('multiple-ranged')
+ })
+ })
+
+ describe('input value parsing', () => {
+ it('should parse initial input value', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ // Should have one date selected (exact date may vary due to timezone)
+ const dates = datepicker.getSelectedDates()
+ expect(dates.length).toEqual(1)
+ expect(dates[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/)
+ })
+
+ it('should handle invalid input value gracefully', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ // Should not throw
+ expect(() => new Datepicker(inputEl)).not.toThrow()
+ })
+
+ it('should handle empty input value', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker.getSelectedDates()).toEqual([])
+ })
+ })
+
+ describe('vcpOptions pass-through', () => {
+ it('should pass vcpOptions to VCP', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl, {
+ vcpOptions: {
+ jumpMonths: 2
+ }
+ })
+
+ expect(datepicker._config.vcpOptions.jumpMonths).toEqual(2)
+ })
+ })
+
+ describe('date selection handling', () => {
+ it('should trigger change.bs.datepicker event on date click', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ inputEl.addEventListener('change.bs.datepicker', event => {
+ expect(event.dates).toBeDefined()
+ expect(Array.isArray(event.dates)).toBeTrue()
+ resolve()
+ })
+
+ // Simulate VCP date click callback
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+ })
+ })
+
+ it('should update input value on date selection', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ expect(inputEl.value).not.toEqual('')
+ })
+
+ it('should update display element on date selection for buttons', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' Select date ',
+ ' '
+ ].join('')
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const displayEl = fixtureEl.querySelector('[data-bs-datepicker-display]')
+ const datepicker = new Datepicker(buttonEl)
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ expect(displayEl.textContent).not.toEqual('Select date')
+ })
+
+ it('should update bound hidden input in inline mode', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const divEl = fixtureEl.querySelector('div')
+ const hiddenInput = fixtureEl.querySelector('input[type="hidden"]')
+ const datepicker = new Datepicker(divEl)
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ expect(hiddenInput.value).toEqual('2025-01-15')
+ })
+
+ it('should auto-hide after single date selection', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const hideSpy = spyOn(datepicker, 'hide')
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ setTimeout(() => {
+ expect(hideSpy).toHaveBeenCalled()
+ resolve()
+ }, 150)
+ })
+ })
+
+ it('should auto-hide after range selection with 2 dates', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const hideSpy = spyOn(datepicker, 'hide')
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15', '2025-01-20'] }
+ }, new Event('click'))
+
+ setTimeout(() => {
+ expect(hideSpy).toHaveBeenCalled()
+ resolve()
+ }, 150)
+ })
+ })
+
+ it('should not auto-hide in range mode with only 1 date', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ const hideSpy = spyOn(datepicker, 'hide')
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ setTimeout(() => {
+ expect(hideSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 150)
+ })
+ })
+
+ it('should not auto-hide in inline mode', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ const hideSpy = spyOn(datepicker, 'hide')
+
+ datepicker._handleDateClick({
+ context: { selectedDates: ['2025-01-15'] }
+ }, new Event('click'))
+
+ setTimeout(() => {
+ expect(hideSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 150)
+ })
+ })
+
+ it('should handle empty date selection', () => {
+ fixtureEl.innerHTML = ' '
+
+ const inputEl = fixtureEl.querySelector('input')
+ inputEl.value = 'previous value'
+ const datepicker = new Datepicker(inputEl)
+
+ // Should not throw
+ expect(() => {
+ datepicker._handleDateClick({
+ context: { selectedDates: [] }
+ }, new Event('click'))
+ }).not.toThrow()
+
+ // Value should not be updated when no dates
+ expect(inputEl.value).toEqual('previous value')
+ })
+ })
+
+ describe('_maybeHideAfterSelection', () => {
+ it('should not hide when inline', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ const hideSpy = spyOn(datepicker, 'hide')
+
+ datepicker._maybeHideAfterSelection(['2025-01-15'])
+
+ setTimeout(() => {
+ expect(hideSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 150)
+ })
+ })
+ })
+
+ describe('_resolvePositionElement', () => {
+ it('should resolve selector string to element', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' '
+ ].join('')
+
+ const inputEl = fixtureEl.querySelector('input')
+ const targetEl = fixtureEl.querySelector('#position-target')
+ const datepicker = new Datepicker(inputEl, {
+ positionElement: '#position-target'
+ })
+
+ expect(datepicker._positionElement).toEqual(targetEl)
+ })
+ })
+
+ describe('_resolveDisplayElement', () => {
+ it('should resolve selector string to element', () => {
+ fixtureEl.innerHTML = [
+ ' ',
+ 'Click '
+ ].join('')
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const targetEl = fixtureEl.querySelector('#display-target')
+ const datepicker = new Datepicker(buttonEl, {
+ displayElement: '#display-target'
+ })
+
+ expect(datepicker._displayElement).toEqual(targetEl)
+ })
+
+ it('should use element directly when passed', () => {
+ fixtureEl.innerHTML = [
+ ' ',
+ 'Click '
+ ].join('')
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const targetEl = fixtureEl.querySelector('#display-target')
+ const datepicker = new Datepicker(buttonEl, {
+ displayElement: targetEl
+ })
+
+ expect(datepicker._displayElement).toEqual(targetEl)
+ })
+ })
+})
diff --git a/js/tests/visual/datepicker.html b/js/tests/visual/datepicker.html
new file mode 100644
index 000000000000..70db69c4865e
--- /dev/null
+++ b/js/tests/visual/datepicker.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ Datepicker
+
+
+
+
Datepicker Bootstrap Visual Test
+
+
+
+
Basic Input Datepicker
+
+
+
+
+
With Min/Max Dates
+
+
+ Only dates in 2025
+
+
+
+
+
+
+
Multiple Selection
+
+
+ Select multiple dates
+
+
+
+
+
+
+
Range Selection
+
+
+ Select a date range
+
+
+
+
+
+
+
Week Numbers
+
+
+ With week numbers
+
+
+
+
+
+
+
Sunday First
+
+
+ Week starts on Sunday
+
+
+
+
+
+
+
Dark Mode
+
+
+
+ Dark mode datepicker
+
+
+
+
+
+
+
+
JavaScript Initialization
+
+
+
Initialized via JavaScript
+
+
+ Show
+ Hide
+ Get Dates
+
+
+
+
+
+
+
+
Events Test
+
+
+
Events datepicker
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index e42ad0080188..a1b469a7d547 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,8 @@
],
"license": "MIT",
"dependencies": {
- "postcss-prefix-custom-properties": "^0.1.0"
+ "postcss-prefix-custom-properties": "^0.1.0",
+ "vanilla-calendar-pro": "^3.0.5"
},
"devDependencies": {
"@astrojs/check": "^0.9.5",
@@ -19737,6 +19738,16 @@
"spdx-expression-parse": "^3.0.0"
}
},
+ "node_modules/vanilla-calendar-pro": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.0.5.tgz",
+ "integrity": "sha512-4X9bmTo1/KzbZrB7B6mZXtvVXIhcKxaVSnFZuaVtps7tshKJDxgaIElkgdia6IjB5qWetWuu7kZ+ZaV1sPxy6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "individual",
+ "url": "https://buymeacoffee.com/uvarov"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/package.json b/package.json
index fe99cc23746d..40a686d202fb 100644
--- a/package.json
+++ b/package.json
@@ -210,6 +210,7 @@
"volar-service-emmet": "0.0.63"
},
"dependencies": {
- "postcss-prefix-custom-properties": "^0.1.0"
+ "postcss-prefix-custom-properties": "^0.1.0",
+ "vanilla-calendar-pro": "^3.0.5"
}
}
diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss
new file mode 100644
index 000000000000..218c08aa5c3c
--- /dev/null
+++ b/scss/_datepicker.scss
@@ -0,0 +1,426 @@
+// stylelint-disable selector-max-attribute, property-disallowed-list, selector-no-qualifying-type -- VCP uses extensive data attributes and requires direct border-radius properties for range selection
+
+@use "config" as *;
+@use "colors" as *;
+@use "variables" as *;
+@use "mixins/border-radius" as *;
+@use "mixins/focus-ring" as *;
+
+// scss-docs-start datepicker-variables
+$datepicker-padding: 1rem !default;
+$datepicker-bg: var(--bg-body) !default;
+$datepicker-color: var(--fg-body) !default;
+$datepicker-border-color: var(--border-color-translucent) !default;
+$datepicker-border-width: var(--border-width) !default;
+$datepicker-border-radius: var(--border-radius-lg) !default;
+$datepicker-box-shadow: var(--box-shadow) !default;
+$datepicker-font-size: var(--font-size-sm) !default;
+$datepicker-min-width: 280px !default;
+
+$datepicker-header-font-weight: 600 !default;
+$datepicker-weekday-color: var(--fg-3) !default;
+$datepicker-day-hover-bg: var(--bg-1) !default;
+$datepicker-day-selected-bg: var(--primary-bg) !default;
+$datepicker-day-selected-color: var(--primary-contrast) !default;
+$datepicker-day-today-bg: var(--bg-2) !default;
+$datepicker-day-today-color: var(--fg-1) !default;
+$datepicker-day-disabled-color: var(--fg-4) !default;
+// scss-docs-end datepicker-variables
+
+@layer components {
+ [data-vc="calendar"] {
+ // scss-docs-start datepicker-css-vars
+ --datepicker-padding: #{$datepicker-padding};
+ --datepicker-bg: #{$datepicker-bg};
+ --datepicker-color: #{$datepicker-color};
+ --datepicker-border-color: #{$datepicker-border-color};
+ --datepicker-border-width: #{$datepicker-border-width};
+ --datepicker-border-radius: #{$datepicker-border-radius};
+ --datepicker-box-shadow: #{$datepicker-box-shadow};
+ --datepicker-font-size: #{$datepicker-font-size};
+ --datepicker-min-width: #{$datepicker-min-width};
+ --datepicker-zindex: #{$zindex-dropdown};
+
+ --datepicker-header-font-weight: #{$datepicker-header-font-weight};
+ --datepicker-weekday-color: #{$datepicker-weekday-color};
+ --datepicker-day-hover-bg: #{$datepicker-day-hover-bg};
+ --datepicker-day-selected-bg: #{$datepicker-day-selected-bg};
+ --datepicker-day-selected-color: #{$datepicker-day-selected-color};
+ --datepicker-day-today-bg: #{$datepicker-day-today-bg};
+ --datepicker-day-today-color: #{$datepicker-day-today-color};
+ --datepicker-day-disabled-color: #{$datepicker-day-disabled-color};
+ // scss-docs-end datepicker-css-vars
+
+ position: absolute;
+ z-index: var(--datepicker-zindex);
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ min-width: var(--datepicker-min-width);
+ padding: var(--datepicker-padding);
+ font-family: var(--font-sans-serif);
+ font-size: var(--datepicker-font-size);
+ color: var(--datepicker-color);
+ color-scheme: light dark;
+ background-color: var(--datepicker-bg);
+ border: var(--datepicker-border-width) solid var(--datepicker-border-color);
+ @include border-radius(var(--datepicker-border-radius));
+ box-shadow: var(--datepicker-box-shadow);
+ opacity: 1;
+
+ // Respond to Bootstrap's color mode system
+ &[data-bs-theme="light"] {
+ color-scheme: light;
+ }
+
+ &[data-bs-theme="dark"] {
+ color-scheme: dark;
+ }
+
+ // Catch-all for focus styles
+ button:focus-visible {
+ position: relative;
+ z-index: 1;
+ @include focus-ring();
+ }
+ }
+
+ [data-vc-calendar-hidden] {
+ pointer-events: none;
+ opacity: 0;
+ }
+
+ // Inline calendars
+ //
+ // Remove popover styling for more neutral styling
+ [data-vc="calendar"]:not([data-vc-input]) {
+ position: relative;
+ width: fit-content;
+ padding: 0;
+ border: 0;
+ box-shadow: none;
+ }
+
+ [data-vc-position="bottom"] {
+ margin-block-start: .25rem;
+ }
+
+ [data-vc-position="top"] {
+ margin-block-end: -.25rem;
+ }
+
+ [data-vc-arrow] {
+ position: relative;
+ display: block;
+ width: 2rem;
+ height: 2rem;
+ color: var(--datepicker-color);
+ pointer-events: auto;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ @include border-radius($border-radius);
+
+ &::before {
+ position: absolute;
+ inset: .25rem;
+ content: "";
+ background-image: url("data:image/svg+xml, ");
+ background-repeat: no-repeat;
+ background-position: center;
+ }
+
+ &:hover {
+ background-color: var(--datepicker-day-hover-bg);
+ }
+ }
+
+ [data-vc-arrow="prev"]::before {
+ transform: rotate(90deg);
+ }
+
+ [data-vc-arrow="next"]::before {
+ transform: rotate(-90deg);
+ }
+
+ // Grid layout
+ [data-vc="controls"] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 1rem;
+ padding-right: 1rem;
+ padding-left: 1rem;
+ pointer-events: none;
+ }
+
+ [data-vc="grid"] {
+ display: flex;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ gap: 1.75rem;
+ }
+
+ [data-vc="column"] {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ min-width: 240px;
+ }
+
+ //
+ // Header
+ //
+
+ [data-vc="header"] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin-bottom: .75rem;
+ }
+
+ // Month and year
+ [data-vc-header="content"] {
+ display: inline-flex;
+ flex-grow: 1;
+ align-items: center;
+ justify-content: center;
+ white-space: pre-wrap;
+ }
+
+ [data-vc="month"],
+ [data-vc="year"] {
+ padding: .25rem .5rem;
+ margin-inline: -.125rem;
+ font-size: 1rem;
+ font-weight: var(--datepicker-header-font-weight);
+ color: var(--datepicker-color);
+ // cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ @include border-radius($border-radius);
+
+ &:disabled {
+ color: var(--datepicker-day-disabled-color);
+ pointer-events: none;
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--datepicker-day-hover-bg);
+ }
+ }
+
+
+ [data-vc="content"] {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ }
+
+ // Month/Year grids
+ [data-vc="months"],
+ [data-vc="years"] {
+ display: grid;
+ flex-grow: 1;
+ grid-template-columns: repeat(var(--vc-columns, 4), minmax(0, 1fr));
+ row-gap: 1rem;
+ column-gap: .25rem;
+ align-items: center;
+ }
+
+ [data-vc="years"] {
+ --vc-columns: 5;
+ }
+
+ [data-vc-months-month],
+ [data-vc-years-year] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 2.5rem;
+ padding: .25rem;
+ font-size: .75rem;
+ font-weight: 600;
+ line-height: 1rem;
+ color: var(--datepicker-weekday-color);
+ text-align: center;
+ word-break: break-all;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ @include border-radius($border-radius);
+
+ &:disabled {
+ color: var(--datepicker-day-disabled-color);
+ pointer-events: none;
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--datepicker-day-hover-bg);
+ }
+
+ &[data-vc-months-month-selected],
+ &[data-vc-years-year-selected] {
+ color: var(--datepicker-day-selected-color);
+ background-color: var(--datepicker-day-selected-bg);
+
+ &:hover {
+ color: var(--datepicker-day-selected-color);
+ background-color: var(--datepicker-day-selected-bg);
+ }
+ }
+ }
+
+ // Week days header
+ [data-vc="week"] {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ justify-items: center;
+ margin-bottom: .5rem;
+ }
+
+ [data-vc-week-day] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-width: 1.875rem;
+ padding: 0;
+ margin: 0;
+ font-size: .75rem;
+ font-weight: 600;
+ line-height: 1rem;
+ color: var(--datepicker-weekday-color);
+ background-color: transparent;
+ border: 0;
+ }
+
+ button[data-vc-week-day] {
+ cursor: pointer;
+ }
+
+ // Dates grid
+ [data-vc="dates"] {
+ display: grid;
+ flex-grow: 1;
+ grid-template-columns: repeat(7, 1fr);
+ align-items: center;
+ justify-items: center;
+ pointer-events: none;
+ }
+
+ [data-vc-date] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ padding-top: .125rem;
+ padding-bottom: .125rem;
+ pointer-events: auto;
+
+ &:not(:has([data-vc-date-btn])),
+ &[data-vc-date-disabled],
+ &[data-vc-date-disabled] [data-vc-date-btn] {
+ pointer-events: none;
+ }
+ }
+
+ // Date button
+ [data-vc-date-btn] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-width: 1.875rem;
+ height: 100%;
+ min-height: 1.875rem;
+ padding: 0;
+ font-size: .75rem;
+ font-weight: 400;
+ line-height: 1rem;
+ color: var(--datepicker-color);
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ border-radius: $border-radius;
+
+ &:hover {
+ background-color: var(--datepicker-day-hover-bg);
+ }
+ }
+
+ // Today
+ [data-vc-date-today] [data-vc-date-btn] {
+ font-weight: 600;
+ color: var(--datepicker-day-today-color);
+ background-color: var(--datepicker-day-today-bg);
+ }
+
+
+ // Outside month
+ [data-vc-date-month="next"] [data-vc-date-btn],
+ [data-vc-date-month="prev"] [data-vc-date-btn] {
+ opacity: .5;
+ }
+
+ // Disabled
+ [data-vc-date-disabled] [data-vc-date-btn] {
+ color: var(--datepicker-day-disabled-color);
+ }
+
+ // Range selection styles
+ [data-vc-date-hover] [data-vc-date-btn] {
+ background-color: var(--datepicker-day-hover-bg);
+ border-radius: 0;
+ }
+
+ [data-vc-date-hover="first"] [data-vc-date-btn] {
+ border-start-start-radius: $border-radius;
+ border-end-start-radius: $border-radius;
+ }
+
+ [data-vc-date-hover="last"] [data-vc-date-btn] {
+ border-start-end-radius: $border-radius;
+ border-end-end-radius: $border-radius;
+ }
+
+ [data-vc-date-hover="first-and-last"] [data-vc-date-btn] {
+ border-radius: $border-radius;
+ }
+
+ [data-vc-date-selected="middle"] [data-vc-date-btn] {
+ border-radius: 0;
+ opacity: .8;
+ }
+
+ // Selected
+ [data-vc-date-selected] [data-vc-date-btn] {
+ color: var(--datepicker-day-selected-color);
+ background-color: var(--datepicker-day-selected-bg);
+
+ }
+
+ [data-vc-date-selected="first"] [data-vc-date-btn] {
+ border-top-left-radius: $border-radius;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: $border-radius;
+ }
+
+ [data-vc-date-selected="last"] [data-vc-date-btn] {
+ border-top-left-radius: 0;
+ border-top-right-radius: $border-radius;
+ border-bottom-right-radius: $border-radius;
+ border-bottom-left-radius: 0;
+ }
+
+ [data-vc-date-selected="first-and-last"] [data-vc-date-btn] {
+ border-radius: $border-radius;
+ }
+}
diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss
index 27ad32e3fbb2..09f9662639ef 100644
--- a/scss/bootstrap.scss
+++ b/scss/bootstrap.scss
@@ -17,6 +17,7 @@
@forward "breadcrumb";
@forward "card";
@forward "carousel";
+@forward "datepicker";
@forward "dialog";
@forward "dropdown";
@forward "list-group";
diff --git a/scss/forms/_form-adorn.scss b/scss/forms/_form-adorn.scss
new file mode 100644
index 000000000000..f38892befeac
--- /dev/null
+++ b/scss/forms/_form-adorn.scss
@@ -0,0 +1,111 @@
+@use "../config" as *;
+@use "../variables" as *;
+@use "../mixins/border-radius" as *;
+@use "../mixins/box-shadow" as *;
+@use "../mixins/focus-ring" as *;
+@use "../mixins/transition" as *;
+@use "form-variables" as *;
+
+// scss-docs-start form-adorn-variables
+$form-adorn-gap: .375rem !default;
+$form-adorn-icon-size: 1rem !default;
+$form-adorn-icon-color: var(--fg-2) !default;
+// scss-docs-end form-adorn-variables
+
+@layer forms {
+ .form-adorn {
+ // Inherit form-control CSS variables for sizing
+ --control-min-height: #{$control-min-height};
+ --control-padding-y: #{$control-padding-y};
+ --control-padding-x: #{$control-padding-x};
+ --control-font-size: #{$control-font-size};
+ --control-line-height: #{$control-line-height};
+ --control-color: #{$control-color};
+ --control-bg: #{$control-bg};
+ --control-border-width: #{$control-border-width};
+ --control-border-color: #{$control-border-color};
+ --control-border-radius: #{$control-border-radius};
+
+ // Adorn-specific variables
+ --form-adorn-gap: #{$form-adorn-gap};
+ --form-adorn-icon-size: #{$form-adorn-icon-size};
+ --form-adorn-icon-color: #{$form-adorn-icon-color};
+
+ // Flexbox layout
+ display: flex;
+ gap: var(--form-adorn-gap);
+ align-items: center;
+
+ // Replicate .form-control styles on the wrapper
+ min-height: var(--control-min-height);
+ padding: var(--control-padding-y) var(--control-padding-x);
+ font-size: var(--control-font-size);
+ line-height: var(--control-line-height);
+ color: var(--control-color);
+ background-color: var(--control-bg);
+ background-clip: padding-box;
+ border: var(--control-border-width) solid var(--control-border-color);
+ @include border-radius(var(--control-border-radius), 0);
+ @include box-shadow($input-box-shadow);
+ @include transition($input-transition);
+
+ // Focus state when ghost input is focused
+ &:focus-within {
+ border-color: $input-focus-border-color;
+ @include focus-ring(true);
+ --focus-ring-offset: -1px;
+ }
+
+ // Ghost input fills remaining space
+ > .form-ghost {
+ flex: 1;
+ min-width: 0; // Prevent text overflow
+ }
+
+
+ // Adornment at end (right in LTR) - input comes first visually
+ &.form-adorn-end > .form-ghost {
+ order: -1;
+ }
+ }
+
+ .form-adorn-icon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ color: var(--form-adorn-icon-color);
+ pointer-events: none;
+
+ > svg {
+ width: var(--form-adorn-icon-size);
+ height: var(--form-adorn-icon-size);
+ }
+ }
+
+ .form-adorn-text {
+ flex-shrink: 0;
+ color: var(--form-adorn-icon-color);
+ pointer-events: none;
+ user-select: none;
+ }
+
+ // Sizing variants
+ .form-adorn-sm {
+ --control-min-height: #{$control-min-height-sm};
+ --control-padding-y: #{$control-padding-y-sm};
+ --control-padding-x: #{$control-padding-x-sm};
+ --control-font-size: #{$control-font-size-sm};
+ --control-line-height: #{$control-line-height-sm};
+ --control-border-radius: #{$control-border-radius-sm};
+ }
+
+ .form-adorn-lg {
+ --control-min-height: #{$control-min-height-lg};
+ --control-padding-y: #{$control-padding-y-lg};
+ --control-padding-x: #{$control-padding-x-lg};
+ --control-font-size: #{$control-font-size-lg};
+ --control-line-height: #{$control-line-height-lg};
+ --control-border-radius: #{$control-border-radius-lg};
+ }
+}
diff --git a/scss/forms/_form-control.scss b/scss/forms/_form-control.scss
index 07b8b134dadd..ff18bd102bdd 100644
--- a/scss/forms/_form-control.scss
+++ b/scss/forms/_form-control.scss
@@ -243,4 +243,31 @@
&.form-control-sm { height: $input-height-sm; }
&.form-control-lg { height: $input-height-lg; }
}
+
+ // Ghost input - removes all visual styling
+ // Used inside custom wrappers that handle their own styling
+ .form-ghost {
+ display: block;
+ width: 100%;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ appearance: none;
+ background: transparent;
+ border: 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ &::placeholder {
+ color: var(--fg-3);
+ opacity: 1;
+ }
+
+ &:disabled {
+ color: var(--fg-4);
+ cursor: not-allowed;
+ }
+ }
}
diff --git a/scss/forms/index.scss b/scss/forms/index.scss
index 54539eebb604..4817b882c1aa 100644
--- a/scss/forms/index.scss
+++ b/scss/forms/index.scss
@@ -9,4 +9,5 @@
@forward "input-group";
@forward "strength";
@forward "otp-input";
+@forward "form-adorn";
@forward "validation";
diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml
index f8fa3d610084..50df81cf06d8 100644
--- a/site/data/sidebar.yml
+++ b/site/data/sidebar.yml
@@ -75,6 +75,7 @@
- title: Floating labels
- title: OTP input
- title: Password strength
+ - title: Form adorn
- title: Layout
- title: Validation
@@ -92,6 +93,7 @@
- title: Carousel
- title: Close button
- title: Collapse
+ - title: Datepicker
- title: Dialog
- title: Dropdown
- title: List group
diff --git a/site/src/components/icons/Symbols.astro b/site/src/components/icons/Symbols.astro
index 0bb9ed7c3e12..f6d6351c363e 100644
--- a/site/src/components/icons/Symbols.astro
+++ b/site/src/components/icons/Symbols.astro
@@ -35,6 +35,10 @@
d="M1.114 8.063V7.9c1.005-.102 1.497-.615 1.497-1.6V4.503c0-1.094.39-1.538 1.354-1.538h.273V2h-.376C2.25 2 1.49 2.759 1.49 4.352v1.524c0 1.094-.376 1.456-1.49 1.456v1.299c1.114 0 1.49.362 1.49 1.456v1.524c0 1.593.759 2.352 2.372 2.352h.376v-.964h-.273c-.964 0-1.354-.444-1.354-1.538V9.663c0-.984-.492-1.497-1.497-1.6ZM14.886 7.9v.164c-1.005.103-1.497.616-1.497 1.6v1.798c0 1.094-.39 1.538-1.354 1.538h-.273v.964h.376c1.613 0 2.372-.759 2.372-2.352v-1.524c0-1.094.376-1.456 1.49-1.456v-1.3c-1.114 0-1.49-.362-1.49-1.456V4.352C14.51 2.759 13.75 2 12.138 2h-.376v.964h.273c.964 0 1.354.444 1.354 1.538V6.3c0 .984.492 1.497 1.497 1.6ZM7.5 11.5V9.207l-1.621 1.621-.707-.707L6.792 8.5H4.5v-1h2.293L5.172 5.879l.707-.707L7.5 6.792V4.5h1v2.293l1.621-1.621.707.707L9.208 7.5H11.5v1H9.207l1.621 1.621-.707.707L8.5 9.208V11.5h-1Z"
>
+
+
+
+
+
+
+
+
+
+
Datepicker
+ `} />
+
+Note that we're using a width utility of `.w-12` to ensure the input is wide enough to accommodate the date format and imply some affordance for the expected type of input.
+
+## How it works
+
+- Add `data-bs-toggle="datepicker"` to any ` ` element to enable the datepicker
+- Use `type="text"` to avoid conflicts with native browser date pickers
+- When focused, the calendar popup appears below the input
+- Selecting a date updates the input value and closes the picker
+- The picker respects Bootstrap's color modes (`data-bs-theme`)
+- Configurable with any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings) via `vcpOptions` when initializing with JavaScript
+
+## Examples
+
+### With icon
+
+Use the [form adorn component](/docs/forms/form-adorn) to add a calendar icon alongside the datepicker input. When the input is inside a `.form-adorn` wrapper, the calendar automatically positions relative to the wrapper instead of the input.
+
+Select date
+ `} />
+
+### Min & Max dates
+
+Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`.
+
+Event date (2025 only)
+ `} />
+
+### Multiple dates
+
+Enable multiple date selection with `data-bs-selection-mode="multiple"`.
+
+Select multiple dates
+ `} />
+
+### Multiple months
+
+Display multiple months side-by-side with the `displayMonthsCount` option. This is useful for date range selection where users need to see more context.
+
+Select date range
+ `} />
+
+### Date range
+
+Select a range of dates with `data-bs-selection-mode="multiple-ranged"`. Use `data-bs-selected-dates` to preselect a date range.
+
+Select date range
+ `} />
+
+### Multi-month date range
+
+For selecting date ranges that span multiple months, combine `data-bs-selection-mode="multiple-ranged"` with `data-bs-display-months-count="2"` to show two months side-by-side, making it easier for users to select across month boundaries.
+
+Select date range
+ `} />
+
+## Options
+
+### First day of week
+
+Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first-weekday`.
+
+Week starts on Sunday
+ `} />
+
+### Placement
+
+Control where the calendar appears relative to the input with `data-bs-placement`. Options are `left` (default), `center`, `right`, and `auto`.
+
+
+
+ Left aligned
+
+
+
+ Center aligned
+
+
+
+ Right aligned
+
+
+ `} />
+
+### Button trigger
+
+Use a button instead of an input for use cases like dashboard date filters. Add `data-bs-datepicker-display` to the text element to preserve icons when the date updates.
+
+
+
+ Select date
+`} />
+
+For date range selection (e.g., dashboard time filters), use `data-bs-selection-mode="multiple-ranged"`. The calendar will close after both start and end dates are selected.
+
+
+
+ Last 7 days
+ `} />
+
+You can also display the selected date in a separate element using the `displayElement` option via JavaScript:
+
+```js
+const datepicker = new bootstrap.Datepicker(buttonElement, {
+ selectionMode: 'multiple-ranged',
+ displayElement: '#date-display' // Selector or element
+})
+```
+
+### Inline mode
+
+Render the calendar inline (always visible, no popup) with `data-bs-inline="true"`. This is useful for embedding a calendar directly in the page.
+
+`} />
+
+Inline datepickers with date range selection:
+
+`} />
+
+Multiple months inline:
+
+`} />
+
+To bind to a form field, include a hidden input inside the container. The value will be updated with the selected date(s) in `YYYY-MM-DD` format:
+
+
+
+
+
+ Submit
+`} />
+
+### Custom date formatting
+
+Control how dates are displayed using the `dateFormat` option. Pass an [`Intl.DateTimeFormat` options object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options) or a custom function.
+
+```js
+// Using Intl.DateTimeFormat options
+const datepicker = new bootstrap.Datepicker(element, {
+ dateFormat: { month: 'short', day: 'numeric', year: 'numeric' }
+ // Output: "Dec 23, 2025 – Dec 28, 2025"
+})
+
+// Using a custom function
+const datepicker = new bootstrap.Datepicker(element, {
+ dateFormat: (date, locale) => {
+ return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
+ }
+ // Output: "Dec 23 – Dec 28"
+})
+```
+
+## Dark mode
+
+The datepicker automatically adapts to Bootstrap's color modes. When `data-bs-theme="dark"` is set on a parent element or the `` tag, the calendar popup inherits that theme.
+
+### Inherited from parent
+
+When a parent element has a theme, both the input and calendar popup inherit it:
+
+
+ Dark mode datepicker
+
+`} />
+
+### Datepicker-only theme
+
+Use `data-bs-datepicker-theme` to set the datepicker popup's theme independently of the input. This is useful when you want a light input with a dark datepicker, or vice versa:
+
+Light input, dark datepicker
+ `} />
+
+## Usage
+
+### Via data attributes
+
+Add `data-bs-toggle="datepicker"` to any input element to initialize it as a datepicker.
+
+```html
+
+```
+
+### Via JavaScript
+
+Initialize datepickers programmatically:
+
+```js
+const datepickerEl = document.getElementById('myDatepicker')
+const datepicker = new bootstrap.Datepicker(datepickerEl, {
+ selectionMode: 'single',
+ firstWeekday: 1
+})
+```
+
+### Options
+
+
+| Name | Type | Default | Description |
+| --- | --- | --- | --- |
+| `dateMin` | string, number, Date | `null` | Minimum selectable date. Format: `YYYY-MM-DD` |
+| `dateMax` | string, number, Date | `null` | Maximum selectable date. Format: `YYYY-MM-DD` |
+| `dateFormat` | object, function | `null` | Date formatting. Pass `Intl.DateTimeFormat` options or a `function(date, locale)`. |
+| `displayElement` | string, element, boolean | `null` | Element to show formatted date. For buttons, defaults to the button itself. Set to `false` to disable. |
+| `displayMonthsCount` | number | `1` | Number of months to display side-by-side in the calendar. |
+| `firstWeekday` | number | `1` | First day of week (0 = Sunday, 1 = Monday, etc.) |
+| `inline` | boolean | `false` | Render calendar inline (always visible, no popup). |
+| `locale` | string | `'default'` | Locale for date formatting (e.g., `'en-US'`, `'de-DE'`) |
+| `positionElement` | string, element | `null` | Element to position calendar relative to. Auto-detects `.form-adorn` wrapper if present. |
+| `selectedDates` | array | `[]` | Pre-selected dates in `YYYY-MM-DD` format |
+| `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` |
+| `placement` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` |
+| `datepickerTheme` | string | `null` | Force datepicker popup theme: `'light'`, `'dark'`, `'auto'`, or `null` to inherit from ancestor `[data-bs-theme]` |
+| `vcpOptions` | object | `{}` | Pass-through object for any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings) |
+
+
+### Advanced configuration
+
+For features not directly exposed by Bootstrap's options, use `vcpOptions` to pass any Vanilla Calendar Pro setting:
+
+```js
+const datepicker = new bootstrap.Datepicker(element, {
+ vcpOptions: {
+ disableDatesPast: true, // Disable past dates
+ disableWeekdays: [0, 6], // Disable weekends
+ disableDates: ['2025-12-25', '2025-12-26'], // Disable specific dates
+ selectedHolidays: ['2025-01-01'], // Highlight holidays
+ selectionTimeMode: 24 // Enable 24-hour time selection
+ }
+})
+```
+
+See the [Vanilla Calendar Pro documentation](https://vanilla-calendar.pro/docs/reference/settings) for all available options.
+
+### Methods
+
+
+| Method | Description |
+| --- | --- |
+| `show()` | Shows the datepicker calendar |
+| `hide()` | Hides the datepicker calendar |
+| `toggle()` | Toggles the datepicker visibility |
+| `getSelectedDates()` | Returns an array of selected dates in `YYYY-MM-DD` format |
+| `setSelectedDates(dates)` | Sets the selected dates. Expects an array of `YYYY-MM-DD` strings |
+| `dispose()` | Destroys the datepicker instance |
+| `getInstance(element)` | Static method to get the datepicker instance from a DOM element |
+| `getOrCreateInstance(element)` | Static method to get or create a datepicker instance |
+
+
+### Events
+
+
+| Event | Description |
+| --- | --- |
+| `show.bs.datepicker` | Fires immediately when the `show` method is called |
+| `shown.bs.datepicker` | Fires when the datepicker has been made visible |
+| `hide.bs.datepicker` | Fires immediately when the `hide` method is called |
+| `hidden.bs.datepicker` | Fires when the datepicker has been hidden |
+| `change.bs.datepicker` | Fires when a date is selected. Event includes `dates` (array) and `event` properties |
+
+
+```js
+const datepickerEl = document.getElementById('myDatepicker')
+datepickerEl.addEventListener('change.bs.datepicker', event => {
+ console.log('Selected dates:', event.dates)
+})
+```
diff --git a/site/src/content/docs/forms/form-adorn.mdx b/site/src/content/docs/forms/form-adorn.mdx
new file mode 100644
index 000000000000..cca9664e3b77
--- /dev/null
+++ b/site/src/content/docs/forms/form-adorn.mdx
@@ -0,0 +1,108 @@
+---
+title: Form adorn
+description: Decorate inputs with icons, text, and more using a custom wrapper that easily handles styling and positioning.
+toc: true
+---
+
+## How it works
+
+The `.form-adorn` wrapper replicates `.form-control` styling (border, background, focus states) while using flexbox to position adornments alongside a ghost input. The `.form-ghost` input inside has no visual styling—it's transparent and inherits from the wrapper.
+
+## Example
+
+Wrap an icon and a `.form-ghost` input inside `.form-adorn`. Place the adornment before the input in the DOM for start position (left in LTR).
+
+
+
+
+
+
+ `} />
+
+Use `.form-adorn-end` to position the adornment on the trailing side (keeps DOM order, uses CSS to flip visually):
+
+
+
+
+
+
+ `} />
+
+## With labels
+
+Add a label outside the `.form-adorn` wrapper for proper form semantics:
+
+
+ Search
+
+
+ `} />
+
+## Text adornments
+
+Use `.form-adorn-text` for currency symbols, units, domain suffixes, and other text-based adornments. Text adornments auto-size to their content.
+
+
+ $
+
+
+
+ USD
+
+
+
+ https://
+
+
+
+ @example.com
+
+
`} />
+
+## Sizing
+
+Use `.form-adorn-sm` or `.form-adorn-lg` on the wrapper to adjust sizing.
+
+
+
+
+
+
+
+
+ `} />
+
+## Ghost input
+
+The `.form-ghost` class strips all visual styling from an input, making it transparent. It's designed for use inside custom wrappers like `.form-adorn` that handle their own border, background, and focus states.
+
+`} />
+
+## CSS
+
+### Sass variables
+
+