]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
New Datepicker plugin via Vanilla Calendar Pro, new `.form-adorn` component (#41965)
authorMark Otto <markd.otto@gmail.com>
Tue, 6 Jan 2026 06:43:56 +0000 (22:43 -0800)
committerMark Otto <markdotto@gmail.com>
Fri, 9 Jan 2026 04:14:09 +0000 (20:14 -0800)
* First pass at datepicker via Vanilla Calendar Pro

* fixes

* optimize

* Docs updates, add advanced config

* rename attr

* edits

* Update datepicker docs, improve color modes, add tests

* New .form-adorn component for overlaying icons and text with inputs

* temp

* bump limits

* cleanup and simpler selectors

* few more tweaks

* Remove comment

* Fix multi-month, reorg some docs content, fix selections

17 files changed:
.bundlewatch.config.json
js/index.esm.js
js/index.umd.js
js/src/datepicker.js [new file with mode: 0644]
js/tests/unit/datepicker.spec.js [new file with mode: 0644]
js/tests/visual/datepicker.html [new file with mode: 0644]
package-lock.json
package.json
scss/_datepicker.scss [new file with mode: 0644]
scss/bootstrap.scss
scss/forms/_form-adorn.scss [new file with mode: 0644]
scss/forms/_form-control.scss
scss/forms/index.scss
site/data/sidebar.yml
site/src/components/icons/Symbols.astro
site/src/content/docs/components/datepicker.mdx [new file with mode: 0644]
site/src/content/docs/forms/form-adorn.mdx [new file with mode: 0644]

index 236c6ffa1df026a9f8e19ffb915c63331a51a525..a535daf92269a37187f075ad37dd8e5f9e18f83c 100644 (file)
     },
     {
       "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": {
index a4c7cdf3866c9d91adcd1932c3a4fbd94aeb51d8..01d298e05fce7d851e5a0fc0438daaea0c05a34c 100644 (file)
@@ -9,6 +9,7 @@ export { default as Alert } from './src/alert.js'
 export { default as Button } from './src/button.js'
 export { default as Carousel } from './src/carousel.js'
 export { default as Collapse } from './src/collapse.js'
+export { default as Datepicker } from './src/datepicker.js'
 export { default as Dialog } from './src/dialog.js'
 export { default as Dropdown } from './src/dropdown.js'
 export { default as Offcanvas } from './src/offcanvas.js'
index 4da18556e30e52bc323b91aa6ea4b2ecfd286399..73f12b424edd2c2429f3279bc3277b7e6b1cda76 100644 (file)
@@ -9,6 +9,7 @@ import Alert from './src/alert.js'
 import Button from './src/button.js'
 import Carousel from './src/carousel.js'
 import Collapse from './src/collapse.js'
+import Datepicker from './src/datepicker.js'
 import Dialog from './src/dialog.js'
 import Dropdown from './src/dropdown.js'
 import Offcanvas from './src/offcanvas.js'
@@ -26,6 +27,7 @@ export default {
   Button,
   Carousel,
   Collapse,
+  Datepicker,
   Dialog,
   Dropdown,
   Offcanvas,
diff --git a/js/src/datepicker.js b/js/src/datepicker.js
new file mode 100644 (file)
index 0000000..03821e9
--- /dev/null
@@ -0,0 +1,451 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap datepicker.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { Calendar } from 'vanilla-calendar-pro'
+import BaseComponent from './base-component.js'
+import EventHandler from './dom/event-handler.js'
+import { isDisabled } from './util/index.js'
+
+/**
+ * Constants
+ */
+
+const NAME = 'datepicker'
+const DATA_KEY = 'bs.datepicker'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_CHANGE = `change${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}`
+
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"]'
+
+const HIDE_DELAY = 100 // ms delay before hiding after selection
+
+const Default = {
+  datepickerTheme: null, // 'light', 'dark', 'auto' - explicit theme for datepicker popover only
+  dateMin: null,
+  dateMax: null,
+  dateFormat: null, // Intl.DateTimeFormat options, or function(date, locale) => string
+  displayElement: null, // Element to show formatted date (defaults to element for buttons)
+  displayMonthsCount: 1, // Number of months to display side-by-side
+  firstWeekday: 1, // Monday
+  inline: false, // Render calendar inline (no popup)
+  locale: 'default',
+  positionElement: null, // Element to position calendar relative to (defaults to input)
+  selectedDates: [],
+  selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged'
+  placement: 'left', // 'left', 'center', 'right', 'auto'
+  vcpOptions: {} // Pass-through for any VCP option
+}
+
+const DefaultType = {
+  datepickerTheme: '(null|string)',
+  dateMin: '(null|string|number|object)',
+  dateMax: '(null|string|number|object)',
+  dateFormat: '(null|object|function)',
+  displayElement: '(null|string|element|boolean)',
+  displayMonthsCount: 'number',
+  firstWeekday: 'number',
+  inline: 'boolean',
+  locale: 'string',
+  positionElement: '(null|string|element)',
+  selectedDates: 'array',
+  selectionMode: 'string',
+  placement: 'string',
+  vcpOptions: 'object'
+}
+
+/**
+ * Class definition
+ */
+
+class Datepicker extends BaseComponent {
+  constructor(element, config) {
+    super(element, config)
+
+    this._calendar = null
+    this._isShown = false
+
+    this._initCalendar()
+  }
+
+  // Getters
+  static get Default() {
+    return Default
+  }
+
+  static get DefaultType() {
+    return DefaultType
+  }
+
+  static get NAME() {
+    return NAME
+  }
+
+  // Public
+  toggle() {
+    if (this._config.inline) {
+      return // Inline calendars are always visible
+    }
+
+    return this._isShown ? this.hide() : this.show()
+  }
+
+  show() {
+    if (this._config.inline) {
+      return // Inline calendars are always visible
+    }
+
+    if (!this._calendar || isDisabled(this._element) || this._isShown) {
+      return
+    }
+
+    const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
+    if (showEvent.defaultPrevented) {
+      return
+    }
+
+    this._calendar.show()
+    this._isShown = true
+
+    EventHandler.trigger(this._element, EVENT_SHOWN)
+  }
+
+  hide() {
+    if (this._config.inline) {
+      return // Inline calendars are always visible
+    }
+
+    if (!this._calendar || !this._isShown) {
+      return
+    }
+
+    const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+    if (hideEvent.defaultPrevented) {
+      return
+    }
+
+    this._calendar.hide()
+    this._isShown = false
+
+    EventHandler.trigger(this._element, EVENT_HIDDEN)
+  }
+
+  dispose() {
+    if (this._themeObserver) {
+      this._themeObserver.disconnect()
+      this._themeObserver = null
+    }
+
+    if (this._calendar) {
+      this._calendar.destroy()
+    }
+
+    this._calendar = null
+    super.dispose()
+  }
+
+  getSelectedDates() {
+    const dates = this._calendar?.context?.selectedDates
+    return dates ? [...dates] : []
+  }
+
+  setSelectedDates(dates) {
+    if (this._calendar) {
+      this._calendar.set({ selectedDates: dates })
+    }
+  }
+
+  // Private
+  _initCalendar() {
+    this._isInput = this._element.tagName === 'INPUT'
+    this._isInline = this._config.inline
+
+    // For inline mode, look for a hidden input child to bind to
+    if (this._isInline && !this._isInput) {
+      this._boundInput = this._element.querySelector('input[type="hidden"], input[name]')
+    }
+
+    this._positionElement = this._resolvePositionElement()
+    this._displayElement = this._resolveDisplayElement()
+
+    const calendarOptions = this._buildCalendarOptions()
+
+    // Create calendar on the position element (for correct popup positioning)
+    // but value updates still go to this._element (the input)
+    this._calendar = new Calendar(this._positionElement, calendarOptions)
+    this._calendar.init()
+
+    // Watch for theme changes on ancestor elements (for live theme switching)
+    this._setupThemeObserver()
+
+    // Set initial value if input has a value
+    if (this._isInput && this._element.value) {
+      this._parseInputValue()
+    }
+  }
+
+  _resolvePositionElement() {
+    let { positionElement } = this._config
+
+    if (typeof positionElement === 'string') {
+      positionElement = document.querySelector(positionElement)
+    }
+
+    // Use input's parent if in form-adorn
+    if (!positionElement && this._isInput && !this._isInline) {
+      const parent = this._element.closest('.form-adorn')
+      if (parent) {
+        positionElement = parent
+      }
+    }
+
+    return positionElement || this._element
+  }
+
+  _resolveDisplayElement() {
+    const { displayElement } = this._config
+
+    if (typeof displayElement === 'string') {
+      return document.querySelector(displayElement)
+    }
+
+    // For buttons/non-inputs (not inline), look for a [data-bs-datepicker-display] child
+    if (displayElement === true || (displayElement === null && !this._isInput && !this._isInline)) {
+      const displayChild = this._element.querySelector('[data-bs-datepicker-display]')
+      return displayChild || this._element
+    }
+
+    return displayElement
+  }
+
+  _getThemeAncestor() {
+    return this._element.closest('[data-bs-theme]')
+  }
+
+  _getEffectiveTheme() {
+    // Priority: explicit datepickerTheme config > inherited from ancestor > none
+    const { datepickerTheme } = this._config
+    if (datepickerTheme) {
+      return datepickerTheme
+    }
+
+    const ancestor = this._getThemeAncestor()
+    return ancestor?.getAttribute('data-bs-theme') || null
+  }
+
+  _syncThemeAttribute(element) {
+    if (!element) {
+      return
+    }
+
+    const theme = this._getEffectiveTheme()
+
+    if (theme) {
+      // Copy theme to popover (needed because VCP appends to body, breaking CSS inheritance)
+      element.setAttribute('data-bs-theme', theme)
+    } else {
+      // No theme - remove attribute to allow natural inheritance
+      element.removeAttribute('data-bs-theme')
+    }
+  }
+
+  _setupThemeObserver() {
+    // Watch for theme changes on ancestor elements
+    const ancestor = this._getThemeAncestor()
+    if (!ancestor || this._config.datepickerTheme) {
+      // No ancestor to watch, or explicit datepickerTheme overrides
+      return
+    }
+
+    this._themeObserver = new MutationObserver(() => {
+      this._syncThemeAttribute(this._calendar?.context?.mainElement)
+    })
+
+    this._themeObserver.observe(ancestor, {
+      attributes: true,
+      attributeFilter: ['data-bs-theme']
+    })
+  }
+
+  _buildCalendarOptions() {
+    // Get theme for VCP - use 'system' for auto-detection if no explicit theme
+    const theme = this._getEffectiveTheme()
+    // VCP uses 'system' for auto, Bootstrap uses 'auto'
+    const vcpTheme = !theme || theme === 'auto' ? 'system' : theme
+
+    const calendarOptions = {
+      ...this._config.vcpOptions,
+      inputMode: !this._isInline,
+      positionToInput: this._config.placement,
+      firstWeekday: this._config.firstWeekday,
+      locale: this._config.locale,
+      selectionDatesMode: this._config.selectionMode,
+      selectedDates: this._config.selectedDates,
+      displayMonthsCount: this._config.displayMonthsCount,
+      type: this._config.displayMonthsCount > 1 ? 'multiple' : 'default',
+      selectedTheme: vcpTheme,
+      themeAttrDetect: '[data-bs-theme]',
+      onClickDate: (self, event) => this._handleDateClick(self, event),
+      onInit: self => {
+        this._syncThemeAttribute(self.context.mainElement)
+      },
+      onShow: () => {
+        this._isShown = true
+        this._syncThemeAttribute(this._calendar.context.mainElement)
+      },
+      onHide: () => {
+        this._isShown = false
+      }
+    }
+
+    if (this._config.dateMin) {
+      calendarOptions.dateMin = this._config.dateMin
+    }
+
+    if (this._config.dateMax) {
+      calendarOptions.dateMax = this._config.dateMax
+    }
+
+    return calendarOptions
+  }
+
+  _handleDateClick(self, event) {
+    const selectedDates = [...self.context.selectedDates]
+
+    if (selectedDates.length > 0) {
+      const formattedDate = this._formatDateForInput(selectedDates)
+
+      if (this._isInput) {
+        this._element.value = formattedDate
+      }
+
+      if (this._boundInput) {
+        this._boundInput.value = selectedDates.join(',')
+      }
+
+      if (this._displayElement) {
+        this._displayElement.textContent = formattedDate
+      }
+    }
+
+    EventHandler.trigger(this._element, EVENT_CHANGE, {
+      dates: selectedDates,
+      event
+    })
+
+    this._maybeHideAfterSelection(selectedDates)
+  }
+
+  _maybeHideAfterSelection(selectedDates) {
+    if (this._isInline) {
+      return
+    }
+
+    const shouldHide =
+      (this._config.selectionMode === 'single' && selectedDates.length > 0) ||
+      (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2)
+
+    if (shouldHide) {
+      setTimeout(() => this.hide(), HIDE_DELAY)
+    }
+  }
+
+  _parseDate(dateStr) {
+    const [year, month, day] = dateStr.split('-')
+    return new Date(year, month - 1, day)
+  }
+
+  _formatDate(dateStr) {
+    const date = this._parseDate(dateStr)
+    const locale = this._config.locale === 'default' ? undefined : this._config.locale
+    const { dateFormat } = this._config
+
+    // Custom function formatter
+    if (typeof dateFormat === 'function') {
+      return dateFormat(date, locale)
+    }
+
+    // Intl.DateTimeFormat options object
+    if (dateFormat && typeof dateFormat === 'object') {
+      return new Intl.DateTimeFormat(locale, dateFormat).format(date)
+    }
+
+    // Default: locale-aware formatting
+    return date.toLocaleDateString(locale)
+  }
+
+  _formatDateForInput(dates) {
+    if (dates.length === 0) {
+      return ''
+    }
+
+    if (dates.length === 1) {
+      return this._formatDate(dates[0])
+    }
+
+    // For date ranges, use en-dash; for multiple dates, use comma
+    const separator = this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', '
+    return dates.map(d => this._formatDate(d)).join(separator)
+  }
+
+  _parseInputValue() {
+    // Try to parse the input value as a date
+    const value = this._element.value.trim()
+    if (!value) {
+      return
+    }
+
+    const date = new Date(value)
+    if (!Number.isNaN(date.getTime())) {
+      const year = date.getFullYear()
+      const month = String(date.getMonth() + 1).padStart(2, '0')
+      const day = String(date.getDate()).padStart(2, '0')
+      const formatted = `${year}-${month}-${day}`
+      this._calendar.set({ selectedDates: [formatted] })
+    }
+  }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+  // Only handle if not an input (inputs use focus)
+  // Skip inline datepickers (they're always visible)
+  if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true') {
+    return
+  }
+
+  event.preventDefault()
+  Datepicker.getOrCreateInstance(this).toggle()
+})
+
+EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () {
+  // Handle focus for input elements
+  if (this.tagName !== 'INPUT') {
+    return
+  }
+
+  Datepicker.getOrCreateInstance(this).show()
+})
+
+// Auto-initialize inline datepickers on DOMContentLoaded
+EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
+  for (const element of document.querySelectorAll(`${SELECTOR_DATA_TOGGLE}[data-bs-inline="true"]`)) {
+    Datepicker.getOrCreateInstance(element)
+  }
+})
+
+export default Datepicker
diff --git a/js/tests/unit/datepicker.spec.js b/js/tests/unit/datepicker.spec.js
new file mode 100644 (file)
index 0000000..ad2710a
--- /dev/null
@@ -0,0 +1,1205 @@
+import EventHandler from '../../src/dom/event-handler.js'
+import Datepicker from '../../src/datepicker.js'
+import {
+  clearFixture, createEvent, getFixture
+} from '../helpers/fixture.js'
+
+describe('Datepicker', () => {
+  let fixtureEl
+
+  beforeAll(() => {
+    fixtureEl = getFixture()
+  })
+
+  afterEach(() => {
+    clearFixture()
+
+    // Clean up any VCP calendar elements that may have been created
+    for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) {
+      calendarEl.remove()
+    }
+  })
+
+  describe('VERSION', () => {
+    it('should return plugin version', () => {
+      expect(Datepicker.VERSION).toEqual(jasmine.any(String))
+    })
+  })
+
+  describe('Default', () => {
+    it('should return plugin default config', () => {
+      expect(Datepicker.Default).toEqual(jasmine.any(Object))
+    })
+
+    it('should have expected default values', () => {
+      expect(Datepicker.Default.dateMin).toBeNull()
+      expect(Datepicker.Default.dateMax).toBeNull()
+      expect(Datepicker.Default.selectionMode).toEqual('single')
+      expect(Datepicker.Default.firstWeekday).toEqual(1)
+      expect(Datepicker.Default.locale).toEqual('default')
+      expect(Datepicker.Default.placement).toEqual('left')
+      expect(Datepicker.Default.inline).toBeFalse()
+      expect(Datepicker.Default.displayMonthsCount).toEqual(1)
+    })
+  })
+
+  describe('DefaultType', () => {
+    it('should return plugin default type config', () => {
+      expect(Datepicker.DefaultType).toEqual(jasmine.any(Object))
+    })
+  })
+
+  describe('DATA_KEY', () => {
+    it('should return plugin data key', () => {
+      expect(Datepicker.DATA_KEY).toEqual('bs.datepicker')
+    })
+  })
+
+  describe('NAME', () => {
+    it('should return plugin name', () => {
+      expect(Datepicker.NAME).toEqual('datepicker')
+    })
+  })
+
+  describe('constructor', () => {
+    it('should take care of element either passed as a CSS selector or DOM element', () => {
+      fixtureEl.innerHTML = '<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)
+    })
+  })
+})
diff --git a/js/tests/visual/datepicker.html b/js/tests/visual/datepicker.html
new file mode 100644 (file)
index 0000000..70db69c
--- /dev/null
@@ -0,0 +1,170 @@
+<!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>
index f2f49625e536749d7e81971110dda0e1154c7647..50be6e6090146256a65c885e9ba56a62544fd512 100644 (file)
@@ -19,7 +19,8 @@
       ],
       "license": "MIT",
       "dependencies": {
-        "postcss-prefix-custom-properties": "^0.1.0"
+        "postcss-prefix-custom-properties": "^0.1.0",
+        "vanilla-calendar-pro": "^3.0.5"
       },
       "devDependencies": {
         "@astrojs/check": "^0.9.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",
index 7417a776bd84a0857985a27f53ae7a4e82e93303..5d595676ee602a26591848e6d57593804c2f2ea8 100644 (file)
     "volar-service-emmet": "0.0.63"
   },
   "dependencies": {
-    "postcss-prefix-custom-properties": "^0.1.0"
+    "postcss-prefix-custom-properties": "^0.1.0",
+    "vanilla-calendar-pro": "^3.0.5"
   }
 }
diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss
new file mode 100644 (file)
index 0000000..218c08a
--- /dev/null
@@ -0,0 +1,426 @@
+// stylelint-disable selector-max-attribute, property-disallowed-list, selector-no-qualifying-type -- VCP uses extensive data attributes and requires direct border-radius properties for range selection
+
+@use "config" as *;
+@use "colors" as *;
+@use "variables" as *;
+@use "mixins/border-radius" as *;
+@use "mixins/focus-ring" as *;
+
+// scss-docs-start datepicker-variables
+$datepicker-padding:                1rem !default;
+$datepicker-bg:                     var(--bg-body) !default;
+$datepicker-color:                  var(--fg-body) !default;
+$datepicker-border-color:           var(--border-color-translucent) !default;
+$datepicker-border-width:           var(--border-width) !default;
+$datepicker-border-radius:          var(--border-radius-lg) !default;
+$datepicker-box-shadow:             var(--box-shadow) !default;
+$datepicker-font-size:              var(--font-size-sm) !default;
+$datepicker-min-width:              280px !default;
+
+$datepicker-header-font-weight:     600 !default;
+$datepicker-weekday-color:          var(--fg-3) !default;
+$datepicker-day-hover-bg:           var(--bg-1) !default;
+$datepicker-day-selected-bg:        var(--primary-bg) !default;
+$datepicker-day-selected-color:     var(--primary-contrast) !default;
+$datepicker-day-today-bg:           var(--bg-2) !default;
+$datepicker-day-today-color:        var(--fg-1) !default;
+$datepicker-day-disabled-color:     var(--fg-4) !default;
+// scss-docs-end datepicker-variables
+
+@layer components {
+  [data-vc="calendar"] {
+    // scss-docs-start datepicker-css-vars
+    --datepicker-padding: #{$datepicker-padding};
+    --datepicker-bg: #{$datepicker-bg};
+    --datepicker-color: #{$datepicker-color};
+    --datepicker-border-color: #{$datepicker-border-color};
+    --datepicker-border-width: #{$datepicker-border-width};
+    --datepicker-border-radius: #{$datepicker-border-radius};
+    --datepicker-box-shadow: #{$datepicker-box-shadow};
+    --datepicker-font-size: #{$datepicker-font-size};
+    --datepicker-min-width: #{$datepicker-min-width};
+    --datepicker-zindex: #{$zindex-dropdown};
+
+    --datepicker-header-font-weight: #{$datepicker-header-font-weight};
+    --datepicker-weekday-color: #{$datepicker-weekday-color};
+    --datepicker-day-hover-bg: #{$datepicker-day-hover-bg};
+    --datepicker-day-selected-bg: #{$datepicker-day-selected-bg};
+    --datepicker-day-selected-color: #{$datepicker-day-selected-color};
+    --datepicker-day-today-bg: #{$datepicker-day-today-bg};
+    --datepicker-day-today-color: #{$datepicker-day-today-color};
+    --datepicker-day-disabled-color: #{$datepicker-day-disabled-color};
+    // scss-docs-end datepicker-css-vars
+
+    position: absolute;
+    z-index: var(--datepicker-zindex);
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    min-width: var(--datepicker-min-width);
+    padding: var(--datepicker-padding);
+    font-family: var(--font-sans-serif);
+    font-size: var(--datepicker-font-size);
+    color: var(--datepicker-color);
+    color-scheme: light dark;
+    background-color: var(--datepicker-bg);
+    border: var(--datepicker-border-width) solid var(--datepicker-border-color);
+    @include border-radius(var(--datepicker-border-radius));
+    box-shadow: var(--datepicker-box-shadow);
+    opacity: 1;
+
+    // Respond to Bootstrap's color mode system
+    &[data-bs-theme="light"] {
+      color-scheme: light;
+    }
+
+    &[data-bs-theme="dark"] {
+      color-scheme: dark;
+    }
+
+    // Catch-all for focus styles
+    button:focus-visible {
+      position: relative;
+      z-index: 1;
+      @include focus-ring();
+    }
+  }
+
+  [data-vc-calendar-hidden] {
+    pointer-events: none;
+    opacity: 0;
+  }
+
+  // Inline calendars
+  //
+  // Remove popover styling for more neutral styling
+  [data-vc="calendar"]:not([data-vc-input]) {
+    position: relative;
+    width: fit-content;
+    padding: 0;
+    border: 0;
+    box-shadow: none;
+  }
+
+  [data-vc-position="bottom"] {
+    margin-block-start: .25rem;
+  }
+
+  [data-vc-position="top"] {
+    margin-block-end: -.25rem;
+  }
+
+  [data-vc-arrow] {
+    position: relative;
+    display: block;
+    width: 2rem;
+    height: 2rem;
+    color: var(--datepicker-color);
+    pointer-events: auto;
+    cursor: pointer;
+    background-color: transparent;
+    border: 0;
+    @include border-radius($border-radius);
+
+    &::before {
+      position: absolute;
+      inset: .25rem;
+      content: "";
+      background-image: url("data:image/svg+xml,<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;
+  }
+}
index 27ad32e3fbb2d13bd368017c4ab7d9512929d8b3..09f9662639ef8d4b0efe2cda3d3a3c678ef9acc8 100644 (file)
@@ -17,6 +17,7 @@
 @forward "breadcrumb";
 @forward "card";
 @forward "carousel";
+@forward "datepicker";
 @forward "dialog";
 @forward "dropdown";
 @forward "list-group";
diff --git a/scss/forms/_form-adorn.scss b/scss/forms/_form-adorn.scss
new file mode 100644 (file)
index 0000000..f38892b
--- /dev/null
@@ -0,0 +1,111 @@
+@use "../config" as *;
+@use "../variables" as *;
+@use "../mixins/border-radius" as *;
+@use "../mixins/box-shadow" as *;
+@use "../mixins/focus-ring" as *;
+@use "../mixins/transition" as *;
+@use "form-variables" as *;
+
+// scss-docs-start form-adorn-variables
+$form-adorn-gap:           .375rem !default;
+$form-adorn-icon-size:     1rem !default;
+$form-adorn-icon-color:    var(--fg-2) !default;
+// scss-docs-end form-adorn-variables
+
+@layer forms {
+  .form-adorn {
+    // Inherit form-control CSS variables for sizing
+    --control-min-height: #{$control-min-height};
+    --control-padding-y: #{$control-padding-y};
+    --control-padding-x: #{$control-padding-x};
+    --control-font-size: #{$control-font-size};
+    --control-line-height: #{$control-line-height};
+    --control-color: #{$control-color};
+    --control-bg: #{$control-bg};
+    --control-border-width: #{$control-border-width};
+    --control-border-color: #{$control-border-color};
+    --control-border-radius: #{$control-border-radius};
+
+    // Adorn-specific variables
+    --form-adorn-gap: #{$form-adorn-gap};
+    --form-adorn-icon-size: #{$form-adorn-icon-size};
+    --form-adorn-icon-color: #{$form-adorn-icon-color};
+
+    // Flexbox layout
+    display: flex;
+    gap: var(--form-adorn-gap);
+    align-items: center;
+
+    // Replicate .form-control styles on the wrapper
+    min-height: var(--control-min-height);
+    padding: var(--control-padding-y) var(--control-padding-x);
+    font-size: var(--control-font-size);
+    line-height: var(--control-line-height);
+    color: var(--control-color);
+    background-color: var(--control-bg);
+    background-clip: padding-box;
+    border: var(--control-border-width) solid var(--control-border-color);
+    @include border-radius(var(--control-border-radius), 0);
+    @include box-shadow($input-box-shadow);
+    @include transition($input-transition);
+
+    // Focus state when ghost input is focused
+    &:focus-within {
+      border-color: $input-focus-border-color;
+      @include focus-ring(true);
+      --focus-ring-offset: -1px;
+    }
+
+    // Ghost input fills remaining space
+    > .form-ghost {
+      flex: 1;
+      min-width: 0; // Prevent text overflow
+    }
+
+
+    // Adornment at end (right in LTR) - input comes first visually
+    &.form-adorn-end > .form-ghost {
+      order: -1;
+    }
+  }
+
+  .form-adorn-icon {
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    color: var(--form-adorn-icon-color);
+    pointer-events: none;
+
+    > svg {
+      width: var(--form-adorn-icon-size);
+      height: var(--form-adorn-icon-size);
+    }
+  }
+
+  .form-adorn-text {
+    flex-shrink: 0;
+    color: var(--form-adorn-icon-color);
+    pointer-events: none;
+    user-select: none;
+  }
+
+  // Sizing variants
+  .form-adorn-sm {
+    --control-min-height: #{$control-min-height-sm};
+    --control-padding-y: #{$control-padding-y-sm};
+    --control-padding-x: #{$control-padding-x-sm};
+    --control-font-size: #{$control-font-size-sm};
+    --control-line-height: #{$control-line-height-sm};
+    --control-border-radius: #{$control-border-radius-sm};
+  }
+
+  .form-adorn-lg {
+    --control-min-height: #{$control-min-height-lg};
+    --control-padding-y: #{$control-padding-y-lg};
+    --control-padding-x: #{$control-padding-x-lg};
+    --control-font-size: #{$control-font-size-lg};
+    --control-line-height: #{$control-line-height-lg};
+    --control-border-radius: #{$control-border-radius-lg};
+  }
+}
index 07b8b134dadd0518599144cbd945bbf7c970aa3c..ff18bd102bdde5ae0a5960be5464cd6850e88161 100644 (file)
     &.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;
+    }
+  }
 }
