From b47a4be53285f9ff79031e2f636385ba407b3969 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Sun, 21 Dec 2025 20:54:23 -0800 Subject: [PATCH 01/14] First pass at datepicker via Vanilla Calendar Pro --- js/index.esm.js | 1 + js/index.umd.js | 2 + js/src/datepicker.js | 289 +++++++++++ js/tests/visual/datepicker.html | 170 +++++++ package-lock.json | 13 +- package.json | 3 +- scss/_datepicker.scss | 476 ++++++++++++++++++ scss/bootstrap.scss | 1 + .../content/docs/components/datepicker.mdx | 154 ++++++ 9 files changed, 1107 insertions(+), 2 deletions(-) create mode 100644 js/src/datepicker.js create mode 100644 js/tests/visual/datepicker.html create mode 100644 scss/_datepicker.scss create mode 100644 site/src/content/docs/components/datepicker.mdx 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..69b66756b3af --- /dev/null +++ b/js/src/datepicker.js @@ -0,0 +1,289 @@ +/** + * -------------------------------------------------------------------------- + * 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 SelectorEngine from './dom/selector-engine.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_FOCUS_DATA_API = `focus${EVENT_KEY}${DATA_API_KEY}` + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"]' + +const Default = { + dateMin: null, + dateMax: null, + dateFormat: null, // Uses locale default if null + firstWeekday: 1, // Monday + locale: 'default', + selectedDates: [], + selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged' + showWeekNumbers: false, + positionToInput: 'auto' +} + +const DefaultType = { + dateMin: '(null|string|number|object)', + dateMax: '(null|string|number|object)', + dateFormat: '(null|object)', + firstWeekday: 'number', + locale: 'string', + selectedDates: 'array', + selectionMode: 'string', + showWeekNumbers: 'boolean', + positionToInput: 'string' +} + +/** + * Class definition + */ + +class Datepicker extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._calendar = null + this._isShown = false + this._cleanupFn = null + + this._initCalendar() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle() { + return this._isShown ? this.hide() : this.show() + } + + show() { + if (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._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._cleanupFn) { + this._cleanupFn() + } + + this._calendar = null + super.dispose() + } + + getSelectedDates() { + return this._calendar ? [...this._calendar.context.selectedDates] : [] + } + + setSelectedDates(dates) { + if (this._calendar) { + this._calendar.set({ selectedDates: dates }) + } + } + + // Private + _initCalendar() { + const isInput = this._element.tagName === 'INPUT' + + const calendarOptions = { + inputMode: isInput, + positionToInput: this._config.positionToInput, + firstWeekday: this._config.firstWeekday, + locale: this._config.locale, + enableWeekNumbers: this._config.showWeekNumbers, + selectionDatesMode: this._config.selectionMode, + selectedDates: this._config.selectedDates, + selectedTheme: 'system', + themeAttrDetect: 'data-bs-theme' + } + + if (this._config.dateMin) { + calendarOptions.dateMin = this._config.dateMin + } + + if (this._config.dateMax) { + calendarOptions.dateMax = this._config.dateMax + } + + // Handle date selection + calendarOptions.onClickDate = (self, event) => { + const selectedDates = [...self.context.selectedDates] + + if (isInput && selectedDates.length > 0) { + // Format date for input + const formattedDate = this._formatDateForInput(selectedDates) + this._element.value = formattedDate + } + + EventHandler.trigger(this._element, EVENT_CHANGE, { + dates: selectedDates, + event + }) + + // Auto-hide after selection in single mode + if (this._config.selectionMode === 'single' && selectedDates.length > 0) { + // Small delay to allow the UI to update + setTimeout(() => this.hide(), 100) + } + } + + calendarOptions.onShow = () => { + this._isShown = true + } + + calendarOptions.onHide = () => { + this._isShown = false + } + + this._calendar = new Calendar(this._element, calendarOptions) + this._cleanupFn = this._calendar.init() + + // Set initial value if input has a value + if (isInput && this._element.value) { + this._parseInputValue() + } + } + + _formatDateForInput(dates) { + if (dates.length === 0) { + return '' + } + + if (this._config.dateFormat) { + // Custom formatting could be added here + return dates.join(', ') + } + + // Default: locale-aware formatting + const formatDate = dateStr => { + const [year, month, day] = dateStr.split('-') + const date = new Date(year, month - 1, day) + return date.toLocaleDateString(this._config.locale === 'default' ? undefined : this._config.locale) + } + + if (dates.length === 1) { + return formatDate(dates[0]) + } + + return dates.map(d => formatDate(d)).join(', ') + } + + _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) + if (this.tagName === 'INPUT') { + return + } + + event.preventDefault() + Datepicker.getOrCreateInstance(this).toggle() +}) + +EventHandler.on(document, EVENT_FOCUS_DATA_API, SELECTOR_DATA_TOGGLE, function () { + // Handle focus for input elements + if (this.tagName !== 'INPUT') { + return + } + + Datepicker.getOrCreateInstance(this).show() +}) + +// Close on outside click +EventHandler.on(document, 'click', event => { + const openDatepickers = SelectorEngine.find(SELECTOR_DATA_TOGGLE) + for (const element of openDatepickers) { + const instance = Datepicker.getInstance(element) + if (!instance || !instance._isShown) { + continue + } + + // Check if click is outside the element and calendar + const calendarEl = instance._calendar?.context?.mainElement + if ( + !element.contains(event.target) && + (!calendarEl || !calendarEl.contains(event.target)) + ) { + instance.hide() + } + } +}) + +export default Datepicker 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..c74355d8727e --- /dev/null +++ b/scss/_datepicker.scss @@ -0,0 +1,476 @@ +// Datepicker +// +// Bootstrap wrapper for vanilla-calendar-pro +// Base styles from vanilla-calendar-pro v3.0.5, adapted for Bootstrap + +@use "config" as *; +@use "colors" as *; +@use "variables" as *; + +// scss-docs-start datepicker-variables +$datepicker-padding: 1rem !default; +$datepicker-bg: var(--bg-body) !default; +$datepicker-color: var(--color-body) !default; +$datepicker-border-color: var(--border-color) !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: 272px !default; + +$datepicker-header-font-weight: 700 !default; +$datepicker-weekday-color: var(--secondary-text) !default; +$datepicker-day-hover-bg: var(--tertiary-bg) !default; +$datepicker-day-selected-bg: var(--primary-bg) !default; +$datepicker-day-selected-color: var(--primary-contrast) !default; +$datepicker-day-today-bg: var(--secondary-bg) !default; +$datepicker-day-today-color: var(--primary-text) !default; +$datepicker-day-disabled-color: var(--tertiary-text) !default; +// scss-docs-end datepicker-variables + +@layer components { + // scss-docs-start datepicker-css-vars + [data-vc="calendar"] { + --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 + + border-radius: var(--datepicker-border-radius); + box-sizing: border-box; + display: flex; + flex-direction: column; + min-width: var(--datepicker-min-width); + opacity: 1; + padding: var(--datepicker-padding); + position: relative; + transition: opacity .15s ease-in-out; + font-family: var(--font-sans-serif); + font-size: var(--datepicker-font-size); + background-color: var(--datepicker-bg); + color: var(--datepicker-color); + border: var(--datepicker-border-width) solid var(--datepicker-border-color); + box-shadow: var(--datepicker-box-shadow); + z-index: var(--datepicker-zindex); + + *, + *::before, + *::after { + box-sizing: border-box; + } + } + + [data-vc="calendar"]:focus-visible, + [data-vc="calendar"] button:focus-visible, + [data-vc="calendar"] [tabindex="0"]:focus-visible { + border-radius: $border-radius; + outline: 0; + box-shadow: $focus-ring-box-shadow; + } + + [data-vc="calendar"][data-vc-calendar-hidden] { + opacity: 0; + pointer-events: none; + } + + [data-vc="calendar"][data-vc-input] { + position: absolute; + } + + [data-vc="calendar"][data-vc-input][data-vc-position="bottom"] { + margin-top: .25rem; + } + + [data-vc="calendar"][data-vc-input][data-vc-position="top"] { + margin-top: -.25rem; + } + + // Controls (arrows) + [data-vc="controls"] { + align-items: center; + box-sizing: content-box; + display: flex; + justify-content: space-between; + left: 0; + padding-left: 1rem; + padding-right: 1rem; + padding-top: 1.25rem; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + z-index: 20; + } + + [data-vc-arrow] { + background-color: transparent; + border: 0; + cursor: pointer; + display: block; + height: 1.5rem; + pointer-events: auto; + position: relative; + width: 1.5rem; + border-radius: $border-radius; + color: var(--datepicker-color); + + &::before { + background-position: center; + background-repeat: no-repeat; + content: ""; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + background-image: url("data:image/svg+xml,"); + } + + &: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="grid"] { + display: flex; + flex-grow: 1; + flex-wrap: wrap; + gap: 1.75rem; + } + + [data-vc="column"] { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 240px; + } + + // Header + [data-vc="header"] { + align-items: center; + display: flex; + margin-bottom: .75rem; + position: relative; + } + + [data-vc-header="content"] { + align-items: center; + display: grid; + flex-grow: 1; + grid-auto-columns: max-content; + grid-auto-flow: column; + justify-content: center; + padding-left: 1rem; + padding-right: 1rem; + white-space: pre-wrap; + } + + [data-vc="month"], + [data-vc="year"] { + background-color: transparent; + border: 0; + border-radius: $border-radius; + cursor: pointer; + font-size: 1rem; + font-weight: var(--datepicker-header-font-weight); + line-height: 1.5rem; + padding: .25rem; + color: var(--datepicker-color); + + &:disabled { + pointer-events: none; + color: var(--datepicker-day-disabled-color); + } + + &:hover:not(:disabled) { + background-color: var(--datepicker-day-hover-bg); + } + } + + // Content wrapper + [data-vc="content"], + [data-vc="wrapper"] { + display: flex; + flex-grow: 1; + } + + [data-vc="content"] { + flex-direction: column; + } + + // Month/Year grids + [data-vc="months"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + [data-vc="months"], + [data-vc="years"] { + align-items: center; + column-gap: .25rem; + display: grid; + flex-grow: 1; + row-gap: 1rem; + } + + [data-vc="years"] { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + [data-vc-months-month], + [data-vc-years-year] { + align-items: center; + background-color: transparent; + border: 0; + border-radius: $border-radius; + cursor: pointer; + display: flex; + font-size: .75rem; + font-weight: 600; + height: 2.5rem; + justify-content: center; + line-height: 1rem; + padding: .25rem; + text-align: center; + word-break: break-all; + color: var(--datepicker-weekday-color); + + &:disabled { + pointer-events: none; + color: var(--datepicker-day-disabled-color); + } + + &:hover:not(:disabled) { + background-color: var(--datepicker-day-hover-bg); + } + + &[data-vc-months-month-selected], + &[data-vc-years-year-selected] { + background-color: var(--datepicker-day-selected-bg); + color: var(--datepicker-day-selected-color); + + &:hover { + background-color: var(--datepicker-day-selected-bg); + color: var(--datepicker-day-selected-color); + } + } + } + + // Week numbers + [data-vc-week="numbers"] { + display: flex; + flex-direction: column; + } + + [data-vc-week-numbers="title"] { + align-items: center; + display: flex; + font-size: .75rem; + font-weight: 700; + justify-content: center; + line-height: 1rem; + margin-bottom: .5rem; + } + + [data-vc-week-numbers="content"] { + align-items: center; + display: grid; + grid-auto-flow: row; + justify-items: center; + row-gap: .25rem; + } + + [data-vc-week-number] { + align-items: center; + background-color: transparent; + border: 0; + cursor: pointer; + display: flex; + font-size: .75rem; + font-weight: 600; + justify-content: center; + line-height: 1rem; + margin: 0; + min-height: 1.875rem; + min-width: 1.875rem; + padding: 0; + width: 100%; + color: var(--datepicker-weekday-color); + } + + // Week days header + [data-vc="week"] { + display: grid; + grid-template-columns: repeat(7, 1fr); + justify-items: center; + margin-bottom: .5rem; + } + + [data-vc-week-day] { + align-items: center; + background-color: transparent; + border: 0; + display: flex; + font-size: .75rem; + font-weight: 700; + justify-content: center; + line-height: 1rem; + margin: 0; + min-width: 1.875rem; + padding: 0; + width: 100%; + color: var(--datepicker-weekday-color); + } + + button[data-vc-week-day] { + cursor: pointer; + } + + // Dates grid + [data-vc="dates"] { + align-items: center; + display: grid; + flex-grow: 1; + grid-template-columns: repeat(7, 1fr); + justify-items: center; + pointer-events: none; + } + + [data-vc-date] { + align-items: center; + display: flex; + justify-content: center; + padding-bottom: .125rem; + padding-top: .125rem; + pointer-events: auto; + position: relative; + width: 100%; + } + + [data-vc-date]:not(:has([data-vc-date-btn])), + [data-vc-date][data-vc-date-disabled], + [data-vc-date][data-vc-date-disabled] [data-vc-date-btn] { + pointer-events: none; + } + + // Date button + [data-vc-date-btn] { + align-items: center; + background-color: transparent; + border: 0; + border-radius: $border-radius; + cursor: pointer; + display: flex; + font-size: .75rem; + font-weight: 400; + height: 100%; + justify-content: center; + line-height: 1rem; + min-height: 1.875rem; + min-width: 1.875rem; + padding: 0; + transition: background-color .15s ease-in-out, color .15s ease-in-out; + width: 100%; + color: var(--datepicker-color); + + &:hover { + background-color: var(--datepicker-day-hover-bg); + } + } + + // Today + [data-vc-date][data-vc-date-today] [data-vc-date-btn] { + font-weight: 700; + background-color: var(--datepicker-day-today-bg); + color: var(--datepicker-day-today-color); + } + + // Selected + [data-vc-date][data-vc-date-selected] [data-vc-date-btn] { + background-color: var(--datepicker-day-selected-bg); + color: var(--datepicker-day-selected-color); + + &:hover { + background-color: var(--datepicker-day-selected-bg); + color: var(--datepicker-day-selected-color); + } + } + + // Outside month + [data-vc-date][data-vc-date-month="next"] [data-vc-date-btn], + [data-vc-date][data-vc-date-month="prev"] [data-vc-date-btn] { + opacity: .5; + } + + // Disabled + [data-vc-date][data-vc-date-disabled] [data-vc-date-btn] { + color: var(--datepicker-day-disabled-color); + } + + // Range selection styles + [data-vc-date][data-vc-date-hover] [data-vc-date-btn] { + border-radius: 0; + background-color: var(--datepicker-day-hover-bg); + } + + [data-vc-date][data-vc-date-hover="first"] [data-vc-date-btn] { + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; + } + + [data-vc-date][data-vc-date-hover="last"] [data-vc-date-btn] { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; + } + + [data-vc-date][data-vc-date-hover="first-and-last"] [data-vc-date-btn] { + border-radius: $border-radius; + } + + [data-vc-date][data-vc-date-selected="middle"] [data-vc-date-btn] { + border-radius: 0; + opacity: .8; + } + + [data-vc-date][data-vc-date-selected="first"] [data-vc-date-btn] { + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + [data-vc-date][data-vc-date-selected="last"] [data-vc-date-btn] { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + [data-vc-date][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/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx new file mode 100644 index 000000000000..a7a424d00338 --- /dev/null +++ b/site/src/content/docs/components/datepicker.mdx @@ -0,0 +1,154 @@ +--- +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. + +`} /> + +## How it works + +- Add `data-bs-toggle="datepicker"` to any `` element to enable the datepicker +- 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`) + +## Examples + +### Basic input datepicker + +The simplest use case: add the data attribute to a text input. + + + + +`} /> + +### With min and max dates + +Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`. + + + + +`} /> + +### Multiple date selection + +Enable multiple date selection with `data-bs-selection-mode="multiple"`. + + + + +`} /> + +### Date range selection + +Select a range of dates with `data-bs-selection-mode="multiple-ranged"`. + + + + +`} /> + +### Week numbers + +Show week numbers alongside dates with `data-bs-show-week-numbers="true"`. + + + + +`} /> + +### First day of week + +Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first-weekday`. + + + + +`} /> + +## 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 picker displays in dark mode. + + + + +`} /> + +## 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` | +| `firstWeekday` | number | `1` | First day of week (0 = Sunday, 1 = Monday, etc.) | +| `locale` | string | `'default'` | Locale for date formatting (e.g., `'en-US'`, `'de-DE'`) | +| `selectedDates` | array | `[]` | Pre-selected dates in `YYYY-MM-DD` format | +| `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | +| `showWeekNumbers` | boolean | `false` | Whether to show week numbers | +| `positionToInput` | string | `'auto'` | Calendar position relative to input: `'auto'`, `'center'`, `'left'`, `'right'` | + + +### 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) +}) +``` From 867d3ea6b49ac4080afdbb97bae643599836df1d Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 29 Dec 2025 15:37:29 -0800 Subject: [PATCH 02/14] fixes --- js/src/datepicker.js | 4 ++-- site/data/sidebar.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/js/src/datepicker.js b/js/src/datepicker.js index 69b66756b3af..0f4f9617412b 100644 --- a/js/src/datepicker.js +++ b/js/src/datepicker.js @@ -26,7 +26,7 @@ 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_FOCUS_DATA_API = `focus${EVENT_KEY}${DATA_API_KEY}` +const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}` const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"]' @@ -257,7 +257,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( Datepicker.getOrCreateInstance(this).toggle() }) -EventHandler.on(document, EVENT_FOCUS_DATA_API, SELECTOR_DATA_TOGGLE, function () { +EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () { // Handle focus for input elements if (this.tagName !== 'INPUT') { return diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index f8fa3d610084..6d9275f8f8ec 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -92,6 +92,7 @@ - title: Carousel - title: Close button - title: Collapse + - title: Datepicker - title: Dialog - title: Dropdown - title: List group From f5f6561addb69897019f416b508184517d054b40 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 29 Dec 2025 16:03:31 -0800 Subject: [PATCH 03/14] optimize --- js/src/datepicker.js | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/js/src/datepicker.js b/js/src/datepicker.js index 0f4f9617412b..791ff9b1f56a 100644 --- a/js/src/datepicker.js +++ b/js/src/datepicker.js @@ -8,7 +8,6 @@ import { Calendar } from 'vanilla-calendar-pro' import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' -import SelectorEngine from './dom/selector-engine.js' import { isDisabled } from './util/index.js' /** @@ -120,11 +119,12 @@ class Datepicker extends BaseComponent { } dispose() { - if (this._cleanupFn) { - this._cleanupFn() + if (this._calendar) { + this._calendar.destroy() } this._calendar = null + this._cleanupFn = null super.dispose() } @@ -151,7 +151,7 @@ class Datepicker extends BaseComponent { selectionDatesMode: this._config.selectionMode, selectedDates: this._config.selectedDates, selectedTheme: 'system', - themeAttrDetect: 'data-bs-theme' + themeAttrDetect: '[data-bs-theme]' } if (this._config.dateMin) { @@ -258,32 +258,12 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( }) EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () { - // Handle focus for input elements + // Handle focus for input elements - VCP handles show internally if (this.tagName !== 'INPUT') { return } - Datepicker.getOrCreateInstance(this).show() -}) - -// Close on outside click -EventHandler.on(document, 'click', event => { - const openDatepickers = SelectorEngine.find(SELECTOR_DATA_TOGGLE) - for (const element of openDatepickers) { - const instance = Datepicker.getInstance(element) - if (!instance || !instance._isShown) { - continue - } - - // Check if click is outside the element and calendar - const calendarEl = instance._calendar?.context?.mainElement - if ( - !element.contains(event.target) && - (!calendarEl || !calendarEl.contains(event.target)) - ) { - instance.hide() - } - } + Datepicker.getOrCreateInstance(this) }) export default Datepicker From aa185e07995397dec0724f39c75928758c877a62 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 29 Dec 2025 17:51:56 -0800 Subject: [PATCH 04/14] Docs updates, add advanced config --- js/src/datepicker.js | 16 +++--- .../content/docs/components/datepicker.mdx | 57 ++++++++++++++++--- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/js/src/datepicker.js b/js/src/datepicker.js index 791ff9b1f56a..e02ace3ccfff 100644 --- a/js/src/datepicker.js +++ b/js/src/datepicker.js @@ -38,7 +38,8 @@ const Default = { selectedDates: [], selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged' showWeekNumbers: false, - positionToInput: 'auto' + positionToInput: 'left', + vcpOptions: {} // Pass-through for any VCP option } const DefaultType = { @@ -50,7 +51,8 @@ const DefaultType = { selectedDates: 'array', selectionMode: 'string', showWeekNumbers: 'boolean', - positionToInput: 'string' + positionToInput: 'string', + vcpOptions: 'object' } /** @@ -63,7 +65,6 @@ class Datepicker extends BaseComponent { this._calendar = null this._isShown = false - this._cleanupFn = null this._initCalendar() } @@ -87,7 +88,7 @@ class Datepicker extends BaseComponent { } show() { - if (isDisabled(this._element) || this._isShown) { + if (!this._calendar || isDisabled(this._element) || this._isShown) { return } @@ -103,7 +104,7 @@ class Datepicker extends BaseComponent { } hide() { - if (!this._isShown) { + if (!this._calendar || !this._isShown) { return } @@ -124,7 +125,6 @@ class Datepicker extends BaseComponent { } this._calendar = null - this._cleanupFn = null super.dispose() } @@ -142,7 +142,9 @@ class Datepicker extends BaseComponent { _initCalendar() { const isInput = this._element.tagName === 'INPUT' + // Start with user's VCP options, then override with Bootstrap options const calendarOptions = { + ...this._config.vcpOptions, inputMode: isInput, positionToInput: this._config.positionToInput, firstWeekday: this._config.firstWeekday, @@ -193,7 +195,7 @@ class Datepicker extends BaseComponent { } this._calendar = new Calendar(this._element, calendarOptions) - this._cleanupFn = this._calendar.init() + this._calendar.init() // Set initial value if input has a value if (isInput && this._element.value) { diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx index a7a424d00338..e36cb8a0366a 100644 --- a/site/src/content/docs/components/datepicker.mdx +++ b/site/src/content/docs/components/datepicker.mdx @@ -8,11 +8,12 @@ toc: true 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. -`} /> +`} /> ## 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`) @@ -24,18 +25,18 @@ The Bootstrap Datepicker is a wrapper around [vanilla-calendar-pro](https://vani The simplest use case: add the data attribute to a text input. - - -`} /> + + + `} /> ### With min and max dates Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`. - - - -`} /> + + + + `} /> ### Multiple date selection @@ -73,6 +74,25 @@ Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first `} /> +### Calendar placement + +Control where the calendar appears relative to the input with `data-bs-position-to-input`. Options are `left` (default), `center`, `right`, and `auto`. + + +
+ + +
+
+ + +
+
+ + +
+`} /> + ## 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 picker displays in dark mode. @@ -116,9 +136,28 @@ const datepicker = new bootstrap.Datepicker(datepickerEl, { | `selectedDates` | array | `[]` | Pre-selected dates in `YYYY-MM-DD` format | | `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | | `showWeekNumbers` | boolean | `false` | Whether to show week numbers | -| `positionToInput` | string | `'auto'` | Calendar position relative to input: `'auto'`, `'center'`, `'left'`, `'right'` | +| `positionToInput` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | +| `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 From efe3c0fc4749a5d748aee58449b63b88e916f52f Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 29 Dec 2025 18:45:50 -0800 Subject: [PATCH 05/14] rename attr --- js/src/datepicker.js | 6 +++--- site/src/content/docs/components/datepicker.mdx | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/js/src/datepicker.js b/js/src/datepicker.js index e02ace3ccfff..b2440f7bc09b 100644 --- a/js/src/datepicker.js +++ b/js/src/datepicker.js @@ -38,7 +38,7 @@ const Default = { selectedDates: [], selectionMode: 'single', // 'single', 'multiple', 'multiple-ranged' showWeekNumbers: false, - positionToInput: 'left', + placement: 'left', // 'left', 'center', 'right', 'auto' vcpOptions: {} // Pass-through for any VCP option } @@ -51,7 +51,7 @@ const DefaultType = { selectedDates: 'array', selectionMode: 'string', showWeekNumbers: 'boolean', - positionToInput: 'string', + placement: 'string', vcpOptions: 'object' } @@ -146,7 +146,7 @@ class Datepicker extends BaseComponent { const calendarOptions = { ...this._config.vcpOptions, inputMode: isInput, - positionToInput: this._config.positionToInput, + positionToInput: this._config.placement, // Map Bootstrap's 'placement' to VCP's 'positionToInput' firstWeekday: this._config.firstWeekday, locale: this._config.locale, enableWeekNumbers: this._config.showWeekNumbers, diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx index e36cb8a0366a..f9fcd9bd6c9f 100644 --- a/site/src/content/docs/components/datepicker.mdx +++ b/site/src/content/docs/components/datepicker.mdx @@ -74,22 +74,22 @@ Set the first day of the week (0 = Sunday, 1 = Monday, etc.) with `data-bs-first `} /> -### Calendar placement +### Placement -Control where the calendar appears relative to the input with `data-bs-position-to-input`. Options are `left` (default), `center`, `right`, and `auto`. +Control where the calendar appears relative to the input with `data-bs-placement`. Options are `left` (default), `center`, `right`, and `auto`.
- +
- +
- +
`} /> @@ -136,7 +136,7 @@ const datepicker = new bootstrap.Datepicker(datepickerEl, { | `selectedDates` | array | `[]` | Pre-selected dates in `YYYY-MM-DD` format | | `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | | `showWeekNumbers` | boolean | `false` | Whether to show week numbers | -| `positionToInput` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | +| `placement` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | | `vcpOptions` | object | `{}` | Pass-through object for any [vanilla-calendar-pro option](https://vanilla-calendar.pro/docs/reference/settings) |
From ad560c5192313897dbb836cb2b9d6c32354925cd Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 29 Dec 2025 18:47:30 -0800 Subject: [PATCH 06/14] edits --- site/src/content/docs/components/datepicker.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx index f9fcd9bd6c9f..a87d2a973865 100644 --- a/site/src/content/docs/components/datepicker.mdx +++ b/site/src/content/docs/components/datepicker.mdx @@ -1,12 +1,12 @@ --- title: Datepicker -description: A flexible date picker component powered by vanilla-calendar-pro, with Bootstrap styling and data attribute support. +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. +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. `} /> @@ -137,12 +137,12 @@ const datepicker = new bootstrap.Datepicker(datepickerEl, { | `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | | `showWeekNumbers` | boolean | `false` | Whether to show week numbers | | `placement` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | -| `vcpOptions` | object | `{}` | Pass-through object for any [vanilla-calendar-pro option](https://vanilla-calendar.pro/docs/reference/settings) | +| `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: +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, { @@ -156,7 +156,7 @@ const datepicker = new bootstrap.Datepicker(element, { }) ``` -See the [vanilla-calendar-pro documentation](https://vanilla-calendar.pro/docs/reference/settings) for all available options. +See the [Vanilla Calendar Pro documentation](https://vanilla-calendar.pro/docs/reference/settings) for all available options. ### Methods From 4942825787fd9e16ea76bdfca5837721c89ef622 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 30 Dec 2025 23:38:34 -0800 Subject: [PATCH 07/14] Update datepicker docs, improve color modes, add tests --- js/src/datepicker.js | 282 +++- js/tests/unit/datepicker.spec.js | 1205 +++++++++++++++++ scss/_datepicker.scss | 251 ++-- site/src/components/icons/Symbols.astro | 10 + .../content/docs/components/datepicker.mdx | 179 ++- 5 files changed, 1689 insertions(+), 238 deletions(-) create mode 100644 js/tests/unit/datepicker.spec.js diff --git a/js/src/datepicker.js b/js/src/datepicker.js index b2440f7bc09b..03821e974667 100644 --- a/js/src/datepicker.js +++ b/js/src/datepicker.js @@ -29,28 +29,38 @@ 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, // Uses locale default if 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' - showWeekNumbers: false, 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)', + 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', - showWeekNumbers: 'boolean', placement: 'string', vcpOptions: 'object' } @@ -84,10 +94,18 @@ class Datepicker extends BaseComponent { // 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 } @@ -104,6 +122,10 @@ class Datepicker extends BaseComponent { } hide() { + if (this._config.inline) { + return // Inline calendars are always visible + } + if (!this._calendar || !this._isShown) { return } @@ -120,6 +142,11 @@ class Datepicker extends BaseComponent { } dispose() { + if (this._themeObserver) { + this._themeObserver.disconnect() + this._themeObserver = null + } + if (this._calendar) { this._calendar.destroy() } @@ -129,7 +156,8 @@ class Datepicker extends BaseComponent { } getSelectedDates() { - return this._calendar ? [...this._calendar.context.selectedDates] : [] + const dates = this._calendar?.context?.selectedDates + return dates ? [...dates] : [] } setSelectedDates(dates) { @@ -140,20 +168,145 @@ class Datepicker extends BaseComponent { // Private _initCalendar() { - const isInput = this._element.tagName === 'INPUT' + 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 - // Start with user's VCP options, then override with Bootstrap options const calendarOptions = { ...this._config.vcpOptions, - inputMode: isInput, - positionToInput: this._config.placement, // Map Bootstrap's 'placement' to VCP's 'positionToInput' + inputMode: !this._isInline, + positionToInput: this._config.placement, firstWeekday: this._config.firstWeekday, locale: this._config.locale, - enableWeekNumbers: this._config.showWeekNumbers, selectionDatesMode: this._config.selectionMode, selectedDates: this._config.selectedDates, - selectedTheme: 'system', - themeAttrDetect: '[data-bs-theme]' + 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) { @@ -164,67 +317,86 @@ class Datepicker extends BaseComponent { calendarOptions.dateMax = this._config.dateMax } - // Handle date selection - calendarOptions.onClickDate = (self, event) => { - const selectedDates = [...self.context.selectedDates] + return calendarOptions + } + + _handleDateClick(self, event) { + const selectedDates = [...self.context.selectedDates] + + if (selectedDates.length > 0) { + const formattedDate = this._formatDateForInput(selectedDates) - if (isInput && selectedDates.length > 0) { - // Format date for input - const formattedDate = this._formatDateForInput(selectedDates) + if (this._isInput) { this._element.value = formattedDate } - EventHandler.trigger(this._element, EVENT_CHANGE, { - dates: selectedDates, - event - }) + if (this._boundInput) { + this._boundInput.value = selectedDates.join(',') + } - // Auto-hide after selection in single mode - if (this._config.selectionMode === 'single' && selectedDates.length > 0) { - // Small delay to allow the UI to update - setTimeout(() => this.hide(), 100) + if (this._displayElement) { + this._displayElement.textContent = formattedDate } } - calendarOptions.onShow = () => { - this._isShown = true - } + EventHandler.trigger(this._element, EVENT_CHANGE, { + dates: selectedDates, + event + }) - calendarOptions.onHide = () => { - this._isShown = false + this._maybeHideAfterSelection(selectedDates) + } + + _maybeHideAfterSelection(selectedDates) { + if (this._isInline) { + return } - this._calendar = new Calendar(this._element, calendarOptions) - this._calendar.init() + const shouldHide = + (this._config.selectionMode === 'single' && selectedDates.length > 0) || + (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) - // Set initial value if input has a value - if (isInput && this._element.value) { - this._parseInputValue() + if (shouldHide) { + setTimeout(() => this.hide(), HIDE_DELAY) } } - _formatDateForInput(dates) { - if (dates.length === 0) { - return '' + _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) } - if (this._config.dateFormat) { - // Custom formatting could be added here - return dates.join(', ') + // Intl.DateTimeFormat options object + if (dateFormat && typeof dateFormat === 'object') { + return new Intl.DateTimeFormat(locale, dateFormat).format(date) } // Default: locale-aware formatting - const formatDate = dateStr => { - const [year, month, day] = dateStr.split('-') - const date = new Date(year, month - 1, day) - return date.toLocaleDateString(this._config.locale === 'default' ? undefined : this._config.locale) + return date.toLocaleDateString(locale) + } + + _formatDateForInput(dates) { + if (dates.length === 0) { + return '' } if (dates.length === 1) { - return formatDate(dates[0]) + return this._formatDate(dates[0]) } - return dates.map(d => formatDate(d)).join(', ') + // 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() { @@ -251,7 +423,8 @@ class Datepicker extends BaseComponent { EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { // Only handle if not an input (inputs use focus) - if (this.tagName === 'INPUT') { + // Skip inline datepickers (they're always visible) + if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true') { return } @@ -260,12 +433,19 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( }) EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function () { - // Handle focus for input elements - VCP handles show internally + // Handle focus for input elements if (this.tagName !== 'INPUT') { return } - Datepicker.getOrCreateInstance(this) + 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/scss/_datepicker.scss b/scss/_datepicker.scss index c74355d8727e..84604af0bee0 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -6,12 +6,14 @@ @use "config" as *; @use "colors" as *; @use "variables" as *; +@use "mixins/border-radius" as *; +@use "mixins/transition" as *; // scss-docs-start datepicker-variables $datepicker-padding: 1rem !default; $datepicker-bg: var(--bg-body) !default; -$datepicker-color: var(--color-body) !default; -$datepicker-border-color: var(--border-color) !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; @@ -19,18 +21,18 @@ $datepicker-font-size: var(--font-size-sm) !default; $datepicker-min-width: 272px !default; $datepicker-header-font-weight: 700 !default; -$datepicker-weekday-color: var(--secondary-text) !default; -$datepicker-day-hover-bg: var(--tertiary-bg) !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(--secondary-bg) !default; -$datepicker-day-today-color: var(--primary-text) !default; -$datepicker-day-disabled-color: var(--tertiary-text) !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 { - // scss-docs-start datepicker-css-vars [data-vc="calendar"] { + // scss-docs-start datepicker-css-vars --datepicker-padding: #{$datepicker-padding}; --datepicker-bg: #{$datepicker-bg}; --datepicker-color: #{$datepicker-color}; @@ -50,49 +52,62 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; --datepicker-day-today-bg: #{$datepicker-day-today-bg}; --datepicker-day-today-color: #{$datepicker-day-today-color}; --datepicker-day-disabled-color: #{$datepicker-day-disabled-color}; + @include border-radius(var(--datepicker-border-radius)); // scss-docs-end datepicker-css-vars - border-radius: var(--datepicker-border-radius); + position: relative; + z-index: var(--datepicker-zindex); box-sizing: border-box; display: flex; flex-direction: column; min-width: var(--datepicker-min-width); - opacity: 1; padding: var(--datepicker-padding); - position: relative; - transition: opacity .15s ease-in-out; font-family: var(--font-sans-serif); font-size: var(--datepicker-font-size); - background-color: var(--datepicker-bg); color: var(--datepicker-color); + color-scheme: light dark; + background-color: var(--datepicker-bg); border: var(--datepicker-border-width) solid var(--datepicker-border-color); box-shadow: var(--datepicker-box-shadow); - z-index: var(--datepicker-zindex); + opacity: 1; + + // Respond to Bootstrap's color mode system + &[data-bs-theme="light"] { + color-scheme: light; + } - *, - *::before, - *::after { - box-sizing: border-box; + &[data-bs-theme="dark"] { + color-scheme: dark; } + + // Auto mode uses default color-scheme: light dark (above) } [data-vc="calendar"]:focus-visible, [data-vc="calendar"] button:focus-visible, [data-vc="calendar"] [tabindex="0"]:focus-visible { - border-radius: $border-radius; - outline: 0; - box-shadow: $focus-ring-box-shadow; + @include border-radius(var(--datepicker-border-radius)); + // outline: 0; + // box-shadow: $focus-ring-box-shadow; } [data-vc="calendar"][data-vc-calendar-hidden] { - opacity: 0; pointer-events: none; + opacity: 0; } [data-vc="calendar"][data-vc-input] { position: absolute; } + // Inline calendars: neutral styling (no popup chrome) + [data-vc="calendar"]:not([data-vc-input]) { + width: fit-content; + padding: 0; + border: 0; + box-shadow: none; + } + [data-vc="calendar"][data-vc-input][data-vc-position="bottom"] { margin-top: .25rem; } @@ -103,43 +118,43 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; // Controls (arrows) [data-vc="controls"] { - align-items: center; + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 20; box-sizing: content-box; display: flex; + align-items: center; justify-content: space-between; - left: 0; - padding-left: 1rem; - padding-right: 1rem; padding-top: 1.25rem; + padding-right: 1rem; + padding-left: 1rem; pointer-events: none; - position: absolute; - right: 0; - top: 0; - z-index: 20; } [data-vc-arrow] { - background-color: transparent; - border: 0; - cursor: pointer; + position: relative; display: block; + width: 1.5rem; height: 1.5rem; + color: var(--datepicker-color); pointer-events: auto; - position: relative; - width: 1.5rem; + cursor: pointer; + background-color: transparent; + border: 0; border-radius: $border-radius; - color: var(--datepicker-color); &::before { - background-position: center; - background-repeat: no-repeat; - content: ""; - height: 100%; - left: 0; position: absolute; top: 0; + left: 0; width: 100%; + height: 100%; + content: ""; background-image: url("data:image/svg+xml,"); + background-repeat: no-repeat; + background-position: center; } &:hover { @@ -165,46 +180,46 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; [data-vc="column"] { display: flex; - flex-direction: column; flex-grow: 1; + flex-direction: column; min-width: 240px; } // Header [data-vc="header"] { - align-items: center; + position: relative; display: flex; + align-items: center; margin-bottom: .75rem; - position: relative; } [data-vc-header="content"] { - align-items: center; display: grid; flex-grow: 1; grid-auto-columns: max-content; grid-auto-flow: column; + align-items: center; justify-content: center; - padding-left: 1rem; padding-right: 1rem; + padding-left: 1rem; white-space: pre-wrap; } [data-vc="month"], [data-vc="year"] { - background-color: transparent; - border: 0; - border-radius: $border-radius; - cursor: pointer; + padding: .25rem; font-size: 1rem; font-weight: var(--datepicker-header-font-weight); line-height: 1.5rem; - padding: .25rem; color: var(--datepicker-color); + cursor: pointer; + background-color: transparent; + border: 0; + border-radius: $border-radius; &:disabled { - pointer-events: none; color: var(--datepicker-day-disabled-color); + pointer-events: none; } &:hover:not(:disabled) { @@ -230,11 +245,11 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; [data-vc="months"], [data-vc="years"] { - align-items: center; - column-gap: .25rem; display: grid; flex-grow: 1; row-gap: 1rem; + column-gap: .25rem; + align-items: center; } [data-vc="years"] { @@ -243,25 +258,25 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; [data-vc-months-month], [data-vc-years-year] { - align-items: center; - background-color: transparent; - border: 0; - border-radius: $border-radius; - cursor: pointer; display: flex; + align-items: center; + justify-content: center; + height: 2.5rem; + padding: .25rem; font-size: .75rem; font-weight: 600; - height: 2.5rem; - justify-content: center; line-height: 1rem; - padding: .25rem; + color: var(--datepicker-weekday-color); text-align: center; word-break: break-all; - color: var(--datepicker-weekday-color); + cursor: pointer; + background-color: transparent; + border: 0; + border-radius: $border-radius; &:disabled { - pointer-events: none; color: var(--datepicker-day-disabled-color); + pointer-events: none; } &:hover:not(:disabled) { @@ -270,58 +285,16 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; &[data-vc-months-month-selected], &[data-vc-years-year-selected] { - background-color: var(--datepicker-day-selected-bg); color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); &:hover { - background-color: var(--datepicker-day-selected-bg); color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); } } } - // Week numbers - [data-vc-week="numbers"] { - display: flex; - flex-direction: column; - } - - [data-vc-week-numbers="title"] { - align-items: center; - display: flex; - font-size: .75rem; - font-weight: 700; - justify-content: center; - line-height: 1rem; - margin-bottom: .5rem; - } - - [data-vc-week-numbers="content"] { - align-items: center; - display: grid; - grid-auto-flow: row; - justify-items: center; - row-gap: .25rem; - } - - [data-vc-week-number] { - align-items: center; - background-color: transparent; - border: 0; - cursor: pointer; - display: flex; - font-size: .75rem; - font-weight: 600; - justify-content: center; - line-height: 1rem; - margin: 0; - min-height: 1.875rem; - min-width: 1.875rem; - padding: 0; - width: 100%; - color: var(--datepicker-weekday-color); - } - // Week days header [data-vc="week"] { display: grid; @@ -331,19 +304,19 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; } [data-vc-week-day] { - align-items: center; - background-color: transparent; - border: 0; display: flex; - font-size: .75rem; - font-weight: 700; + align-items: center; justify-content: center; - line-height: 1rem; - margin: 0; + width: 100%; min-width: 1.875rem; padding: 0; - width: 100%; + margin: 0; + font-size: .75rem; + font-weight: 700; + line-height: 1rem; color: var(--datepicker-weekday-color); + background-color: transparent; + border: 0; } button[data-vc-week-day] { @@ -352,23 +325,23 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; // Dates grid [data-vc="dates"] { - align-items: center; display: grid; flex-grow: 1; grid-template-columns: repeat(7, 1fr); + align-items: center; justify-items: center; pointer-events: none; } [data-vc-date] { - align-items: center; + position: relative; display: flex; + align-items: center; justify-content: center; - padding-bottom: .125rem; + width: 100%; padding-top: .125rem; + padding-bottom: .125rem; pointer-events: auto; - position: relative; - width: 100%; } [data-vc-date]:not(:has([data-vc-date-btn])), @@ -379,23 +352,23 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; // Date button [data-vc-date-btn] { - align-items: center; - background-color: transparent; - border: 0; - border-radius: $border-radius; - cursor: pointer; display: flex; - font-size: .75rem; - font-weight: 400; - height: 100%; + align-items: center; justify-content: center; - line-height: 1rem; - min-height: 1.875rem; + width: 100%; min-width: 1.875rem; + height: 100%; + min-height: 1.875rem; padding: 0; - transition: background-color .15s ease-in-out, color .15s ease-in-out; - width: 100%; + font-size: .75rem; + font-weight: 400; + line-height: 1rem; color: var(--datepicker-color); + cursor: pointer; + background-color: transparent; + border: 0; + border-radius: $border-radius; + transition: background-color .15s ease-in-out, color .15s ease-in-out; &:hover { background-color: var(--datepicker-day-hover-bg); @@ -405,18 +378,18 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; // Today [data-vc-date][data-vc-date-today] [data-vc-date-btn] { font-weight: 700; - background-color: var(--datepicker-day-today-bg); color: var(--datepicker-day-today-color); + background-color: var(--datepicker-day-today-bg); } // Selected [data-vc-date][data-vc-date-selected] [data-vc-date-btn] { - background-color: var(--datepicker-day-selected-bg); color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); &:hover { - background-color: var(--datepicker-day-selected-bg); color: var(--datepicker-day-selected-color); + background-color: var(--datepicker-day-selected-bg); } } @@ -433,8 +406,8 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; // Range selection styles [data-vc-date][data-vc-date-hover] [data-vc-date-btn] { - border-radius: 0; background-color: var(--datepicker-day-hover-bg); + border-radius: 0; } [data-vc-date][data-vc-date-hover="first"] [data-vc-date-btn] { @@ -458,15 +431,15 @@ $datepicker-day-disabled-color: var(--tertiary-text) !default; [data-vc-date][data-vc-date-selected="first"] [data-vc-date-btn] { border-top-left-radius: $border-radius; - border-bottom-left-radius: $border-radius; border-top-right-radius: 0; border-bottom-right-radius: 0; + border-bottom-left-radius: $border-radius; } [data-vc-date][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-top-left-radius: 0; border-bottom-left-radius: 0; } 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 @@ -17,91 +20,166 @@ The Bootstrap Datepicker is a wrapper around [Vanilla Calendar Pro](https://vani - 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 -### Basic input datepicker +### With icon -The simplest use case: add the data attribute to a text input. +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 +
+
+ +
+ +
`} /> -### With min and max dates +### Min & Max dates Restrict the selectable date range using `data-bs-date-min` and `data-bs-date-max`. - - - - `} /> +Event date (2025 only) + `} /> -### Multiple date selection +### Multiple dates Enable multiple date selection with `data-bs-selection-mode="multiple"`. - - - -`} /> +Select multiple dates + `} /> -### Date range selection +### Date range Select a range of dates with `data-bs-selection-mode="multiple-ranged"`. - - - -`} /> +Select date range + `} /> -### Week numbers +### Multiple months -Show week numbers alongside dates with `data-bs-show-week-numbers="true"`. +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 + `} /> + +## 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 picker displays in 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 @@ -131,12 +209,17 @@ const datepicker = new bootstrap.Datepicker(datepickerEl, { | --- | --- | --- | --- | | `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'` | -| `showWeekNumbers` | boolean | `false` | Whether to show week numbers | | `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) | From 504f7bcc87317b0217b9e4324d7ff4d8a68563f7 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 30 Dec 2025 23:39:00 -0800 Subject: [PATCH 08/14] New .form-adorn component for overlaying icons and text with inputs --- scss/forms/_form-adorn.scss | 112 +++++++++++++++++++++ scss/forms/_form-control.scss | 27 +++++ scss/forms/index.scss | 1 + site/data/sidebar.yml | 1 + site/src/content/docs/forms/form-adorn.mdx | 108 ++++++++++++++++++++ 5 files changed, 249 insertions(+) create mode 100644 scss/forms/_form-adorn.scss create mode 100644 site/src/content/docs/forms/form-adorn.mdx diff --git a/scss/forms/_form-adorn.scss b/scss/forms/_form-adorn.scss new file mode 100644 index 000000000000..380928992318 --- /dev/null +++ b/scss/forms/_form-adorn.scss @@ -0,0 +1,112 @@ +@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 + } + + // Icon adornment + > .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); + } + } + + // Text adornment + > .form-adorn-text { + flex-shrink: 0; + color: var(--form-adorn-icon-color); + pointer-events: none; + user-select: none; + } + + // Adornment at end (right in LTR) - input comes first visually + &.form-adorn-end > .form-ghost { + order: -1; + } + } + + // 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 6d9275f8f8ec..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 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 + + From e3f82e370ebfef21c716f1b4146b5d9fb5b7cef6 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 30 Dec 2025 23:49:23 -0800 Subject: [PATCH 09/14] temp --- scss/_datepicker.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index 84604af0bee0..746af06f6420 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -3,6 +3,8 @@ // Bootstrap wrapper for vanilla-calendar-pro // Base styles from vanilla-calendar-pro v3.0.5, adapted for Bootstrap +// 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 *; @@ -143,7 +145,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; cursor: pointer; background-color: transparent; border: 0; - border-radius: $border-radius; + @include border-radius($border-radius); &::before { position: absolute; @@ -215,7 +217,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; cursor: pointer; background-color: transparent; border: 0; - border-radius: $border-radius; + @include border-radius($border-radius); &:disabled { color: var(--datepicker-day-disabled-color); @@ -272,7 +274,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; cursor: pointer; background-color: transparent; border: 0; - border-radius: $border-radius; + @include border-radius($border-radius); &:disabled { color: var(--datepicker-day-disabled-color); From c8b409999970d9a2130fe4b23b7a300c0dce670a Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 30 Dec 2025 23:53:06 -0800 Subject: [PATCH 10/14] bump limits --- .bundlewatch.config.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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": { From 71d349676f8e7f5efd5ec77395fc72707ecfbf73 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Sun, 4 Jan 2026 14:12:59 -0800 Subject: [PATCH 11/14] cleanup and simpler selectors --- scss/_datepicker.scss | 110 ++++++++---------- scss/forms/_form-adorn.scss | 43 ++++--- .../content/docs/components/datepicker.mdx | 12 +- 3 files changed, 78 insertions(+), 87 deletions(-) diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index 746af06f6420..a36935339549 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -9,7 +9,6 @@ @use "colors" as *; @use "variables" as *; @use "mixins/border-radius" as *; -@use "mixins/transition" as *; // scss-docs-start datepicker-variables $datepicker-padding: 1rem !default; @@ -20,7 +19,7 @@ $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: 272px !default; +$datepicker-min-width: 280px !default; $datepicker-header-font-weight: 700 !default; $datepicker-weekday-color: var(--fg-3) !default; @@ -54,10 +53,9 @@ $datepicker-day-disabled-color: var(--fg-4) !default; --datepicker-day-today-bg: #{$datepicker-day-today-bg}; --datepicker-day-today-color: #{$datepicker-day-today-color}; --datepicker-day-disabled-color: #{$datepicker-day-disabled-color}; - @include border-radius(var(--datepicker-border-radius)); // scss-docs-end datepicker-css-vars - position: relative; + position: absolute; z-index: var(--datepicker-zindex); box-sizing: border-box; display: flex; @@ -70,6 +68,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; 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; @@ -81,8 +80,6 @@ $datepicker-day-disabled-color: var(--fg-4) !default; &[data-bs-theme="dark"] { color-scheme: dark; } - - // Auto mode uses default color-scheme: light dark (above) } [data-vc="calendar"]:focus-visible, @@ -93,47 +90,46 @@ $datepicker-day-disabled-color: var(--fg-4) !default; // box-shadow: $focus-ring-box-shadow; } - [data-vc="calendar"][data-vc-calendar-hidden] { + [data-vc-calendar-hidden] { pointer-events: none; opacity: 0; } - [data-vc="calendar"][data-vc-input] { - position: absolute; - } - - // Inline calendars: neutral styling (no popup chrome) + // 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="calendar"][data-vc-input][data-vc-position="bottom"] { - margin-top: .25rem; + [data-vc-position="bottom"] { + margin-block-start: .25rem; } - [data-vc="calendar"][data-vc-input][data-vc-position="top"] { - margin-top: -.25rem; + [data-vc-position="top"] { + margin-block-end: -.25rem; } // Controls (arrows) - [data-vc="controls"] { - position: absolute; - top: 0; - right: 0; - left: 0; - z-index: 20; - box-sizing: content-box; - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 1.25rem; - padding-right: 1rem; - padding-left: 1rem; - pointer-events: none; - } + // [data-vc="controls"] { + // position: absolute; + // top: 0; + // right: 0; + // left: 0; + // z-index: 20; + // box-sizing: content-box; + // display: flex; + // align-items: center; + // justify-content: space-between; + // padding-top: 1.25rem; + // padding-right: 1rem; + // padding-left: 1rem; + // pointer-events: none; + // } [data-vc-arrow] { position: relative; @@ -370,7 +366,6 @@ $datepicker-day-disabled-color: var(--fg-4) !default; background-color: transparent; border: 0; border-radius: $border-radius; - transition: background-color .15s ease-in-out, color .15s ease-in-out; &:hover { background-color: var(--datepicker-day-hover-bg); @@ -378,74 +373,71 @@ $datepicker-day-disabled-color: var(--fg-4) !default; } // Today - [data-vc-date][data-vc-date-today] [data-vc-date-btn] { - font-weight: 700; + [data-vc-date-today] [data-vc-date-btn] { + font-weight: 600; color: var(--datepicker-day-today-color); background-color: var(--datepicker-day-today-bg); } - // Selected - [data-vc-date][data-vc-date-selected] [data-vc-date-btn] { - 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); - } - } // Outside month - [data-vc-date][data-vc-date-month="next"] [data-vc-date-btn], - [data-vc-date][data-vc-date-month="prev"] [data-vc-date-btn] { + [data-vc-date-month="next"] [data-vc-date-btn], + [data-vc-date-month="prev"] [data-vc-date-btn] { opacity: .5; } // Disabled - [data-vc-date][data-vc-date-disabled] [data-vc-date-btn] { + [data-vc-date-disabled] [data-vc-date-btn] { color: var(--datepicker-day-disabled-color); } // Range selection styles - [data-vc-date][data-vc-date-hover] [data-vc-date-btn] { + [data-vc-date-hover] [data-vc-date-btn] { background-color: var(--datepicker-day-hover-bg); border-radius: 0; } - [data-vc-date][data-vc-date-hover="first"] [data-vc-date-btn] { - border-top-left-radius: $border-radius; - border-bottom-left-radius: $border-radius; + [data-vc-date-hover="first"] [data-vc-date-btn] { + border-start-start-radius: $border-radius; + border-end-start-radius: $border-radius; } - [data-vc-date][data-vc-date-hover="last"] [data-vc-date-btn] { - border-top-right-radius: $border-radius; - border-bottom-right-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][data-vc-date-hover="first-and-last"] [data-vc-date-btn] { + [data-vc-date-hover="first-and-last"] [data-vc-date-btn] { border-radius: $border-radius; } - [data-vc-date][data-vc-date-selected="middle"] [data-vc-date-btn] { + [data-vc-date-selected="middle"] [data-vc-date-btn] { border-radius: 0; opacity: .8; } - [data-vc-date][data-vc-date-selected="first"] [data-vc-date-btn] { + // 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][data-vc-date-selected="last"] [data-vc-date-btn] { + [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][data-vc-date-selected="first-and-last"] [data-vc-date-btn] { + [data-vc-date-selected="first-and-last"] [data-vc-date-btn] { border-radius: $border-radius; } } diff --git a/scss/forms/_form-adorn.scss b/scss/forms/_form-adorn.scss index 380928992318..f38892befeac 100644 --- a/scss/forms/_form-adorn.scss +++ b/scss/forms/_form-adorn.scss @@ -62,28 +62,6 @@ $form-adorn-icon-color: var(--fg-2) !default; min-width: 0; // Prevent text overflow } - // Icon adornment - > .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); - } - } - - // Text adornment - > .form-adorn-text { - flex-shrink: 0; - color: var(--form-adorn-icon-color); - pointer-events: none; - user-select: none; - } // Adornment at end (right in LTR) - input comes first visually &.form-adorn-end > .form-ghost { @@ -91,6 +69,27 @@ $form-adorn-icon-color: var(--fg-2) !default; } } + .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}; diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx index a0db14990921..e4ad66945a3f 100644 --- a/site/src/content/docs/components/datepicker.mdx +++ b/site/src/content/docs/components/datepicker.mdx @@ -29,12 +29,12 @@ Note that we're using a width utility of `.w-12` to ensure the input is wide eno 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 From dfb7f0b6239b29a28731708b2b44b61aea54c669 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Sun, 4 Jan 2026 14:31:28 -0800 Subject: [PATCH 12/14] few more tweaks --- scss/_datepicker.scss | 113 +++++++++++++++++------------------------- 1 file changed, 46 insertions(+), 67 deletions(-) diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index a36935339549..986d7728f030 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -9,6 +9,7 @@ @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; @@ -21,7 +22,7 @@ $datepicker-box-shadow: var(--box-shadow) !default; $datepicker-font-size: var(--font-size-sm) !default; $datepicker-min-width: 280px !default; -$datepicker-header-font-weight: 700 !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; @@ -80,14 +81,13 @@ $datepicker-day-disabled-color: var(--fg-4) !default; &[data-bs-theme="dark"] { color-scheme: dark; } - } - [data-vc="calendar"]:focus-visible, - [data-vc="calendar"] button:focus-visible, - [data-vc="calendar"] [tabindex="0"]:focus-visible { - @include border-radius(var(--datepicker-border-radius)); - // outline: 0; - // box-shadow: $focus-ring-box-shadow; + // Catch-all for focus styles + button:focus-visible { + position: relative; + z-index: 1; + @include focus-ring(); + } } [data-vc-calendar-hidden] { @@ -114,28 +114,11 @@ $datepicker-day-disabled-color: var(--fg-4) !default; margin-block-end: -.25rem; } - // Controls (arrows) - // [data-vc="controls"] { - // position: absolute; - // top: 0; - // right: 0; - // left: 0; - // z-index: 20; - // box-sizing: content-box; - // display: flex; - // align-items: center; - // justify-content: space-between; - // padding-top: 1.25rem; - // padding-right: 1rem; - // padding-left: 1rem; - // pointer-events: none; - // } - [data-vc-arrow] { position: relative; display: block; - width: 1.5rem; - height: 1.5rem; + width: 2rem; + height: 2rem; color: var(--datepicker-color); pointer-events: auto; cursor: pointer; @@ -145,10 +128,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; &::before { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: .25rem; content: ""; background-image: url("data:image/svg+xml,"); background-repeat: no-repeat; @@ -169,21 +149,24 @@ $datepicker-day-disabled-color: var(--fg-4) !default; } // Grid layout - [data-vc="grid"] { - display: flex; - flex-grow: 1; - flex-wrap: wrap; - gap: 1.75rem; - } + // [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; - } + // [data-vc="column"] { + // display: flex; + // flex-grow: 1; + // flex-direction: column; + // min-width: 240px; + // } + // // Header + // + [data-vc="header"] { position: relative; display: flex; @@ -191,26 +174,23 @@ $datepicker-day-disabled-color: var(--fg-4) !default; margin-bottom: .75rem; } + // Month and year [data-vc-header="content"] { - display: grid; + display: inline-flex; flex-grow: 1; - grid-auto-columns: max-content; - grid-auto-flow: column; align-items: center; justify-content: center; - padding-right: 1rem; - padding-left: 1rem; white-space: pre-wrap; } [data-vc="month"], [data-vc="year"] { - padding: .25rem; + padding: .25rem .5rem; + margin-inline: -.125rem; font-size: 1rem; font-weight: var(--datepicker-header-font-weight); - line-height: 1.5rem; color: var(--datepicker-color); - cursor: pointer; + // cursor: pointer; background-color: transparent; border: 0; @include border-radius($border-radius); @@ -226,32 +206,31 @@ $datepicker-day-disabled-color: var(--fg-4) !default; } // Content wrapper - [data-vc="content"], - [data-vc="wrapper"] { - display: flex; - flex-grow: 1; - } + // [data-vc="content"], + // [data-vc="wrapper"] { + // display: flex; + // flex-grow: 1; + // } [data-vc="content"] { + display: flex; + flex-grow: 1; flex-direction: column; } // Month/Year grids - [data-vc="months"] { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - [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"] { - grid-template-columns: repeat(5, minmax(0, 1fr)); + --vc-columns: 5; } [data-vc-months-month], @@ -310,7 +289,7 @@ $datepicker-day-disabled-color: var(--fg-4) !default; padding: 0; margin: 0; font-size: .75rem; - font-weight: 700; + font-weight: 600; line-height: 1rem; color: var(--datepicker-weekday-color); background-color: transparent; @@ -340,12 +319,12 @@ $datepicker-day-disabled-color: var(--fg-4) !default; padding-top: .125rem; padding-bottom: .125rem; pointer-events: auto; - } - [data-vc-date]:not(:has([data-vc-date-btn])), - [data-vc-date][data-vc-date-disabled], - [data-vc-date][data-vc-date-disabled] [data-vc-date-btn] { - pointer-events: none; + &:not(:has([data-vc-date-btn])), + &[data-vc-date-disabled], + &[data-vc-date-disabled] [data-vc-date-btn] { + pointer-events: none; + } } // Date button From 0ccff75ae1230743f262097c3ad63285c6fa0345 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 5 Jan 2026 11:18:02 -0800 Subject: [PATCH 13/14] Remove comment --- scss/_datepicker.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index 986d7728f030..b6885afe73d9 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -1,8 +1,3 @@ -// Datepicker -// -// Bootstrap wrapper for vanilla-calendar-pro -// Base styles from vanilla-calendar-pro v3.0.5, adapted for Bootstrap - // 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 *; From b7d14d24ce77791fcbde934dee2189a2a9b51108 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Mon, 5 Jan 2026 16:05:40 -0800 Subject: [PATCH 14/14] Fix multi-month, reorg some docs content, fix selections --- scss/_datepicker.scss | 47 +++++++++++-------- .../content/docs/components/datepicker.mdx | 19 +++++--- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index b6885afe73d9..218c08aa5c3c 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -144,19 +144,34 @@ $datepicker-day-disabled-color: var(--fg-4) !default; } // Grid layout - // [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; - // } + [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 @@ -200,12 +215,6 @@ $datepicker-day-disabled-color: var(--fg-4) !default; } } - // Content wrapper - // [data-vc="content"], - // [data-vc="wrapper"] { - // display: flex; - // flex-grow: 1; - // } [data-vc="content"] { display: flex; diff --git a/site/src/content/docs/components/datepicker.mdx b/site/src/content/docs/components/datepicker.mdx index e4ad66945a3f..0795c7cff864 100644 --- a/site/src/content/docs/components/datepicker.mdx +++ b/site/src/content/docs/components/datepicker.mdx @@ -50,19 +50,26 @@ 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"`. +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 - `} /> + `} /> -### Multiple months +### Multi-month date range -Display multiple months side-by-side with the `displayMonthsCount` option. This is useful for date range selection where users need to see more context. +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 - `} /> +Select date range + `} /> ## Options