diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 236c6ffa1df0..a535daf92269 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -18,43 +18,43 @@ }, { "path": "./dist/css/bootstrap-utilities.css", - "maxSize": "14.5 kB" + "maxSize": "14.25 kB" }, { "path": "./dist/css/bootstrap-utilities.min.css", - "maxSize": "12.75 kB" + "maxSize": "12.5 kB" }, { "path": "./dist/css/bootstrap.css", - "maxSize": "36.0 kB" + "maxSize": "37.75 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "32.5 kB" + "maxSize": "34.0 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "49.75 kB" + "maxSize": "67.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "26.0 kB" + "maxSize": "41.0 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "36.0 kB" + "maxSize": "39.0 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "22.25 kB" + "maxSize": "23.75 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "36.5 kB" + "maxSize": "39.5 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "19.75 kB" + "maxSize": "21.25 kB" } ], "ci": { diff --git a/js/index.esm.js b/js/index.esm.js index a4c7cdf3866c..01d298e05fce 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -9,6 +9,7 @@ export { default as Alert } from './src/alert.js' export { default as Button } from './src/button.js' export { default as Carousel } from './src/carousel.js' export { default as Collapse } from './src/collapse.js' +export { default as Datepicker } from './src/datepicker.js' export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' export { default as Offcanvas } from './src/offcanvas.js' diff --git a/js/index.umd.js b/js/index.umd.js index 4da18556e30e..73f12b424edd 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -9,6 +9,7 @@ import Alert from './src/alert.js' import Button from './src/button.js' import Carousel from './src/carousel.js' import Collapse from './src/collapse.js' +import Datepicker from './src/datepicker.js' import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' import Offcanvas from './src/offcanvas.js' @@ -26,6 +27,7 @@ export default { Button, Carousel, Collapse, + Datepicker, Dialog, Dropdown, Offcanvas, diff --git a/js/src/datepicker.js b/js/src/datepicker.js new file mode 100644 index 000000000000..03821e974667 --- /dev/null +++ b/js/src/datepicker.js @@ -0,0 +1,451 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap datepicker.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { Calendar } from 'vanilla-calendar-pro' +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import { isDisabled } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'datepicker' +const DATA_KEY = 'bs.datepicker' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_CHANGE = `change${EVENT_KEY}` +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}` + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"]' + +const HIDE_DELAY = 100 // ms delay before hiding after selection + +const Default = { + datepickerTheme: null, // 'light', 'dark', 'auto' - explicit theme for datepicker popover only + dateMin: null, + dateMax: null, + dateFormat: null, // Intl.DateTimeFormat options, or function(date, locale) => string + displayElement: null, // Element to show formatted date (defaults to element for buttons) + displayMonthsCount: 1, // Number of months to display side-by-side + firstWeekday: 1, // Monday + inline: false, // Render calendar inline (no popup) + locale: 'default', + positionElement: null, // Element to position calendar relative to (defaults to input) + selectedDates: [], + selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged' + placement: 'left', // 'left', 'center', 'right', 'auto' + vcpOptions: {} // Pass-through for any VCP option +} + +const DefaultType = { + datepickerTheme: '(null|string)', + dateMin: '(null|string|number|object)', + dateMax: '(null|string|number|object)', + dateFormat: '(null|object|function)', + displayElement: '(null|string|element|boolean)', + displayMonthsCount: 'number', + firstWeekday: 'number', + inline: 'boolean', + locale: 'string', + positionElement: '(null|string|element)', + selectedDates: 'array', + selectionMode: 'string', + placement: 'string', + vcpOptions: 'object' +} + +/** + * Class definition + */ + +class Datepicker extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._calendar = null + this._isShown = false + + this._initCalendar() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle() { + if (this._config.inline) { + return // Inline calendars are always visible + } + + return this._isShown ? this.hide() : this.show() + } + + show() { + if (this._config.inline) { + return // Inline calendars are always visible + } + + if (!this._calendar || isDisabled(this._element) || this._isShown) { + return + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW) + if (showEvent.defaultPrevented) { + return + } + + this._calendar.show() + this._isShown = true + + EventHandler.trigger(this._element, EVENT_SHOWN) + } + + hide() { + if (this._config.inline) { + return // Inline calendars are always visible + } + + if (!this._calendar || !this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + if (hideEvent.defaultPrevented) { + return + } + + this._calendar.hide() + this._isShown = false + + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + dispose() { + if (this._themeObserver) { + this._themeObserver.disconnect() + this._themeObserver = null + } + + if (this._calendar) { + this._calendar.destroy() + } + + this._calendar = null + super.dispose() + } + + getSelectedDates() { + const dates = this._calendar?.context?.selectedDates + return dates ? [...dates] : [] + } + + setSelectedDates(dates) { + if (this._calendar) { + this._calendar.set({ selectedDates: dates }) + } + } + + // Private + _initCalendar() { + this._isInput = this._element.tagName === 'INPUT' + this._isInline = this._config.inline + + // For inline mode, look for a hidden input child to bind to + if (this._isInline && !this._isInput) { + this._boundInput = this._element.querySelector('input[type="hidden"], input[name]') + } + + this._positionElement = this._resolvePositionElement() + this._displayElement = this._resolveDisplayElement() + + const calendarOptions = this._buildCalendarOptions() + + // Create calendar on the position element (for correct popup positioning) + // but value updates still go to this._element (the input) + this._calendar = new Calendar(this._positionElement, calendarOptions) + this._calendar.init() + + // Watch for theme changes on ancestor elements (for live theme switching) + this._setupThemeObserver() + + // Set initial value if input has a value + if (this._isInput && this._element.value) { + this._parseInputValue() + } + } + + _resolvePositionElement() { + let { positionElement } = this._config + + if (typeof positionElement === 'string') { + positionElement = document.querySelector(positionElement) + } + + // Use input's parent if in form-adorn + if (!positionElement && this._isInput && !this._isInline) { + const parent = this._element.closest('.form-adorn') + if (parent) { + positionElement = parent + } + } + + return positionElement || this._element + } + + _resolveDisplayElement() { + const { displayElement } = this._config + + if (typeof displayElement === 'string') { + return document.querySelector(displayElement) + } + + // For buttons/non-inputs (not inline), look for a [data-bs-datepicker-display] child + if (displayElement === true || (displayElement === null && !this._isInput && !this._isInline)) { + const displayChild = this._element.querySelector('[data-bs-datepicker-display]') + return displayChild || this._element + } + + return displayElement + } + + _getThemeAncestor() { + return this._element.closest('[data-bs-theme]') + } + + _getEffectiveTheme() { + // Priority: explicit datepickerTheme config > inherited from ancestor > none + const { datepickerTheme } = this._config + if (datepickerTheme) { + return datepickerTheme + } + + const ancestor = this._getThemeAncestor() + return ancestor?.getAttribute('data-bs-theme') || null + } + + _syncThemeAttribute(element) { + if (!element) { + return + } + + const theme = this._getEffectiveTheme() + + if (theme) { + // Copy theme to popover (needed because VCP appends to body, breaking CSS inheritance) + element.setAttribute('data-bs-theme', theme) + } else { + // No theme - remove attribute to allow natural inheritance + element.removeAttribute('data-bs-theme') + } + } + + _setupThemeObserver() { + // Watch for theme changes on ancestor elements + const ancestor = this._getThemeAncestor() + if (!ancestor || this._config.datepickerTheme) { + // No ancestor to watch, or explicit datepickerTheme overrides + return + } + + this._themeObserver = new MutationObserver(() => { + this._syncThemeAttribute(this._calendar?.context?.mainElement) + }) + + this._themeObserver.observe(ancestor, { + attributes: true, + attributeFilter: ['data-bs-theme'] + }) + } + + _buildCalendarOptions() { + // Get theme for VCP - use 'system' for auto-detection if no explicit theme + const theme = this._getEffectiveTheme() + // VCP uses 'system' for auto, Bootstrap uses 'auto' + const vcpTheme = !theme || theme === 'auto' ? 'system' : theme + + const calendarOptions = { + ...this._config.vcpOptions, + inputMode: !this._isInline, + positionToInput: this._config.placement, + firstWeekday: this._config.firstWeekday, + locale: this._config.locale, + selectionDatesMode: this._config.selectionMode, + selectedDates: this._config.selectedDates, + displayMonthsCount: this._config.displayMonthsCount, + type: this._config.displayMonthsCount > 1 ? 'multiple' : 'default', + selectedTheme: vcpTheme, + themeAttrDetect: '[data-bs-theme]', + onClickDate: (self, event) => this._handleDateClick(self, event), + onInit: self => { + this._syncThemeAttribute(self.context.mainElement) + }, + onShow: () => { + this._isShown = true + this._syncThemeAttribute(this._calendar.context.mainElement) + }, + onHide: () => { + this._isShown = false + } + } + + if (this._config.dateMin) { + calendarOptions.dateMin = this._config.dateMin + } + + if (this._config.dateMax) { + calendarOptions.dateMax = this._config.dateMax + } + + return calendarOptions + } + + _handleDateClick(self, event) { + const selectedDates = [...self.context.selectedDates] + + if (selectedDates.length > 0) { + const formattedDate = this._formatDateForInput(selectedDates) + + if (this._isInput) { + this._element.value = formattedDate + } + + if (this._boundInput) { + this._boundInput.value = selectedDates.join(',') + } + + if (this._displayElement) { + this._displayElement.textContent = formattedDate + } + } + + EventHandler.trigger(this._element, EVENT_CHANGE, { + dates: selectedDates, + event + }) + + this._maybeHideAfterSelection(selectedDates) + } + + _maybeHideAfterSelection(selectedDates) { + if (this._isInline) { + return + } + + const shouldHide = + (this._config.selectionMode === 'single' && selectedDates.length > 0) || + (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) + + if (shouldHide) { + setTimeout(() => this.hide(), HIDE_DELAY) + } + } + + _parseDate(dateStr) { + const [year, month, day] = dateStr.split('-') + return new Date(year, month - 1, day) + } + + _formatDate(dateStr) { + const date = this._parseDate(dateStr) + const locale = this._config.locale === 'default' ? undefined : this._config.locale + const { dateFormat } = this._config + + // Custom function formatter + if (typeof dateFormat === 'function') { + return dateFormat(date, locale) + } + + // Intl.DateTimeFormat options object + if (dateFormat && typeof dateFormat === 'object') { + return new Intl.DateTimeFormat(locale, dateFormat).format(date) + } + + // Default: locale-aware formatting + return date.toLocaleDateString(locale) + } + + _formatDateForInput(dates) { + if (dates.length === 0) { + return '' + } + + if (dates.length === 1) { + return this._formatDate(dates[0]) + } + + // For date ranges, use en-dash; for multiple dates, use comma + const separator = this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', ' + return dates.map(d => this._formatDate(d)).join(separator) + } + + _parseInputValue() { + // Try to parse the input value as a date + const value = this._element.value.trim() + if (!value) { + return + } + + const date = new Date(value) + if (!Number.isNaN(date.getTime())) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const formatted = `${year}-${month}-${day}` + this._calendar.set({ selectedDates: [formatted] }) + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + // Only handle if not an input (inputs use focus) + // Skip inline datepickers (they're always visible) + if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true') { + return + } + + event.preventDefault() + Datepicker.getOrCreateInstance(this).toggle() +}) + +EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () { + // Handle focus for input elements + if (this.tagName !== 'INPUT') { + return + } + + Datepicker.getOrCreateInstance(this).show() +}) + +// Auto-initialize inline datepickers on DOMContentLoaded +EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => { + for (const element of document.querySelectorAll(`${SELECTOR_DATA_TOGGLE}[data-bs-inline="true"]`)) { + Datepicker.getOrCreateInstance(element) + } +}) + +export default Datepicker diff --git a/js/tests/unit/datepicker.spec.js b/js/tests/unit/datepicker.spec.js new file mode 100644 index 000000000000..ad2710abf06b --- /dev/null +++ b/js/tests/unit/datepicker.spec.js @@ -0,0 +1,1205 @@ +import EventHandler from '../../src/dom/event-handler.js' +import Datepicker from '../../src/datepicker.js' +import { + clearFixture, createEvent, getFixture +} from '../helpers/fixture.js' + +describe('Datepicker', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + + // Clean up any VCP calendar elements that may have been created + for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) { + calendarEl.remove() + } + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Datepicker.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Datepicker.Default).toEqual(jasmine.any(Object)) + }) + + it('should have expected default values', () => { + expect(Datepicker.Default.dateMin).toBeNull() + expect(Datepicker.Default.dateMax).toBeNull() + expect(Datepicker.Default.selectionMode).toEqual('single') + expect(Datepicker.Default.firstWeekday).toEqual(1) + expect(Datepicker.Default.locale).toEqual('default') + expect(Datepicker.Default.placement).toEqual('left') + expect(Datepicker.Default.inline).toBeFalse() + expect(Datepicker.Default.displayMonthsCount).toEqual(1) + }) + }) + + describe('DefaultType', () => { + it('should return plugin default type config', () => { + expect(Datepicker.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Datepicker.DATA_KEY).toEqual('bs.datepicker') + }) + }) + + describe('NAME', () => { + it('should return plugin name', () => { + expect(Datepicker.NAME).toEqual('datepicker') + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('#datepickerEl') + const datepickerBySelector = new Datepicker('#datepickerEl') + const datepickerByElement = new Datepicker(inputEl) + + expect(datepickerBySelector._element).toEqual(inputEl) + expect(datepickerByElement._element).toEqual(inputEl) + }) + + it('should initialize VCP calendar instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._calendar).not.toBeNull() + }) + + it('should detect input element type', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._isInput).toBeTrue() + }) + + it('should detect button element type', () => { + fixtureEl.innerHTML = '' + + const buttonEl = fixtureEl.querySelector('button') + const datepicker = new Datepicker(buttonEl) + + expect(datepicker._isInput).toBeFalse() + }) + + it('should use config from data attributes', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.selectionMode).toEqual('multiple-ranged') + expect(datepicker._config.firstWeekday).toEqual(0) + }) + + it('should merge passed config with data attributes', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + selectionMode: 'multiple' + }) + + expect(datepicker._config.selectionMode).toEqual('multiple') + expect(datepicker._config.firstWeekday).toEqual(0) + }) + }) + + describe('show', () => { + it('should show the calendar popup', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', () => { + expect(datepicker._isShown).toBeTrue() + resolve() + }) + + datepicker.show() + }) + }) + + it('should trigger show.bs.datepicker event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('show.bs.datepicker', event => { + expect(event).toBeDefined() + resolve() + }) + + datepicker.show() + }) + }) + + it('should trigger shown.bs.datepicker event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', event => { + expect(event).toBeDefined() + resolve() + }) + + datepicker.show() + }) + }) + + it('should be cancelable via preventDefault', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('show.bs.datepicker', event => { + event.preventDefault() + }) + + inputEl.addEventListener('shown.bs.datepicker', () => { + throw new Error('shown event should not fire') + }) + + datepicker.show() + + setTimeout(() => { + expect(datepicker._isShown).toBeFalse() + resolve() + }, 50) + }) + }) + + it('should not show if already shown', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + datepicker._isShown = true + + const spy = spyOn(EventHandler, 'trigger') + datepicker.show() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should not show if disabled', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const spy = spyOn(EventHandler, 'trigger') + datepicker.show() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should do nothing for inline mode', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + const spy = spyOn(datepicker._calendar, 'show') + datepicker.show() + + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('hide', () => { + it('should hide the calendar popup', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', () => { + datepicker.hide() + }) + + inputEl.addEventListener('hidden.bs.datepicker', () => { + expect(datepicker._isShown).toBeFalse() + resolve() + }) + + datepicker.show() + }) + }) + + it('should trigger hide.bs.datepicker event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', () => { + datepicker.hide() + }) + + inputEl.addEventListener('hide.bs.datepicker', event => { + expect(event).toBeDefined() + resolve() + }) + + datepicker.show() + }) + }) + + it('should trigger hidden.bs.datepicker event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', () => { + datepicker.hide() + }) + + inputEl.addEventListener('hidden.bs.datepicker', event => { + expect(event).toBeDefined() + resolve() + }) + + datepicker.show() + }) + }) + + it('should be cancelable via preventDefault', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('shown.bs.datepicker', () => { + datepicker.hide() + }) + + inputEl.addEventListener('hide.bs.datepicker', event => { + event.preventDefault() + }) + + inputEl.addEventListener('hidden.bs.datepicker', () => { + throw new Error('hidden event should not fire') + }) + + datepicker.show() + + setTimeout(() => { + expect(datepicker._isShown).toBeTrue() + resolve() + }, 50) + }) + }) + + it('should not hide if not shown', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const spy = spyOn(EventHandler, 'trigger') + datepicker.hide() + + expect(spy).not.toHaveBeenCalled() + }) + + it('should do nothing for inline mode', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + datepicker._isShown = true + + const spy = spyOn(datepicker._calendar, 'hide') + datepicker.hide() + + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('toggle', () => { + it('should show when hidden', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const showSpy = spyOn(datepicker, 'show') + datepicker.toggle() + + expect(showSpy).toHaveBeenCalled() + }) + + it('should hide when shown', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + datepicker._isShown = true + + const hideSpy = spyOn(datepicker, 'hide') + datepicker.toggle() + + expect(hideSpy).toHaveBeenCalled() + }) + + it('should do nothing for inline mode', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + const showSpy = spyOn(datepicker, 'show') + const hideSpy = spyOn(datepicker, 'hide') + const result = datepicker.toggle() + + expect(result).toBeUndefined() + expect(showSpy).not.toHaveBeenCalled() + expect(hideSpy).not.toHaveBeenCalled() + }) + }) + + describe('getSelectedDates / setSelectedDates', () => { + it('should return empty array when no dates selected', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker.getSelectedDates()).toEqual([]) + }) + + it('should set selected dates', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + const dates = ['2025-01-15'] + + datepicker.setSelectedDates(dates) + + expect(datepicker.getSelectedDates()).toEqual(dates) + }) + + it('should return copy of dates array, not reference', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + const dates = ['2025-01-15'] + + datepicker.setSelectedDates(dates) + + const result = datepicker.getSelectedDates() + result.push('2025-01-20') + + expect(datepicker.getSelectedDates()).toEqual(dates) + }) + }) + + describe('dispose', () => { + it('should destroy VCP instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const destroySpy = spyOn(datepicker._calendar, 'destroy') + + datepicker.dispose() + + expect(destroySpy).toHaveBeenCalled() + expect(datepicker._calendar).toBeNull() + }) + + it('should remove data from element', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(Datepicker.getInstance(inputEl)).toEqual(datepicker) + + datepicker.dispose() + + expect(Datepicker.getInstance(inputEl)).toBeNull() + }) + }) + + describe('options', () => { + it('should respect dateMin option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.dateMin).toEqual('2025-01-01') + }) + + it('should respect dateMax option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.dateMax).toEqual('2025-12-31') + }) + + it('should respect selectionMode option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.selectionMode).toEqual('multiple-ranged') + }) + + it('should respect placement option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.placement).toEqual('right') + }) + + it('should respect inline option', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + expect(datepicker._config.inline).toBeTrue() + expect(datepicker._isInline).toBeTrue() + }) + + it('should respect displayMonthsCount option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.displayMonthsCount).toEqual(2) + }) + + it('should respect locale option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.locale).toEqual('de-DE') + }) + + it('should respect datepickerTheme option', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._config.datepickerTheme).toEqual('dark') + }) + }) + + describe('theme detection', () => { + it('should detect theme from closest [data-bs-theme] ancestor', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._getEffectiveTheme()).toEqual('dark') + }) + + it('should use explicit datepickerTheme config over ancestor', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._getEffectiveTheme()).toEqual('light') + }) + + it('should return null when no theme detected', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._getEffectiveTheme()).toBeNull() + }) + + it('should set data-bs-theme on calendar element when ancestor has theme', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + // Manually sync theme (simulates what onInit does) + datepicker._syncThemeAttribute(datepicker._calendar.context.mainElement) + + const calendarEl = datepicker._calendar.context.mainElement + expect(calendarEl.getAttribute('data-bs-theme')).toEqual('dark') + }) + + it('should not have data-bs-theme when no theme is set', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + // Manually sync theme (simulates what onInit does) + datepicker._syncThemeAttribute(datepicker._calendar.context.mainElement) + + const calendarEl = datepicker._calendar.context.mainElement + expect(calendarEl.hasAttribute('data-bs-theme')).toBeFalse() + }) + }) + + describe('form-adorn integration', () => { + it('should use .form-adorn parent as position element', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const adornEl = fixtureEl.querySelector('.form-adorn') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._positionElement).toEqual(adornEl) + }) + + it('should still use input element when not in form-adorn', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._positionElement).toEqual(inputEl) + }) + + it('should respect explicit positionElement option', () => { + fixtureEl.innerHTML = [ + '
', + '' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const customEl = fixtureEl.querySelector('#custom-position') + const datepicker = new Datepicker(inputEl, { + positionElement: '#custom-position' + }) + + expect(datepicker._positionElement).toEqual(customEl) + }) + }) + + describe('button trigger', () => { + it('should use button element as display element by default', () => { + fixtureEl.innerHTML = '' + + 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 = [ + '' + ].join('') + + const buttonEl = fixtureEl.querySelector('button') + const displayEl = fixtureEl.querySelector('[data-bs-datepicker-display]') + const datepicker = new Datepicker(buttonEl) + + expect(datepicker._displayElement).toEqual(displayEl) + }) + + it('should not set display element for inputs', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._displayElement).toBeNull() + }) + }) + + describe('date formatting', () => { + it('should format single date with default locale', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const result = datepicker._formatDate('2025-01-15') + + // Should be a string (format varies by system locale) + expect(typeof result).toEqual('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should format date with custom dateFormat options', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + dateFormat: { year: 'numeric', month: 'short', day: 'numeric' }, + locale: 'en-US' + }) + + const result = datepicker._formatDate('2025-01-15') + + expect(result).toEqual('Jan 15, 2025') + }) + + it('should format date with custom function', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + dateFormat: date => `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}` + }) + + const result = datepicker._formatDate('2025-01-15') + + expect(result).toEqual('2025-1-15') + }) + + it('should use en-dash for date ranges', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + locale: 'en-US' + }) + + const result = datepicker._formatDateForInput(['2025-01-15', '2025-01-20']) + + expect(result).toContain(' – ') + }) + + it('should use comma for multiple dates', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + locale: 'en-US' + }) + + const result = datepicker._formatDateForInput(['2025-01-15', '2025-01-20']) + + expect(result).toContain(', ') + }) + }) + + describe('inline mode', () => { + it('should set _isInline to true', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + expect(datepicker._isInline).toBeTrue() + }) + + it('should find hidden input for value binding', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const divEl = fixtureEl.querySelector('div') + const hiddenInput = fixtureEl.querySelector('input[type="hidden"]') + const datepicker = new Datepicker(divEl) + + expect(datepicker._boundInput).toEqual(hiddenInput) + }) + + it('should not look for hidden input when element is an input', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker._boundInput).toBeUndefined() + }) + }) + + describe('data-api', () => { + it('should toggle on click for buttons', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const buttonEl = fixtureEl.querySelector('button') + + buttonEl.addEventListener('shown.bs.datepicker', () => { + const datepicker = Datepicker.getInstance(buttonEl) + expect(datepicker._isShown).toBeTrue() + resolve() + }) + + buttonEl.click() + }) + }) + + it('should show on focus for inputs', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + + inputEl.addEventListener('shown.bs.datepicker', () => { + const datepicker = Datepicker.getInstance(inputEl) + expect(datepicker._isShown).toBeTrue() + resolve() + }) + + // Trigger focusin event + const focusEvent = createEvent('focusin', { bubbles: true }) + inputEl.dispatchEvent(focusEvent) + }) + }) + + it('should not toggle on click for inputs', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const toggleSpy = spyOn(datepicker, 'toggle') + + const clickEvent = createEvent('click', { bubbles: true }) + inputEl.dispatchEvent(clickEvent) + + expect(toggleSpy).not.toHaveBeenCalled() + }) + + it('should not toggle for inline datepickers', () => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + // Manually create instance (auto-init happens on DOMContentLoaded) + const datepicker = new Datepicker(divEl) + + const toggleSpy = spyOn(datepicker, 'toggle') + + const clickEvent = createEvent('click', { bubbles: true }) + divEl.dispatchEvent(clickEvent) + + expect(toggleSpy).not.toHaveBeenCalled() + }) + }) + + describe('getInstance', () => { + it('should return datepicker instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(Datepicker.getInstance(inputEl)).toEqual(datepicker) + expect(Datepicker.getInstance(inputEl)).toBeInstanceOf(Datepicker) + }) + + it('should return null when there is no datepicker instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + + expect(Datepicker.getInstance(inputEl)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return datepicker instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(Datepicker.getOrCreateInstance(inputEl)).toEqual(datepicker) + expect(Datepicker.getInstance(inputEl)).toEqual(Datepicker.getOrCreateInstance(inputEl, {})) + expect(Datepicker.getOrCreateInstance(inputEl)).toBeInstanceOf(Datepicker) + }) + + it('should return new instance when there is no datepicker instance', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + + expect(Datepicker.getInstance(inputEl)).toBeNull() + expect(Datepicker.getOrCreateInstance(inputEl)).toBeInstanceOf(Datepicker) + }) + + it('should return new instance when there is no datepicker instance with given configuration', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + + expect(Datepicker.getInstance(inputEl)).toBeNull() + const datepicker = Datepicker.getOrCreateInstance(inputEl, { + selectionMode: 'multiple-ranged' + }) + expect(datepicker).toBeInstanceOf(Datepicker) + expect(datepicker._config.selectionMode).toEqual('multiple-ranged') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + selectionMode: 'multiple-ranged' + }) + expect(Datepicker.getInstance(inputEl)).toEqual(datepicker) + + const datepicker2 = Datepicker.getOrCreateInstance(inputEl, { + selectionMode: 'single' + }) + expect(datepicker).toBeInstanceOf(Datepicker) + expect(datepicker2).toEqual(datepicker) + + // Config should not change + expect(datepicker2._config.selectionMode).toEqual('multiple-ranged') + }) + }) + + describe('input value parsing', () => { + it('should parse initial input value', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + // Should have one date selected (exact date may vary due to timezone) + const dates = datepicker.getSelectedDates() + expect(dates.length).toEqual(1) + expect(dates[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('should handle invalid input value gracefully', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + + // Should not throw + expect(() => new Datepicker(inputEl)).not.toThrow() + }) + + it('should handle empty input value', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + expect(datepicker.getSelectedDates()).toEqual([]) + }) + }) + + describe('vcpOptions pass-through', () => { + it('should pass vcpOptions to VCP', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl, { + vcpOptions: { + jumpMonths: 2 + } + }) + + expect(datepicker._config.vcpOptions.jumpMonths).toEqual(2) + }) + }) + + describe('date selection handling', () => { + it('should trigger change.bs.datepicker event on date click', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + inputEl.addEventListener('change.bs.datepicker', event => { + expect(event.dates).toBeDefined() + expect(Array.isArray(event.dates)).toBeTrue() + resolve() + }) + + // Simulate VCP date click callback + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + }) + }) + + it('should update input value on date selection', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + expect(inputEl.value).not.toEqual('') + }) + + it('should update display element on date selection for buttons', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const buttonEl = fixtureEl.querySelector('button') + const displayEl = fixtureEl.querySelector('[data-bs-datepicker-display]') + const datepicker = new Datepicker(buttonEl) + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + expect(displayEl.textContent).not.toEqual('Select date') + }) + + it('should update bound hidden input in inline mode', () => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const divEl = fixtureEl.querySelector('div') + const hiddenInput = fixtureEl.querySelector('input[type="hidden"]') + const datepicker = new Datepicker(divEl) + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + expect(hiddenInput.value).toEqual('2025-01-15') + }) + + it('should auto-hide after single date selection', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const hideSpy = spyOn(datepicker, 'hide') + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + setTimeout(() => { + expect(hideSpy).toHaveBeenCalled() + resolve() + }, 150) + }) + }) + + it('should auto-hide after range selection with 2 dates', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const hideSpy = spyOn(datepicker, 'hide') + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15', '2025-01-20'] } + }, new Event('click')) + + setTimeout(() => { + expect(hideSpy).toHaveBeenCalled() + resolve() + }, 150) + }) + }) + + it('should not auto-hide in range mode with only 1 date', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + const datepicker = new Datepicker(inputEl) + + const hideSpy = spyOn(datepicker, 'hide') + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + resolve() + }, 150) + }) + }) + + it('should not auto-hide in inline mode', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + const hideSpy = spyOn(datepicker, 'hide') + + datepicker._handleDateClick({ + context: { selectedDates: ['2025-01-15'] } + }, new Event('click')) + + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + resolve() + }, 150) + }) + }) + + it('should handle empty date selection', () => { + fixtureEl.innerHTML = '' + + const inputEl = fixtureEl.querySelector('input') + inputEl.value = 'previous value' + const datepicker = new Datepicker(inputEl) + + // Should not throw + expect(() => { + datepicker._handleDateClick({ + context: { selectedDates: [] } + }, new Event('click')) + }).not.toThrow() + + // Value should not be updated when no dates + expect(inputEl.value).toEqual('previous value') + }) + }) + + describe('_maybeHideAfterSelection', () => { + it('should not hide when inline', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + + const divEl = fixtureEl.querySelector('div') + const datepicker = new Datepicker(divEl) + + const hideSpy = spyOn(datepicker, 'hide') + + datepicker._maybeHideAfterSelection(['2025-01-15']) + + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + resolve() + }, 150) + }) + }) + }) + + describe('_resolvePositionElement', () => { + it('should resolve selector string to element', () => { + fixtureEl.innerHTML = [ + '
', + '' + ].join('') + + const inputEl = fixtureEl.querySelector('input') + const targetEl = fixtureEl.querySelector('#position-target') + const datepicker = new Datepicker(inputEl, { + positionElement: '#position-target' + }) + + expect(datepicker._positionElement).toEqual(targetEl) + }) + }) + + describe('_resolveDisplayElement', () => { + it('should resolve selector string to element', () => { + fixtureEl.innerHTML = [ + '', + '' + ].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 = [ + '', + '' + ].join('') + + const buttonEl = fixtureEl.querySelector('button') + const targetEl = fixtureEl.querySelector('#display-target') + const datepicker = new Datepicker(buttonEl, { + displayElement: targetEl + }) + + expect(datepicker._displayElement).toEqual(targetEl) + }) + }) +}) diff --git a/js/tests/visual/datepicker.html b/js/tests/visual/datepicker.html new file mode 100644 index 000000000000..70db69c4865e --- /dev/null +++ b/js/tests/visual/datepicker.html @@ -0,0 +1,170 @@ + + + + + + + Datepicker + + +
+