index 54539eebb604b5408c693a5cd3bfd135582e95b1..4817b882c1aab34dd3d3844c9228061edcc1353d 100644 (file)
@@ -9,4 +9,5 @@
 @forward "input-group";
 @forward "strength";
 @forward "otp-input";
+@forward "form-adorn";
 @forward "validation";
index f8fa3d610084eab225a11f00475a880a62c093bb..50df81cf06d84b733faa49c6e62c4f49dde55f59 100644 (file)
@@ -75,6 +75,7 @@
     - title: Floating labels
     - title: OTP input
     - title: Password strength
+    - title: Form adorn
     - title: Layout
     - title: Validation
 
@@ -92,6 +93,7 @@
     - title: Carousel
     - title: Close button
     - title: Collapse
+    - title: Datepicker
     - title: Dialog
     - title: Dropdown
     - title: List group
index 0bb9ed7c3e12cf58d3805dd3e4be09bbf2687c7b..f6d6351c363e980ba7984f18442bb722957a0150 100644 (file)
       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"
@@ -62,6 +66,9 @@
       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"
diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx
new file mode 100644 (file)
index 0000000..0795c7c
--- /dev/null
@@ -0,0 +1,283 @@
+---
+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)
+})
+```
diff --git a/site/src/content/docs/forms/form-adorn.mdx b/site/src/content/docs/forms/form-adorn.mdx
new file mode 100644 (file)
index 0000000..cca9664
--- /dev/null
@@ -0,0 +1,108 @@
+---
+title: Form adorn
+description: Decorate inputs with icons, text, and more using a custom wrapper that easily handles styling and positioning.
+toc: true
+---
+
+## How it works
+
+The `.form-adorn` wrapper replicates `.form-control` styling (border, background, focus states) while using flexbox to position adornments alongside a ghost input. The `.form-ghost` input inside has no visual styling—it's transparent and inherits from the wrapper.
+
+## Example
+
+Wrap an icon and a `.form-ghost` input inside `.form-adorn`. Place the adornment before the input in the DOM for start position (left in LTR).
+
+<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" />