},
{
"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": {
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'
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'
Button,
Carousel,
Collapse,
+ Datepicker,
Dialog,
Dropdown,
Offcanvas,
--- /dev/null
+/**
+ * --------------------------------------------------------------------------
+ * 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
--- /dev/null
+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 = '<input type="text" id="datepickerEl" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._calendar).not.toBeNull()
+ })
+
+ it('should detect input element type', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._isInput).toBeTrue()
+ })
+
+ it('should detect button element type', () => {
+ fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const datepicker = new Datepicker(buttonEl)
+
+ expect(datepicker._isInput).toBeFalse()
+ })
+
+ it('should use config from data attributes', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-first-weekday="0">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-first-weekday="0">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" disabled>'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker.getSelectedDates()).toEqual([])
+ })
+
+ it('should set selected dates', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-date-min="2025-01-01">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-date-max="2025-12-31">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.selectionMode).toEqual('multiple-ranged')
+ })
+
+ it('should respect placement option', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-placement="right">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.placement).toEqual('right')
+ })
+
+ it('should respect inline option', () => {
+ fixtureEl.innerHTML = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-display-months-count="2">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.displayMonthsCount).toEqual(2)
+ })
+
+ it('should respect locale option', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-locale="de-DE">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._config.locale).toEqual('de-DE')
+ })
+
+ it('should respect datepickerTheme option', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-datepicker-theme="dark">'
+
+ 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 = [
+ '<div data-bs-theme="dark">',
+ ' <input type="text" data-bs-toggle="datepicker">',
+ '</div>'
+ ].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 = [
+ '<div data-bs-theme="dark">',
+ ' <input type="text" data-bs-toggle="datepicker" data-bs-datepicker-theme="light">',
+ '</div>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = [
+ '<div data-bs-theme="dark">',
+ ' <input type="text" data-bs-toggle="datepicker">',
+ '</div>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = [
+ '<div class="form-adorn" id="adorn-wrapper">',
+ ' <input type="text" data-bs-toggle="datepicker">',
+ '</div>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ const inputEl = fixtureEl.querySelector('input')
+ const datepicker = new Datepicker(inputEl)
+
+ expect(datepicker._positionElement).toEqual(inputEl)
+ })
+
+ it('should respect explicit positionElement option', () => {
+ fixtureEl.innerHTML = [
+ '<div id="custom-position"></div>',
+ '<input type="text" data-bs-toggle="datepicker">'
+ ].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 = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
+
+ 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 = [
+ '<button type="button" data-bs-toggle="datepicker">',
+ ' <span>Icon</span>',
+ ' <span data-bs-datepicker-display>Select date</span>',
+ '</button>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ const divEl = fixtureEl.querySelector('div')
+ const datepicker = new Datepicker(divEl)
+
+ expect(datepicker._isInline).toBeTrue()
+ })
+
+ it('should find hidden input for value binding', () => {
+ fixtureEl.innerHTML = [
+ '<div data-bs-toggle="datepicker" data-bs-inline="true">',
+ ' <input type="hidden" name="selected_date">',
+ '</div>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker" data-bs-inline="true">'
+
+ 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 = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text">'
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ expect(Datepicker.getInstance(inputEl)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return datepicker instance', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" value="2025-01-15">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" value="not-a-date">'
+
+ const inputEl = fixtureEl.querySelector('input')
+
+ // Should not throw
+ expect(() => new Datepicker(inputEl)).not.toThrow()
+ })
+
+ it('should handle empty input value', () => {
+ fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" value="">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = [
+ '<button type="button" data-bs-toggle="datepicker">',
+ ' <span data-bs-datepicker-display>Select date</span>',
+ '</button>'
+ ].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 = [
+ '<div data-bs-toggle="datepicker" data-bs-inline="true">',
+ ' <input type="hidden" name="date">',
+ '</div>'
+ ].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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = '<input type="text" data-bs-toggle="datepicker">'
+
+ 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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
+
+ 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 = [
+ '<div id="position-target"></div>',
+ '<input type="text" data-bs-toggle="datepicker">'
+ ].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 = [
+ '<span id="display-target"></span>',
+ '<button type="button" data-bs-toggle="datepicker">Click</button>'
+ ].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 = [
+ '<span id="display-target"></span>',
+ '<button type="button" data-bs-toggle="datepicker">Click</button>'
+ ].join('')
+
+ const buttonEl = fixtureEl.querySelector('button')
+ const targetEl = fixtureEl.querySelector('#display-target')
+ const datepicker = new Datepicker(buttonEl, {
+ displayElement: targetEl
+ })
+
+ expect(datepicker._displayElement).toEqual(targetEl)
+ })
+ })
+})
--- /dev/null
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+ <title>Datepicker</title>
+ </head>
+ <body>
+ <div class="container py-5">
+ <h1>Datepicker <small class="text-body-secondary">Bootstrap Visual Test</small></h1>
+
+ <hr>
+
+ <h2>Basic Input Datepicker</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="basicDatepicker" class="form-label">Select a date</label>
+ <input type="text" class="form-control" id="basicDatepicker" data-bs-toggle="datepicker" placeholder="Click to select">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>With Min/Max Dates</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="minMaxDatepicker" class="form-label">Only dates in 2025</label>
+ <input type="text" class="form-control" id="minMaxDatepicker" data-bs-toggle="datepicker" data-bs-date-min="2025-01-01" data-bs-date-max="2025-12-31" placeholder="Select a date in 2025">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Multiple Selection</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="multipleDatepicker" class="form-label">Select multiple dates</label>
+ <input type="text" class="form-control" id="multipleDatepicker" data-bs-toggle="datepicker" data-bs-selection-mode="multiple" placeholder="Click to select multiple">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Range Selection</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="rangeDatepicker" class="form-label">Select a date range</label>
+ <input type="text" class="form-control" id="rangeDatepicker" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" placeholder="Select start and end">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Week Numbers</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="weekNumbersDatepicker" class="form-label">With week numbers</label>
+ <input type="text" class="form-control" id="weekNumbersDatepicker" data-bs-toggle="datepicker" data-bs-show-week-numbers="true" placeholder="Select a date">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Sunday First</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="sundayFirstDatepicker" class="form-label">Week starts on Sunday</label>
+ <input type="text" class="form-control" id="sundayFirstDatepicker" data-bs-toggle="datepicker" data-bs-first-weekday="0" placeholder="Select a date">
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Dark Mode</h2>
+ <div class="row mb-4" data-bs-theme="dark">
+ <div class="col-md-6">
+ <div class="p-4 bg-dark rounded">
+ <label for="darkDatepicker" class="form-label text-light">Dark mode datepicker</label>
+ <input type="text" class="form-control" id="darkDatepicker" data-bs-toggle="datepicker" placeholder="Select a date">
+ </div>
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>JavaScript Initialization</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="jsDatepicker" class="form-label">Initialized via JavaScript</label>
+ <input type="text" class="form-control" id="jsDatepicker" placeholder="Click to select">
+ <div class="mt-2">
+ <button type="button" class="btn btn-primary btn-sm" id="showBtn">Show</button>
+ <button type="button" class="btn btn-secondary btn-sm" id="hideBtn">Hide</button>
+ <button type="button" class="btn btn-info btn-sm" id="getDatesBtn">Get Dates</button>
+ </div>
+ <div id="selectedDatesOutput" class="mt-2 text-body-secondary"></div>
+ </div>
+ </div>
+
+ <hr>
+
+ <h2>Events Test</h2>
+ <div class="row mb-4">
+ <div class="col-md-6">
+ <label for="eventsDatepicker" class="form-label">Events datepicker</label>
+ <input type="text" class="form-control" id="eventsDatepicker" data-bs-toggle="datepicker" placeholder="Select a date">
+ <div id="eventsLog" class="mt-2 p-2 bg-light border rounded" style="min-height: 100px; font-family: monospace; font-size: 12px; white-space: pre-wrap;"></div>
+ </div>
+ </div>
+
+ </div>
+
+ <script src="../../../dist/js/bootstrap.bundle.js"></script>
+ <script>
+ /* global bootstrap: false */
+
+ // Initialize via JavaScript
+ const jsDatepickerEl = document.getElementById('jsDatepicker')
+ const jsDatepicker = new bootstrap.Datepicker(jsDatepickerEl, {
+ firstWeekday: 1,
+ selectedDates: ['2025-01-15']
+ })
+
+ document.getElementById('showBtn').addEventListener('click', () => {
+ jsDatepicker.show()
+ })
+
+ document.getElementById('hideBtn').addEventListener('click', () => {
+ jsDatepicker.hide()
+ })
+
+ document.getElementById('getDatesBtn').addEventListener('click', () => {
+ const dates = jsDatepicker.getSelectedDates()
+ document.getElementById('selectedDatesOutput').textContent = `Selected: ${dates.length ? dates.join(', ') : 'None'}`
+ })
+
+ // Events test
+ const eventsDatepickerEl = document.getElementById('eventsDatepicker')
+ const eventsLog = document.getElementById('eventsLog')
+
+ function logEvent(eventName, detail) {
+ const timestamp = new Date().toLocaleTimeString()
+ const detailStr = detail ? ` - ${JSON.stringify(detail)}` : ''
+ eventsLog.textContent += `[${timestamp}] ${eventName}${detailStr}\n`
+ eventsLog.scrollTop = eventsLog.scrollHeight
+ }
+
+ eventsDatepickerEl.addEventListener('show.bs.datepicker', () => {
+ logEvent('show.bs.datepicker')
+ })
+
+ eventsDatepickerEl.addEventListener('shown.bs.datepicker', () => {
+ logEvent('shown.bs.datepicker')
+ })
+
+ eventsDatepickerEl.addEventListener('hide.bs.datepicker', () => {
+ logEvent('hide.bs.datepicker')
+ })
+
+ eventsDatepickerEl.addEventListener('hidden.bs.datepicker', () => {
+ logEvent('hidden.bs.datepicker')
+ })
+
+ eventsDatepickerEl.addEventListener('change.bs.datepicker', event => {
+ logEvent('change.bs.datepicker', { dates: event.dates })
+ })
+ </script>
+ </body>
+</html>
],
"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.6",
"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",
"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"
}
}
--- /dev/null
+// 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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%236b7280' d='M12 16c-.3 0-.5-.1-.7-.3l-6-6c-.4-.4-.4-1 0-1.4s1-.4 1.4 0l5.3 5.3 5.3-5.3c.4-.4 1-.4 1.4 0s.4 1 0 1.4l-6 6c-.2.2-.4.3-.7.3'/></svg>");
+ 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;
+ }
+}
@forward "breadcrumb";
@forward "card";
@forward "carousel";
+@forward "datepicker";
@forward "dialog";
@forward "dropdown";
@forward "list-group";
--- /dev/null
+@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};
+ }
+}
&.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;
+ }
+ }
}
@forward "input-group";
@forward "strength";
@forward "otp-input";
+@forward "form-adorn";
@forward "validation";
- title: Floating labels
- title: OTP input
- title: Password strength
+ - title: Form adorn
- title: Layout
- title: Validation
- title: Carousel
- title: Close button
- title: Collapse
+ - title: Datepicker
- title: Dialog
- title: Dropdown
- title: List group
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"
></path>
</symbol>
+ <symbol id="calendar-week" viewBox="0 0 16 16">
+ <path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5z"/>
+ <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
+ </symbol>
<symbol id="check2" viewBox="0 0 16 16">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"
d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"
></path>
</symbol>
+ <symbol id="envelope" viewBox="0 0 16 16">
+ <path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1zm13 2.383-4.708 2.825L15 11.105zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741M1 11.105l4.708-2.897L1 5.383z"/>
+ </symbol>
<symbol id="file-earmark-richtext" viewBox="0 0 16 16">
<path
d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"
></path>
</symbol>
+ <symbol id="search" viewBox="0 0 16 16">
+ <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
+ </symbol>
<symbol id="sun-fill" viewBox="0 0 16 16">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"
--- /dev/null
+---
+title: Datepicker
+description: A flexible date picker component powered by Vanilla Calendar Pro, with Bootstrap styling and data attribute support.
+toc: true
+---
+
+## Overview
+
+The Bootstrap Datepicker is a wrapper around [Vanilla Calendar Pro](https://vanilla-calendar.pro/) that provides a consistent, accessible date selection experience. It supports light/dark themes, input binding, and flexible configuration via data attributes or JavaScript.
+
+<Example code={`<label for="datepicker1" class="form-label">Datepicker</label>
+ <input type="text" class="form-control w-12" data-bs-toggle="datepicker" placeholder="Choose date…">`} />
+
+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 `<input>` 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.
+
+<Example code={`<label for="datepickerIconStart" class="form-label">Select date</label>
+ <div class="form-adorn w-12">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#calendar-week" /></svg>
+ </div>
+ <input type="text" class="form-ghost" id="datepickerIconStart" data-bs-toggle="datepicker" placeholder="Choose date…">
+ </div>`} />
+
+### Min & Max dates
+
+Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`.
+
+<Example code={`<label for="datepicker2" class="form-label">Event date (2025 only)</label>
+ <input type="text" class="form-control w-12" id="datepicker2" data-bs-toggle="datepicker" data-bs-date-min="2025-01-01" data-bs-date-max="2025-12-31" placeholder="Select a date in 2025">`} />
+
+### Multiple dates
+
+Enable multiple date selection with `data-bs-selection-mode="multiple"`.
+
+<Example code={`<label for="datepicker3" class="form-label">Select multiple dates</label>
+ <input type="text" class="form-control" id="datepicker3" data-bs-toggle="datepicker" data-bs-selection-mode="multiple" placeholder="Select date range…">`} />
+
+### 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.
+
+<Example code={`<label for="datepickerMultiMonth" class="form-label">Select date range</label>
+ <input type="text" class="form-control" id="datepickerMultiMonth" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-display-months-count="2" placeholder="Select start and end dates">`} />
+
+### Date range
+
+Select a range of dates with `data-bs-selection-mode="multiple-ranged"`. Use `data-bs-selected-dates` to preselect a date range.
+
+<Example code={`<label for="datepicker4" class="form-label">Select date range</label>
+ <input type="text" class="form-control" id="datepicker4" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-selected-dates='["2025-06-10", "2025-06-18"]' placeholder="Select start and end dates…">`} />
+
+### 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.
+
+<Example code={`<label for="datepickerRangeTwoMonths" class="form-label">Select date range</label>
+ <input type="text" class="form-control" id="datepickerRangeTwoMonths" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-display-months-count="2" data-bs-selected-dates='["2025-06-25", "2025-07-08"]' placeholder="Select start and end dates…">`} />
+
+## Options
+
+### First day of week
+
+Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first-weekday`.
+
+<Example code={`<label for="datepicker6" class="form-label">Week starts on Sunday</label>
+ <input type="text" class="form-control w-12" id="datepicker6" data-bs-toggle="datepicker" data-bs-first-weekday="0" placeholder="Select a date">`} />
+
+### Placement
+
+Control where the calendar appears relative to the input with `data-bs-placement`. Options are `left` (default), `center`, `right`, and `auto`.
+
+<Example code={`<div class="d-flex gap-3">
+ <div>
+ <label for="datepickerLeft" class="form-label">Left aligned</label>
+ <input type="text" class="form-control" id="datepickerLeft" data-bs-toggle="datepicker" data-bs-placement="left" placeholder="Left">
+ </div>
+ <div>
+ <label for="datepickerCenter" class="form-label">Center aligned</label>
+ <input type="text" class="form-control" id="datepickerCenter" data-bs-toggle="datepicker" data-bs-placement="center" placeholder="Center">
+ </div>
+ <div>
+ <label for="datepickerRight" class="form-label">Right aligned</label>
+ <input type="text" class="form-control" id="datepickerRight" data-bs-toggle="datepicker" data-bs-placement="right" placeholder="Right">
+ </div>
+ </div>`} />
+
+### 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.
+
+<Example code={`<button type="button" class="btn btn-outline-secondary" data-bs-toggle="datepicker">
+ <svg class="bi" width="16" height="16"><use href="#calendar-week" /></svg>
+ <span data-bs-datepicker-display>Select date</span>
+</button>`} />
+
+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.
+
+<Example code={`<button type="button" class="btn btn-outline-secondary" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">
+ <svg class="bi" width="16" height="16"><use href="#calendar-week" /></svg>
+ <span data-bs-datepicker-display>Last 7 days</span>
+ </button>`} />
+
+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.
+
+<Example code={`<div data-bs-toggle="datepicker" data-bs-inline="true"></div>`} />
+
+Inline datepickers with date range selection:
+
+<Example code={`<div data-bs-toggle="datepicker" data-bs-inline="true" data-bs-selection-mode="multiple-ranged"></div>`} />
+
+Multiple months inline:
+
+<Example code={`<div data-bs-toggle="datepicker" data-bs-inline="true" data-bs-display-months-count="2"></div>`} />
+
+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:
+
+<Example code={`<form>
+ <div data-bs-toggle="datepicker" data-bs-inline="true">
+ <input type="hidden" name="selected_date">
+ </div>
+ <button type="submit" class="btn btn-primary mt-3">Submit</button>
+</form>`} />
+
+### 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 `<html>` 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:
+
+<Example code={`<div data-bs-theme="dark" class="p-3 bg-body fg-body rounded">
+ <label for="datepickerDark" class="form-label">Dark mode datepicker</label>
+ <input type="text" class="form-control" id="datepickerDark" data-bs-toggle="datepicker" placeholder="Select a date">
+</div>`} />
+
+### 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:
+
+<Example code={`<label for="datepickerTheme" class="form-label">Light input, dark datepicker</label>
+<input type="text" class="form-control w-12" id="datepickerTheme" data-bs-toggle="datepicker" data-bs-datepicker-theme="dark" placeholder="Select a date">`} />
+
+## Usage
+
+### Via data attributes
+
+Add `data-bs-toggle="datepicker"` to any input element to initialize it as a datepicker.
+
+```html
+<input type="text" class="form-control" data-bs-toggle="datepicker">
+```
+
+### Via JavaScript
+
+Initialize datepickers programmatically:
+
+```js
+const datepickerEl = document.getElementById('myDatepicker')
+const datepicker = new bootstrap.Datepicker(datepickerEl, {
+ selectionMode: 'single',
+ firstWeekday: 1
+})
+```
+
+### Options
+
+<BsTable>
+| 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) |
+</BsTable>
+
+### 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
+
+<BsTable>
+| 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 |
+</BsTable>
+
+### Events
+
+<BsTable>
+| 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 |
+</BsTable>
+
+```js
+const datepickerEl = document.getElementById('myDatepicker')
+datepickerEl.addEventListener('change.bs.datepicker', event => {
+ console.log('Selected dates:', event.dates)
+})
+```
--- /dev/null
+---
+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).
+
+<Example code={`<div class="form-adorn">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#search" /></svg>
+ </div>
+ <input type="text" class="form-ghost" placeholder="Search...">
+ </div>`} />
+
+Use `.form-adorn-end` to position the adornment on the trailing side (keeps DOM order, uses CSS to flip visually):
+
+<Example code={`<div class="form-adorn form-adorn-end">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#envelope" /></svg>
+ </div>
+ <input type="email" class="form-ghost" placeholder="you@example.com">
+ </div>`} />
+
+## With labels
+
+Add a label outside the `.form-adorn` wrapper for proper form semantics:
+
+<Example class="vstack gap-3" code={`<div>
+ <label for="searchInput" class="form-label">Search</label>
+ <div class="form-adorn">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#search" /></svg>
+ </div>
+ <input type="text" class="form-ghost" id="searchInput" placeholder="Search...">
+ </div>
+ </div>
+ <div>
+ <label for="emailInput" class="form-label">Email address</label>
+ <div class="form-adorn form-adorn-end">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#envelope" /></svg>
+ </div>
+ <input type="email" class="form-ghost" id="emailInput" placeholder="you@example.com">
+ </div>
+ </div>`} />
+
+## Text adornments
+
+Use `.form-adorn-text` for currency symbols, units, domain suffixes, and other text-based adornments. Text adornments auto-size to their content.
+
+<Example class="vstack gap-3" code={`<div class="form-adorn">
+ <span class="form-adorn-text">$</span>
+ <input type="text" class="form-ghost" placeholder="0.00">
+ </div>
+ <div class="form-adorn form-adorn-end">
+ <span class="form-adorn-text">USD</span>
+ <input type="text" class="form-ghost" placeholder="Amount">
+ </div>
+ <div class="form-adorn">
+ <span class="form-adorn-text">https://</span>
+ <input type="text" class="form-ghost" placeholder="example.com">
+ </div>
+ <div class="form-adorn form-adorn-end">
+ <span class="form-adorn-text">@example.com</span>
+ <input type="text" class="form-ghost" placeholder="username">
+ </div>`} />
+
+## Sizing
+
+Use `.form-adorn-sm` or `.form-adorn-lg` on the wrapper to adjust sizing.
+
+<Example class="vstack gap-3" code={`<div class="form-adorn form-adorn-sm">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#search" /></svg>
+ </div>
+ <input type="text" class="form-ghost" placeholder="Small input">
+ </div>
+ <div class="form-adorn">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#search" /></svg>
+ </div>
+ <input type="text" class="form-ghost" placeholder="Default input">
+ </div>
+ <div class="form-adorn form-adorn-lg">
+ <div class="form-adorn-icon">
+ <svg class="bi" width="16" height="16"><use href="#search" /></svg>
+ </div>
+ <input type="text" class="form-ghost" placeholder="Large input">
+ </div>`} />
+
+## 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.
+
+<Example code={`<input type="text" class="form-ghost" placeholder="Ghost input (no styling)">`} />
+
+## CSS
+
+### Sass variables
+
+<ScssDocs name="form-adorn-variables" file="scss/forms/_form-adorn.scss" />