Datepicker Bootstrap Visual Test

+ +
+ +

Basic Input Datepicker

+
+
+ + +
+
+ +
+ +

With Min/Max Dates

+
+
+ + +
+
+ +
+ +

Multiple Selection

+
+
+ + +
+
+ +
+ +

Range Selection

+
+
+ + +
+
+ +
+ +

Week Numbers

+
+
+ + +
+
+ +
+ +

Sunday First

+
+
+ + +
+
+ +
+ +

Dark Mode

+
+
+
+ + +
+
+
+ +
+ +

JavaScript Initialization

+
+
+ + +
+ + + +
+
+
+
+ +
+ +

Events Test

+
+
+ + +
+
+
+ +
+ + + + + diff --git a/package-lock.json b/package-lock.json index e42ad0080188..a1b469a7d547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ ], "license": "MIT", "dependencies": { - "postcss-prefix-custom-properties": "^0.1.0" + "postcss-prefix-custom-properties": "^0.1.0", + "vanilla-calendar-pro": "^3.0.5" }, "devDependencies": { "@astrojs/check": "^0.9.5", @@ -19737,6 +19738,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vanilla-calendar-pro": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.0.5.tgz", + "integrity": "sha512-4X9bmTo1/KzbZrB7B6mZXtvVXIhcKxaVSnFZuaVtps7tshKJDxgaIElkgdia6IjB5qWetWuu7kZ+ZaV1sPxy6w==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://buymeacoffee.com/uvarov" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index fe99cc23746d..40a686d202fb 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,7 @@ "volar-service-emmet": "0.0.63" }, "dependencies": { - "postcss-prefix-custom-properties": "^0.1.0" + "postcss-prefix-custom-properties": "^0.1.0", + "vanilla-calendar-pro": "^3.0.5" } } diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss new file mode 100644 index 000000000000..218c08aa5c3c --- /dev/null +++ b/scss/_datepicker.scss @@ -0,0 +1,426 @@ +// stylelint-disable selector-max-attribute, property-disallowed-list, selector-no-qualifying-type -- VCP uses extensive data attributes and requires direct border-radius properties for range selection + +@use "config" as *; +@use "colors" as *; +@use "variables" as *; +@use "mixins/border-radius" as *; +@use "mixins/focus-ring" as *; + +// scss-docs-start datepicker-variables +$datepicker-padding: 1rem !default; +$datepicker-bg: var(--bg-body) !default; +$datepicker-color: var(--fg-body) !default; +$datepicker-border-color: var(--border-color-translucent) !default; +$datepicker-border-width: var(--border-width) !default; +$datepicker-border-radius: var(--border-radius-lg) !default; +$datepicker-box-shadow: var(--box-shadow) !default; +$datepicker-font-size: var(--font-size-sm) !default; +$datepicker-min-width: 280px !default; + +$datepicker-header-font-weight: 600 !default; +$datepicker-weekday-color: var(--fg-3) !default; +$datepicker-day-hover-bg: var(--bg-1) !default; +$datepicker-day-selected-bg: var(--primary-bg) !default; +$datepicker-day-selected-color: var(--primary-contrast) !default; +$datepicker-day-today-bg: var(--bg-2) !default; +$datepicker-day-today-color: var(--fg-1) !default; +$datepicker-day-disabled-color: var(--fg-4) !default; +// scss-docs-end datepicker-variables + +@layer components { + [data-vc="calendar"] { + // scss-docs-start datepicker-css-vars + --datepicker-padding: #{$datepicker-padding}; + --datepicker-bg: #{$datepicker-bg}; + --datepicker-color: #{$datepicker-color}; + --datepicker-border-color: #{$datepicker-border-color}; + --datepicker-border-width: #{$datepicker-border-width}; + --datepicker-border-radius: #{$datepicker-border-radius}; + --datepicker-box-shadow: #{$datepicker-box-shadow}; + --datepicker-font-size: #{$datepicker-font-size}; + --datepicker-min-width: #{$datepicker-min-width}; + --datepicker-zindex: #{$zindex-dropdown}; + + --datepicker-header-font-weight: #{$datepicker-header-font-weight}; + --datepicker-weekday-color: #{$datepicker-weekday-color}; + --datepicker-day-hover-bg: #{$datepicker-day-hover-bg}; + --datepicker-day-selected-bg: #{$datepicker-day-selected-bg}; + --datepicker-day-selected-color: #{$datepicker-day-selected-color}; + --datepicker-day-today-bg: #{$datepicker-day-today-bg}; + --datepicker-day-today-color: #{$datepicker-day-today-color}; + --datepicker-day-disabled-color: #{$datepicker-day-disabled-color}; + // scss-docs-end datepicker-css-vars + + position: absolute; + z-index: var(--datepicker-zindex); + box-sizing: border-box; + display: flex; + flex-direction: column; + min-width: var(--datepicker-min-width); + padding: var(--datepicker-padding); + font-family: var(--font-sans-serif); + font-size: var(--datepicker-font-size); + color: var(--datepicker-color); + color-scheme: light dark; + background-color: var(--datepicker-bg); + border: var(--datepicker-border-width) solid var(--datepicker-border-color); + @include border-radius(var(--datepicker-border-radius)); + box-shadow: var(--datepicker-box-shadow); + opacity: 1; + + // Respond to Bootstrap's color mode system + &[data-bs-theme="light"] { + color-scheme: light; + } + + &[data-bs-theme="dark"] { + color-scheme: dark; + } + + // Catch-all for focus styles + button:focus-visible { + position: relative; + z-index: 1; + @include focus-ring(); + } + } + + [data-vc-calendar-hidden] { + pointer-events: none; + opacity: 0; + } + + // Inline calendars + // + // Remove popover styling for more neutral styling + [data-vc="calendar"]:not([data-vc-input]) { + position: relative; + width: fit-content; + padding: 0; + border: 0; + box-shadow: none; + } + + [data-vc-position="bottom"] { + margin-block-start: .25rem; + } + + [data-vc-position="top"] { + margin-block-end: -.25rem; + } + + [data-vc-arrow] { + position: relative; + display: block; + width: 2rem; + height: 2rem; + color: var(--datepicker-color); + pointer-events: auto; + cursor: pointer; + background-color: transparent; + border: 0; + @include border-radius($border-radius); + + &::before { + position: absolute; + inset: .25rem; + content: ""; + background-image: url("data:image/svg+xml,"); + background-repeat: no-repeat; + background-position: center; + } + + &:hover { + background-color: var(--datepicker-day-hover-bg); + } + } + + [data-vc-arrow="prev"]::before { + transform: rotate(90deg); + } + + [data-vc-arrow="next"]::before { + transform: rotate(-90deg); + } + + // Grid layout + [data-vc="controls"] { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 1rem; + padding-right: 1rem; + padding-left: 1rem; + pointer-events: none; + } + + [data-vc="grid"] { + display: flex; + flex-grow: 1; + flex-wrap: wrap; + gap: 1.75rem; + } + + [data-vc="column"] { + display: flex; + flex-grow: 1; + flex-direction: column; + min-width: 240px; + } + + // + // Header + // + + [data-vc="header"] { + position: relative; + display: flex; + align-items: center; + margin-bottom: .75rem; + } + + // Month and year + [data-vc-header="content"] { + display: inline-flex; + flex-grow: 1; + align-items: center; + justify-content: center; + white-space: pre-wrap; + } + + [data-vc="month"], + [data-vc="year"] { + padding: .25rem .5rem; + margin-inline: -.125rem; + font-size: 1rem; + font-weight: var(--datepicker-header-font-weight); + color: var(--datepicker-color); + // cursor: pointer; + background-color: transparent; + border: 0; + @include border-radius($border-radius); + + &:disabled { + color: var(--datepicker-day-disabled-color); + pointer-events: none; + } + + &:hover:not(:disabled) { + background-color: var(--datepicker-day-hover-bg); + } + } + + + [data-vc="content"] { + display: flex; + flex-grow: 1; + flex-direction: column; + } + + // Month/Year grids + [data-vc="months"], + [data-vc="years"] { + display: grid; + flex-grow: 1; + grid-template-columns: repeat(var(--vc-columns, 4), minmax(0, 1fr)); + row-gap: 1rem; + column-gap: .25rem; + align-items: center; + } + + [data-vc="years"] { + --vc-columns: 5; + } + + [data-vc-months-month], + [data-vc-years-year] { + display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: .25rem; + font-size: .75rem; + font-weight: 600; + line-height: 1rem; + color: var(--datepicker-weekday-color); + text-align: center; + word-break: break-all; + cursor: pointer; + background-color: transparent; + border: 0; + @include border-radius($border-radius); + + &:disabled { + color: var(--datepicker-day-disabled-color); + pointer-events: none; + } + + &:hover:not(:disabled) { + background-color: var(--datepicker-day-hover-bg); + } + + &[data-vc-months-month-selected], + &[data-vc-years-year-selected] { + color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); + + &:hover { + color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); + } + } + } + + // Week days header + [data-vc="week"] { + display: grid; + grid-template-columns: repeat(7, 1fr); + justify-items: center; + margin-bottom: .5rem; + } + + [data-vc-week-day] { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 1.875rem; + padding: 0; + margin: 0; + font-size: .75rem; + font-weight: 600; + line-height: 1rem; + color: var(--datepicker-weekday-color); + background-color: transparent; + border: 0; + } + + button[data-vc-week-day] { + cursor: pointer; + } + + // Dates grid + [data-vc="dates"] { + display: grid; + flex-grow: 1; + grid-template-columns: repeat(7, 1fr); + align-items: center; + justify-items: center; + pointer-events: none; + } + + [data-vc-date] { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding-top: .125rem; + padding-bottom: .125rem; + pointer-events: auto; + + &:not(:has([data-vc-date-btn])), + &[data-vc-date-disabled], + &[data-vc-date-disabled] [data-vc-date-btn] { + pointer-events: none; + } + } + + // Date button + [data-vc-date-btn] { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 1.875rem; + height: 100%; + min-height: 1.875rem; + padding: 0; + font-size: .75rem; + font-weight: 400; + line-height: 1rem; + color: var(--datepicker-color); + cursor: pointer; + background-color: transparent; + border: 0; + border-radius: $border-radius; + + &:hover { + background-color: var(--datepicker-day-hover-bg); + } + } + + // Today + [data-vc-date-today] [data-vc-date-btn] { + font-weight: 600; + color: var(--datepicker-day-today-color); + background-color: var(--datepicker-day-today-bg); + } + + + // Outside month + [data-vc-date-month="next"] [data-vc-date-btn], + [data-vc-date-month="prev"] [data-vc-date-btn] { + opacity: .5; + } + + // Disabled + [data-vc-date-disabled] [data-vc-date-btn] { + color: var(--datepicker-day-disabled-color); + } + + // Range selection styles + [data-vc-date-hover] [data-vc-date-btn] { + background-color: var(--datepicker-day-hover-bg); + border-radius: 0; + } + + [data-vc-date-hover="first"] [data-vc-date-btn] { + border-start-start-radius: $border-radius; + border-end-start-radius: $border-radius; + } + + [data-vc-date-hover="last"] [data-vc-date-btn] { + border-start-end-radius: $border-radius; + border-end-end-radius: $border-radius; + } + + [data-vc-date-hover="first-and-last"] [data-vc-date-btn] { + border-radius: $border-radius; + } + + [data-vc-date-selected="middle"] [data-vc-date-btn] { + border-radius: 0; + opacity: .8; + } + + // Selected + [data-vc-date-selected] [data-vc-date-btn] { + color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); + + } + + [data-vc-date-selected="first"] [data-vc-date-btn] { + border-top-left-radius: $border-radius; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: $border-radius; + } + + [data-vc-date-selected="last"] [data-vc-date-btn] { + border-top-left-radius: 0; + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-bottom-left-radius: 0; + } + + [data-vc-date-selected="first-and-last"] [data-vc-date-btn] { + border-radius: $border-radius; + } +} diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 27ad32e3fbb2..09f9662639ef 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -17,6 +17,7 @@ @forward "breadcrumb"; @forward "card"; @forward "carousel"; +@forward "datepicker"; @forward "dialog"; @forward "dropdown"; @forward "list-group"; diff --git a/scss/forms/_form-adorn.scss b/scss/forms/_form-adorn.scss new file mode 100644 index 000000000000..f38892befeac --- /dev/null +++ b/scss/forms/_form-adorn.scss @@ -0,0 +1,111 @@ +@use "../config" as *; +@use "../variables" as *; +@use "../mixins/border-radius" as *; +@use "../mixins/box-shadow" as *; +@use "../mixins/focus-ring" as *; +@use "../mixins/transition" as *; +@use "form-variables" as *; + +// scss-docs-start form-adorn-variables +$form-adorn-gap: .375rem !default; +$form-adorn-icon-size: 1rem !default; +$form-adorn-icon-color: var(--fg-2) !default; +// scss-docs-end form-adorn-variables + +@layer forms { + .form-adorn { + // Inherit form-control CSS variables for sizing + --control-min-height: #{$control-min-height}; + --control-padding-y: #{$control-padding-y}; + --control-padding-x: #{$control-padding-x}; + --control-font-size: #{$control-font-size}; + --control-line-height: #{$control-line-height}; + --control-color: #{$control-color}; + --control-bg: #{$control-bg}; + --control-border-width: #{$control-border-width}; + --control-border-color: #{$control-border-color}; + --control-border-radius: #{$control-border-radius}; + + // Adorn-specific variables + --form-adorn-gap: #{$form-adorn-gap}; + --form-adorn-icon-size: #{$form-adorn-icon-size}; + --form-adorn-icon-color: #{$form-adorn-icon-color}; + + // Flexbox layout + display: flex; + gap: var(--form-adorn-gap); + align-items: center; + + // Replicate .form-control styles on the wrapper + min-height: var(--control-min-height); + padding: var(--control-padding-y) var(--control-padding-x); + font-size: var(--control-font-size); + line-height: var(--control-line-height); + color: var(--control-color); + background-color: var(--control-bg); + background-clip: padding-box; + border: var(--control-border-width) solid var(--control-border-color); + @include border-radius(var(--control-border-radius), 0); + @include box-shadow($input-box-shadow); + @include transition($input-transition); + + // Focus state when ghost input is focused + &:focus-within { + border-color: $input-focus-border-color; + @include focus-ring(true); + --focus-ring-offset: -1px; + } + + // Ghost input fills remaining space + > .form-ghost { + flex: 1; + min-width: 0; // Prevent text overflow + } + + + // Adornment at end (right in LTR) - input comes first visually + &.form-adorn-end > .form-ghost { + order: -1; + } + } + + .form-adorn-icon { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: var(--form-adorn-icon-color); + pointer-events: none; + + > svg { + width: var(--form-adorn-icon-size); + height: var(--form-adorn-icon-size); + } + } + + .form-adorn-text { + flex-shrink: 0; + color: var(--form-adorn-icon-color); + pointer-events: none; + user-select: none; + } + + // Sizing variants + .form-adorn-sm { + --control-min-height: #{$control-min-height-sm}; + --control-padding-y: #{$control-padding-y-sm}; + --control-padding-x: #{$control-padding-x-sm}; + --control-font-size: #{$control-font-size-sm}; + --control-line-height: #{$control-line-height-sm}; + --control-border-radius: #{$control-border-radius-sm}; + } + + .form-adorn-lg { + --control-min-height: #{$control-min-height-lg}; + --control-padding-y: #{$control-padding-y-lg}; + --control-padding-x: #{$control-padding-x-lg}; + --control-font-size: #{$control-font-size-lg}; + --control-line-height: #{$control-line-height-lg}; + --control-border-radius: #{$control-border-radius-lg}; + } +} diff --git a/scss/forms/_form-control.scss b/scss/forms/_form-control.scss index 07b8b134dadd..ff18bd102bdd 100644 --- a/scss/forms/_form-control.scss +++ b/scss/forms/_form-control.scss @@ -243,4 +243,31 @@ &.form-control-sm { height: $input-height-sm; } &.form-control-lg { height: $input-height-lg; } } + + // Ghost input - removes all visual styling + // Used inside custom wrappers that handle their own styling + .form-ghost { + display: block; + width: 100%; + padding: 0; + font: inherit; + color: inherit; + appearance: none; + background: transparent; + border: 0; + + &:focus { + outline: 0; + } + + &::placeholder { + color: var(--fg-3); + opacity: 1; + } + + &:disabled { + color: var(--fg-4); + cursor: not-allowed; + } + } } diff --git a/scss/forms/index.scss b/scss/forms/index.scss index 54539eebb604..4817b882c1aa 100644 --- a/scss/forms/index.scss +++ b/scss/forms/index.scss @@ -9,4 +9,5 @@ @forward "input-group"; @forward "strength"; @forward "otp-input"; +@forward "form-adorn"; @forward "validation"; diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index f8fa3d610084..50df81cf06d8 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -75,6 +75,7 @@ - title: Floating labels - title: OTP input - title: Password strength + - title: Form adorn - title: Layout - title: Validation @@ -92,6 +93,7 @@ - title: Carousel - title: Close button - title: Collapse + - title: Datepicker - title: Dialog - title: Dropdown - title: List group diff --git a/site/src/components/icons/Symbols.astro b/site/src/components/icons/Symbols.astro index 0bb9ed7c3e12..f6d6351c363e 100644 --- a/site/src/components/icons/Symbols.astro +++ b/site/src/components/icons/Symbols.astro @@ -35,6 +35,10 @@ d="M1.114 8.063V7.9c1.005-.102 1.497-.615 1.497-1.6V4.503c0-1.094.39-1.538 1.354-1.538h.273V2h-.376C2.25 2 1.49 2.759 1.49 4.352v1.524c0 1.094-.376 1.456-1.49 1.456v1.299c1.114 0 1.49.362 1.49 1.456v1.524c0 1.593.759 2.352 2.372 2.352h.376v-.964h-.273c-.964 0-1.354-.444-1.354-1.538V9.663c0-.984-.492-1.497-1.497-1.6ZM14.886 7.9v.164c-1.005.103-1.497.616-1.497 1.6v1.798c0 1.094-.39 1.538-1.354 1.538h-.273v.964h.376c1.613 0 2.372-.759 2.372-2.352v-1.524c0-1.094.376-1.456 1.49-1.456v-1.3c-1.114 0-1.49-.362-1.49-1.456V4.352C14.51 2.759 13.75 2 12.138 2h-.376v.964h.273c.964 0 1.354.444 1.354 1.538V6.3c0 .984.492 1.497 1.497 1.6ZM7.5 11.5V9.207l-1.621 1.621-.707-.707L6.792 8.5H4.5v-1h2.293L5.172 5.879l.707-.707L7.5 6.792V4.5h1v2.293l1.621-1.621.707.707L9.208 7.5H11.5v1H9.207l1.621 1.621-.707.707L8.5 9.208V11.5h-1Z" > + + + + + + + + + + Datepicker + `} /> + +Note that we're using a width utility of `.w-12` to ensure the input is wide enough to accommodate the date format and imply some affordance for the expected type of input. + +## How it works + +- Add `data-bs-toggle="datepicker"` to any `` element to enable the datepicker +- Use `type="text"` to avoid conflicts with native browser date pickers +- When focused, the calendar popup appears below the input +- Selecting a date updates the input value and closes the picker +- The picker respects Bootstrap's color modes (`data-bs-theme`) +- Configurable with any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings) via `vcpOptions` when initializing with JavaScript + +## Examples + +### With icon + +Use the [form adorn component](/docs/forms/form-adorn) to add a calendar icon alongside the datepicker input. When the input is inside a `.form-adorn` wrapper, the calendar automatically positions relative to the wrapper instead of the input. + +Select date +
+
+ +
+ +
`} /> + +### Min & Max dates + +Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`. + +Event date (2025 only) + `} /> + +### Multiple dates + +Enable multiple date selection with `data-bs-selection-mode="multiple"`. + +Select multiple dates + `} /> + +### Multiple months + +Display multiple months side-by-side with the `displayMonthsCount` option. This is useful for date range selection where users need to see more context. + +Select date range + `} /> + +### Date range + +Select a range of dates with `data-bs-selection-mode="multiple-ranged"`. Use `data-bs-selected-dates` to preselect a date range. + +Select date range + `} /> + +### Multi-month date range + +For selecting date ranges that span multiple months, combine `data-bs-selection-mode="multiple-ranged"` with `data-bs-display-months-count="2"` to show two months side-by-side, making it easier for users to select across month boundaries. + +Select date range + `} /> + +## Options + +### First day of week + +Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first-weekday`. + +Week starts on Sunday + `} /> + +### Placement + +Control where the calendar appears relative to the input with `data-bs-placement`. Options are `left` (default), `center`, `right`, and `auto`. + + +
+ + +
+
+ + +
+
+ + +
+ `} /> + +### Button trigger + +Use a button instead of an input for use cases like dashboard date filters. Add `data-bs-datepicker-display` to the text element to preserve icons when the date updates. + + + + Select date +`} /> + +For date range selection (e.g., dashboard time filters), use `data-bs-selection-mode="multiple-ranged"`. The calendar will close after both start and end dates are selected. + + + + Last 7 days + `} /> + +You can also display the selected date in a separate element using the `displayElement` option via JavaScript: + +```js +const datepicker = new bootstrap.Datepicker(buttonElement, { + selectionMode: 'multiple-ranged', + displayElement: '#date-display' // Selector or element +}) +``` + +### Inline mode + +Render the calendar inline (always visible, no popup) with `data-bs-inline="true"`. This is useful for embedding a calendar directly in the page. + +`} /> + +Inline datepickers with date range selection: + +`} /> + +Multiple months inline: + +`} /> + +To bind to a form field, include a hidden input inside the container. The value will be updated with the selected date(s) in `YYYY-MM-DD` format: + + +
+ +
+ +`} /> + +### Custom date formatting + +Control how dates are displayed using the `dateFormat` option. Pass an [`Intl.DateTimeFormat` options object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options) or a custom function. + +```js +// Using Intl.DateTimeFormat options +const datepicker = new bootstrap.Datepicker(element, { + dateFormat: { month: 'short', day: 'numeric', year: 'numeric' } + // Output: "Dec 23, 2025 – Dec 28, 2025" +}) + +// Using a custom function +const datepicker = new bootstrap.Datepicker(element, { + dateFormat: (date, locale) => { + return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' }) + } + // Output: "Dec 23 – Dec 28" +}) +``` + +## Dark mode + +The datepicker automatically adapts to Bootstrap's color modes. When `data-bs-theme="dark"` is set on a parent element or the `` tag, the calendar popup inherits that theme. + +### Inherited from parent + +When a parent element has a theme, both the input and calendar popup inherit it: + + + + +`} /> + +### Datepicker-only theme + +Use `data-bs-datepicker-theme` to set the datepicker popup's theme independently of the input. This is useful when you want a light input with a dark datepicker, or vice versa: + +Light input, dark datepicker +`} /> + +## Usage + +### Via data attributes + +Add `data-bs-toggle="datepicker"` to any input element to initialize it as a datepicker. + +```html + +``` + +### Via JavaScript + +Initialize datepickers programmatically: + +```js +const datepickerEl = document.getElementById('myDatepicker') +const datepicker = new bootstrap.Datepicker(datepickerEl, { + selectionMode: 'single', + firstWeekday: 1 +}) +``` + +### Options + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `dateMin` | string, number, Date | `null` | Minimum selectable date. Format: `YYYY-MM-DD` | +| `dateMax` | string, number, Date | `null` | Maximum selectable date. Format: `YYYY-MM-DD` | +| `dateFormat` | object, function | `null` | Date formatting. Pass `Intl.DateTimeFormat` options or a `function(date, locale)`. | +| `displayElement` | string, element, boolean | `null` | Element to show formatted date. For buttons, defaults to the button itself. Set to `false` to disable. | +| `displayMonthsCount` | number | `1` | Number of months to display side-by-side in the calendar. | +| `firstWeekday` | number | `1` | First day of week (0 = Sunday, 1 = Monday, etc.) | +| `inline` | boolean | `false` | Render calendar inline (always visible, no popup). | +| `locale` | string | `'default'` | Locale for date formatting (e.g., `'en-US'`, `'de-DE'`) | +| `positionElement` | string, element | `null` | Element to position calendar relative to. Auto-detects `.form-adorn` wrapper if present. | +| `selectedDates` | array | `[]` | Pre-selected dates in `YYYY-MM-DD` format | +| `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | +| `placement` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | +| `datepickerTheme` | string | `null` | Force datepicker popup theme: `'light'`, `'dark'`, `'auto'`, or `null` to inherit from ancestor `[data-bs-theme]` | +| `vcpOptions` | object | `{}` | Pass-through object for any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings) | + + +### Advanced configuration + +For features not directly exposed by Bootstrap's options, use `vcpOptions` to pass any Vanilla Calendar Pro setting: + +```js +const datepicker = new bootstrap.Datepicker(element, { + vcpOptions: { + disableDatesPast: true, // Disable past dates + disableWeekdays: [0, 6], // Disable weekends + disableDates: ['2025-12-25', '2025-12-26'], // Disable specific dates + selectedHolidays: ['2025-01-01'], // Highlight holidays + selectionTimeMode: 24 // Enable 24-hour time selection + } +}) +``` + +See the [Vanilla Calendar Pro documentation](https://vanilla-calendar.pro/docs/reference/settings) for all available options. + +### Methods + + +| Method | Description | +| --- | --- | +| `show()` | Shows the datepicker calendar | +| `hide()` | Hides the datepicker calendar | +| `toggle()` | Toggles the datepicker visibility | +| `getSelectedDates()` | Returns an array of selected dates in `YYYY-MM-DD` format | +| `setSelectedDates(dates)` | Sets the selected dates. Expects an array of `YYYY-MM-DD` strings | +| `dispose()` | Destroys the datepicker instance | +| `getInstance(element)` | Static method to get the datepicker instance from a DOM element | +| `getOrCreateInstance(element)` | Static method to get or create a datepicker instance | + + +### Events + + +| Event | Description | +| --- | --- | +| `show.bs.datepicker` | Fires immediately when the `show` method is called | +| `shown.bs.datepicker` | Fires when the datepicker has been made visible | +| `hide.bs.datepicker` | Fires immediately when the `hide` method is called | +| `hidden.bs.datepicker` | Fires when the datepicker has been hidden | +| `change.bs.datepicker` | Fires when a date is selected. Event includes `dates` (array) and `event` properties | + + +```js +const datepickerEl = document.getElementById('myDatepicker') +datepickerEl.addEventListener('change.bs.datepicker', event => { + console.log('Selected dates:', event.dates) +}) +``` diff --git a/site/src/content/docs/forms/form-adorn.mdx b/site/src/content/docs/forms/form-adorn.mdx new file mode 100644 index 000000000000..cca9664e3b77 --- /dev/null +++ b/site/src/content/docs/forms/form-adorn.mdx @@ -0,0 +1,108 @@ +--- +title: Form adorn +description: Decorate inputs with icons, text, and more using a custom wrapper that easily handles styling and positioning. +toc: true +--- + +## How it works + +The `.form-adorn` wrapper replicates `.form-control` styling (border, background, focus states) while using flexbox to position adornments alongside a ghost input. The `.form-ghost` input inside has no visual styling—it's transparent and inherits from the wrapper. + +## Example + +Wrap an icon and a `.form-ghost` input inside `.form-adorn`. Place the adornment before the input in the DOM for start position (left in LTR). + + +
+ +
+ + `} /> + +Use `.form-adorn-end` to position the adornment on the trailing side (keeps DOM order, uses CSS to flip visually): + + +
+ +
+ + `} /> + +## With labels + +Add a label outside the `.form-adorn` wrapper for proper form semantics: + + + +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+
`} /> + +## Text adornments + +Use `.form-adorn-text` for currency symbols, units, domain suffixes, and other text-based adornments. Text adornments auto-size to their content. + + + $ + + +
+ USD + +
+
+ https:// + +
+
+ @example.com + +
`} /> + +## Sizing + +Use `.form-adorn-sm` or `.form-adorn-lg` on the wrapper to adjust sizing. + + +
+ +
+ + +
+
+ +
+ +
+
+
+ +
+ +
`} /> + +## Ghost input + +The `.form-ghost` class strips all visual styling from an input, making it transparent. It's designed for use inside custom wrappers like `.form-adorn` that handle their own border, background, and focus states. + +`} /> + +## CSS + +### Sass variables + +