From d6659e7cb33e1e889e5c97e9c6fe59c769b5246a Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 5 Nov 2026 00:13:51 -0800 Subject: [PATCH 1/2] Repair Datepicker popup and form behavior Keep keyboard users in the form flow. Preserve the application input when the calendar closes or is disposed. Submit ISO dates while the visible field keeps its local format. Let Bootstrap own popup lifetime, focus, events, and positioning. Give Vanilla Calendar Pro a separate render node. Keep reset and disabled form behavior consistent. Repair theme, contrast, RTL, and Sass switch support. Document the form contract and trusted template boundary. Refs #43093, #43101, #43145, #43149, #43202 --- js/src/datepicker.ts | 663 +++++++++++++++---- js/src/dialog-base.ts | 4 +- js/tests/fixtures/datepicker-no-effects.scss | 4 + js/tests/fixtures/datepicker-weights.scss | 3 + js/tests/unit/datepicker-contract.spec.js | 216 ++++++ js/tests/unit/datepicker-interaction.spec.js | 362 ++++++++++ js/tests/unit/datepicker.spec.js | 6 +- scss/_datepicker.scss | 91 +-- site/src/content/docs/forms/datepicker.mdx | 56 +- site/src/content/docs/guides/migration.mdx | 1 + 10 files changed, 1219 insertions(+), 187 deletions(-) create mode 100644 js/tests/fixtures/datepicker-no-effects.scss create mode 100644 js/tests/fixtures/datepicker-weights.scss create mode 100644 js/tests/unit/datepicker-contract.spec.js create mode 100644 js/tests/unit/datepicker-interaction.spec.js diff --git a/js/src/datepicker.ts b/js/src/datepicker.ts index 699ffba125e1..05b1a55d27eb 100644 --- a/js/src/datepicker.ts +++ b/js/src/datepicker.ts @@ -17,10 +17,13 @@ import { type Range, type WeekDayID } from 'vanilla-calendar-pro' +import { + autoUpdate, computePosition, flip, offset, shift +} from '@floating-ui/dom' import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import { onDocumentReady } from './util/component-functions.js' -import { isDisabled } from './util/index.js' +import { getUID, isDisabled, isVisible } from './util/index.js' /** * Constants @@ -44,12 +47,13 @@ const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}` const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"]' +const EVENT_KEYDOWN = `keydown${EVENT_KEY}` +const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]' + const HIDE_DELAY = 100 // ms delay before hiding after selection -// The date, weekday, month-count and placement options are handed straight to -// Vanilla Calendar Pro, so they use its own literal unions rather than the wider -// `string` / `number`. That keeps a bad value a compile error here instead of a -// silent no-op inside the calendar. +// Keep the existing VCP option types, including the public placement names. +// Their literal unions reject unsupported values at compile time. type DatepickerConfig = { datepickerTheme: string | null dateMin: DateAny | null @@ -101,6 +105,53 @@ const DefaultType = { vcpOptions: 'object' } +// Each private field key identifies one entry without confusing duplicate names. +// Transform the entry list once. Keep native file entries and their order. +type FormBinding = { controls: Map [string, string] | null>; handler: (event: FormDataEvent) => void } +const formBindings = new WeakMap() +const bindFormData = (form: HTMLFormElement, key: string, serialize: () => [string, string] | null): (() => void) => { + let binding = formBindings.get(form) + if (!binding) { + const state: FormBinding = { controls: new Map(), handler() {} } + state.handler = event => { + const entries: Array<[string, FormDataEntryValue]> = [] + for (const [name, value] of event.formData) { + const control = state.controls.get(name) + if (!control) { + entries.push([name, value]) + continue + } + + const entry = control() + if (entry) { + entries.push(entry) + } + } + + for (const name of new Set(event.formData.keys())) { + event.formData.delete(name) + } + + for (const [name, value] of entries) { + event.formData.append(name, value) + } + } + + binding = state + formBindings.set(form, binding) + form.addEventListener('formdata', binding.handler, true) + } + + binding.controls.set(key, serialize) + return () => { + binding.controls.delete(key) + if (binding.controls.size === 0) { + form.removeEventListener('formdata', binding.handler, true) + formBindings.delete(form) + } + } +} + /** * Class definition */ @@ -116,17 +167,50 @@ class Datepicker extends BaseComponent { protected declare _positionElement: HTMLElement protected declare _displayElement: HTMLElement | false | null protected declare _themeObserver: MutationObserver | null - protected declare _themeAncestor: Element | null | undefined protected declare _onOutside: (event: Event) => void protected declare _onTriggerClick: () => void + protected declare _onKeydown: (event: Event) => void + protected declare _popup: HTMLElement | null + protected declare _floatingCleanup: (() => void) | null + protected declare _hideTimeout: ReturnType | null + protected declare _resetTimeout: ReturnType | null + protected declare _suppressFocus: boolean + protected declare _positionGeneration: number + protected declare _inputName: string | null + protected declare _inputObserver: MutationObserver | null + protected declare _form: HTMLFormElement | null + protected declare _formCleanup: (() => void) | null + protected declare _onReset: (event: Event) => void + protected declare _initialDates: string[] + protected declare _dialog: HTMLDialogElement | null + protected declare _onDialogClose: () => void + protected declare _eventPending: boolean + protected declare _originalAria: Map constructor(element?: string | Element | null, config?: Partial | null) { super(element, config) - this._calendar = null + this._popup = null this._isShown = false - - this._initCalendar() + this._floatingCleanup = null + this._hideTimeout = null + this._resetTimeout = null + this._suppressFocus = false + this._positionGeneration = 0 + this._inputName = null + this._inputObserver = null + this._form = null + this._formCleanup = null + this._dialog = null + this._initialDates = [] + this._eventPending = false + this._originalAria = new Map(['aria-haspopup', 'aria-expanded'].map(name => [name, this._element.getAttribute(name)])) + try { + this._initCalendar() + } catch (error) { + this.dispose() + throw error + } } // Getters @@ -144,57 +228,93 @@ class Datepicker extends BaseComponent { // Public toggle(): Promise { - if (this._config.inline) { - return Promise.resolve() // Inline calendars are always visible + if (this._isInline) { + return Promise.resolve() } return this._isShown ? this.hide() : this.show() } async show(): Promise { - if (this._config.inline) { - return // Inline calendars are always visible - } - - if (!this._calendar || isDisabled(this._element) || this._isShown) { + if (!this._calendar || this._isInline || this._isShown || this._eventPending || this._suppressFocus || isDisabled(this._element)) { return } - const showEvent = EventHandler.trigger(this._element, EVENT_SHOW) - if (showEvent.defaultPrevented) { + const event = this._triggerLifecycleEvent(EVENT_SHOW) + if (event.defaultPrevented || !this._calendar || this._isShown) { return } - this._calendar.show() this._isShown = true + const popup = this._popup! + this._mountPopup() + this._syncPopupState(this._calendar) + if (typeof popup.showPopover === 'function') { + popup.showPopover() + } + + this._element.setAttribute('aria-expanded', 'true') + await this._updatePosition() + if (!this._calendar || !this._isShown) { + return + } + this._floatingCleanup = autoUpdate(this._positionElement, popup, () => { + this._updatePosition() + }) EventHandler.trigger(this._element, EVENT_SHOWN) } async hide(): Promise { - if (this._config.inline) { - return // Inline calendars are always visible + clearTimeout(this._hideTimeout ?? undefined) + this._hideTimeout = null + if (!this._calendar || this._isInline || !this._isShown || this._eventPending) { + return } - if (!this._calendar || !this._isShown) { + const event = this._triggerLifecycleEvent(EVENT_HIDE) + if (event.defaultPrevented || !this._calendar) { return } - const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) - if (hideEvent.defaultPrevented) { - return + this._closePopup() + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + protected _triggerLifecycleEvent(name: string): Event { + this._eventPending = true + try { + return EventHandler.trigger(this._element, name) + } finally { + this._eventPending = false } + } - this._calendar.hide() + protected _closePopup(): void { this._isShown = false + this._positionGeneration++ + this._floatingCleanup?.() + this._floatingCleanup = null + if (this._popup?.matches(':popover-open')) { + this._popup.hidePopover() + } - EventHandler.trigger(this._element, EVENT_HIDDEN) + if (this._calendar) { + this._syncPopupState(this._calendar) + } + + this._element?.setAttribute('aria-expanded', 'false') } override dispose(): void { - if (this._themeObserver) { - this._themeObserver.disconnect() - this._themeObserver = null + clearTimeout(this._hideTimeout ?? undefined) + clearTimeout(this._resetTimeout ?? undefined) + this._closePopup() + this._themeObserver?.disconnect() + this._inputObserver?.disconnect() + this._formCleanup?.() + if (this._form && this._onReset) { + EventHandler.off(this._form, `reset${EVENT_KEY}`, this._onReset) } if (this._onOutside) { @@ -202,12 +322,37 @@ class Datepicker extends BaseComponent { EventHandler.off(document, EVENT_POINTERDOWN, this._onOutside) } + if (this._onKeydown) { + EventHandler.off(this._element, EVENT_KEYDOWN, this._onKeydown) + EventHandler.off(this._popup!, EVENT_KEYDOWN, this._onKeydown) + } + if (this._onTriggerClick) { EventHandler.off(this._positionElement, EVENT_CLICK, this._onTriggerClick) } - if (this._calendar) { + if (this._dialog) { + EventHandler.off(this._dialog, 'close', this._onDialogClose) + } + + if (this._calendar?.context.isInit) { this._calendar.destroy() + this._calendar.context.mainElement.remove() + } else { + this._popup?.remove() + } + + if (this._inputName !== null) { + this._element.setAttribute('name', this._inputName) + this._boundInput?.remove() + } + + for (const [name, value] of this._originalAria) { + if (value === null) { + this._element.removeAttribute(name) + } else { + this._element.setAttribute(name, value) + } } this._calendar = null @@ -221,6 +366,12 @@ class Datepicker extends BaseComponent { setSelectedDates(dates: DatesArr): void { if (this._calendar) { + for (const date of dates) { + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) && !this._parseIsoDate(date)) { + throw new TypeError('Invalid ISO date') + } + } + this._calendar.set({ selectedDates: dates }) this._syncSelectedDates([...this._calendar.context.selectedDates]) } @@ -230,39 +381,185 @@ class Datepicker extends BaseComponent { protected _initCalendar(): void { 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() + this._popup = document.createElement('div') + if (this._isInline) { + this._element.append(this._popup) + } else { + this._popup.setAttribute('popover', 'manual') + this._element.setAttribute('aria-haspopup', 'dialog') + this._element.setAttribute('aria-expanded', 'false') + this._mountPopup() + } - 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) + // Render VCP as a calendar only. Bootstrap owns the trigger, popup lifetime, + // positioning, focus, and events; VCP never replaces the application node. + this._calendar = new Calendar(this._popup, this._buildCalendarOptions()) this._calendar.init() + if (this._isInput && this._element.value) { + this._parseInputValue() + } - // Watch for theme changes on ancestor elements (for live theme switching) - this._setupThemeObserver() + this._initialDates = this.getSelectedDates() + if (this._isInput && !this._isInline) { + this._createBoundInput() + this._bindInputForm() + EventHandler.on(this._element, `input${EVENT_KEY}`, () => this._parseInputValue()) + } + this._updateDisplayWithSelectedDates() + if (this._isInline && this._boundInput) { + this._bindInputForm() + } + + this._setupThemeObserver() this._setupDismiss() + } - // Set initial value if input has a value - if (this._isInput && this._element.value) { - this._parseInputValue() + protected _createBoundInput(): void { + const name = this._element.getAttribute('name') + if (!name) { + return } - // Populate input/display with preselected dates - this._updateDisplayWithSelectedDates() + this._inputName = name + this._boundInput = document.createElement('input') + this._boundInput.type = 'hidden' + this._boundInput.id = getUID('bs-datepicker-') + this._boundInput.name = this._boundInput.id + this._element.before(this._boundInput) + this._element.removeAttribute('name') + this._syncBoundInput() + this._inputObserver = new MutationObserver(() => { + this._syncBoundInput() + this._bindInputForm() + }) + this._inputObserver.observe(this._element, { attributes: true, attributeFilter: ['name', 'disabled', 'form'] }) + } + + protected _syncBoundInput(): void { + if (this._inputName === null || !this._boundInput) { + return + } + + const name = this._element.getAttribute('name') + if (name !== null) { + this._inputName = name + this._element.removeAttribute('name') + } + + const form = this._element.getAttribute('form') + if (form === null) { + this._boundInput.removeAttribute('form') + } else { + this._boundInput.setAttribute('form', form) + } + + this._boundInput.value = this.getSelectedDates().join(',') + } + + protected _bindInputForm(): void { + const form = this._isInput ? (this._element as HTMLInputElement).form : this._boundInput?.form ?? null + if (form === this._form) { + return + } + + this._formCleanup?.() + if (this._form) { + EventHandler.off(this._form, `reset${EVENT_KEY}`, this._onReset) + } + + this._form = form + if (!form) { + return + } + + if (this._inputName !== null && this._boundInput) { + this._formCleanup = bindFormData(form, this._boundInput.name, () => { + this._syncBoundInput() + return this._element.matches(':disabled') || isDisabled(this._element) || !this._inputName ? + null : + [this._inputName, this.getSelectedDates().join(',')] + }) + } + + this._onReset = event => { + clearTimeout(this._resetTimeout ?? undefined) + this._resetTimeout = setTimeout(() => { + this._resetTimeout = null + if (!event.defaultPrevented && this._calendar) { + this.setSelectedDates(this._initialDates) + } + }, 0) + } + + EventHandler.on(form, `reset${EVENT_KEY}`, this._onReset) + } + + protected _mountPopup(): void { + const dialog = this._element.closest('dialog[open]') + if (dialog !== this._dialog) { + if (this._dialog) { + EventHandler.off(this._dialog, 'close', this._onDialogClose) + } + + this._dialog = dialog + this._onDialogClose = () => this._closePopup() + if (dialog) { + EventHandler.on(dialog, 'close', this._onDialogClose) + } + } + + const root = this._element.getRootNode() + const parent = dialog || (root instanceof ShadowRoot ? root : document.body) + if (this._popup!.parentNode !== parent) { + parent.append(this._popup!) + } + } + + protected async _updatePosition(): Promise { + const popup = this._popup + if (!popup || !this._isShown) { + return + } + + const generation = ++this._positionGeneration + const left = popup.dir === 'rtl' ? '-end' : '-start' + const right = popup.dir === 'rtl' ? '-start' : '-end' + const alignment = this._config.placement === 'center' ? '' : (this._config.placement === 'right' ? right : left) + const result = await computePosition(this._positionElement, popup, { + placement: `bottom${alignment}` as 'bottom' | 'bottom-start' | 'bottom-end', + strategy: 'fixed', + middleware: [offset(4), flip(), shift({ padding: 8 })] + }) + if (generation !== this._positionGeneration || !this._isShown) { + return + } + + Object.assign(popup.style, { position: result.strategy, left: `${result.x}px`, top: `${result.y}px` }) + popup.dataset.vcPosition = result.placement.startsWith('top') ? 'top' : 'bottom' + } + + protected _syncPopupState(self: Calendar): void { + const popup = self.context.mainElement + if (!this._isInline) { + popup.dataset.vcInput = '' + popup.role = 'dialog' + popup.toggleAttribute('data-vc-calendar-hidden', !this._isShown) + popup.inert = !this._isShown + popup.setAttribute('aria-hidden', String(!this._isShown)) + } + + this._syncThemeAttribute(popup) } protected _updateDisplayWithSelectedDates(): void { - const calendarDates = [...(this._calendar?.context.selectedDates || [])] - const selectedDates = calendarDates.length > 0 ? calendarDates : this._config.selectedDates + const selectedDates = this.getSelectedDates() if (selectedDates.length > 0) { this._syncSelectedDates(selectedDates) } @@ -276,6 +573,7 @@ class Datepicker extends BaseComponent { if (this._boundInput) { this._boundInput.value = selectedDates.join(',') + this._syncBoundInput() } if (this._displayElement) { @@ -318,25 +616,11 @@ class Datepicker extends BaseComponent { } protected _getThemeAncestor(): Element | null { - // Cache on first lookup: for an inline calendar, `_syncThemeAttribute` writes - // `data-bs-theme` onto this same element (it doubles as VCP's main element), which - // would make a later `closest()` match itself instead of the real ancestor. - if (this._themeAncestor === undefined) { - this._themeAncestor = this._element.closest('[data-bs-theme]') - } - - return this._themeAncestor + return this._element.closest('[data-bs-theme]') } protected _getEffectiveTheme(): string | null { - // 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 + return this._config.datepickerTheme || this._getThemeAncestor()?.getAttribute('data-bs-theme') || null } protected _syncThemeAttribute(element: HTMLElement | undefined): void { @@ -345,69 +629,153 @@ class Datepicker extends BaseComponent { } 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') } + + const computed = getComputedStyle(this._element) + element.dir = computed.direction + const explicit = this._config.datepickerTheme + element.style.colorScheme = explicit === 'auto' ? 'light dark' : (explicit === 'light' || explicit === 'dark' ? explicit : computed.colorScheme) + if (!this._isInline) { + for (const prefix of ['', 'bs-']) { + for (const role of ['base', 'bg', 'bg-subtle', 'bg-muted', 'fg', 'fg-emphasis', 'contrast', 'border', 'focus-ring']) { + const property = `--${prefix}theme-${role}` + const value = computed.getPropertyValue(property) + if (value) { + element.style.setProperty(property, value) + } else { + element.style.removeProperty(property) + } + } + } + } } protected _setupThemeObserver(): void { - // 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(records => { + const affectsTrigger = records.some(record => { + if (!(record.target instanceof Element)) { + return false + } - this._themeObserver = new MutationObserver(() => { - this._syncThemeAttribute(this._calendar?.context?.mainElement) - }) + let node: Node | null = this._element + while (node) { + if (record.target.contains(node)) { + return true + } - this._themeObserver.observe(ancestor, { - attributes: true, - attributeFilter: ['data-bs-theme'] + const root = node.getRootNode() + node = root instanceof ShadowRoot ? root.host : null + } + + return false + }) + if (affectsTrigger) { + this._syncThemeAttribute(this._popup ?? undefined) + } }) + const root = this._element.getRootNode() + const options = { subtree: true, attributes: true, attributeFilter: ['data-bs-theme', 'class', 'style', 'dir'] } + this._themeObserver.observe(document.documentElement, options) + if (root instanceof ShadowRoot) { + this._themeObserver.observe(root, options) + } } - // VCP hides on outside `click` itself, but skips Bootstrap's hide events and - // ignores focus moves. Bootstrap closes the popup first instead. `focusin` - // covers keyboard moves and focusable targets. `pointerdown` covers presses on - // non-focusable elements (empty page area, a plain `
`, text). Use - // `pointerdown`, not `click`, so the target is read before the calendar - // re-renders on click (e.g. month or year navigation) and detaches the node. protected _setupDismiss(): void { if (this._isInline) { return } - // VCP schedules a show on every click of its trigger, after the data API - // toggle runs. When that toggle closed the popup, cancel the reopen. - // `hide()` on VCP clears its pending show even when already hidden. - if (!this._isInput) { - this._onTriggerClick = () => { - if (!this._isShown) { - this._calendar?.hide() - } + this._onTriggerClick = () => { + if (this._isInput) { + this.show() + } else if (this._element.dataset.bsToggle !== NAME) { + this.toggle() } + } - EventHandler.on(this._positionElement, EVENT_CLICK, this._onTriggerClick) + EventHandler.on(this._positionElement, EVENT_CLICK, this._onTriggerClick) + if (this._isInput) { + EventHandler.on(this._element, EVENT_FOCUSIN, () => this.show()) } - this._onOutside = ({ target }) => { - const mainElement = this._calendar?.context?.mainElement - const isOutside = !(target instanceof Node) || (!this._element.contains(target) && !mainElement?.contains(target)) + this._onOutside = event => { + if (!this._isShown) { + return + } - if (this._isShown && isOutside) { + const inside = event.composedPath().some(node => node instanceof Node && ( + this._positionElement.contains(node) || this._element.contains(node) || this._popup?.contains(node) + )) + if (!inside) { this.hide() } } EventHandler.on(document, EVENT_FOCUSIN, this._onOutside) EventHandler.on(document, EVENT_POINTERDOWN, this._onOutside) + this._onKeydown = event => this._handleKeydown(event as KeyboardEvent) + EventHandler.on(this._element, EVENT_KEYDOWN, this._onKeydown) + EventHandler.on(this._popup!, EVENT_KEYDOWN, this._onKeydown) + } + + protected _focusTrigger(): void { + this._suppressFocus = true + this._element.focus() + this._suppressFocus = false + } + + protected _handleKeydown(event: KeyboardEvent): void { + const { target } = event + if (!this._isShown || !(target instanceof Node) || (!this._positionElement.contains(target) && !this._element.contains(target) && !this._popup?.contains(target))) { + return + } + + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + this.hide() + if (!this._isShown) { + this._focusTrigger() + } + + return + } + + if (event.key !== 'Tab') { + return + } + + const controls = [...this._popup!.querySelectorAll(FOCUSABLE)].filter( + element => element.tabIndex >= 0 && !isDisabled(element) && isVisible(element) + ) + if (this._positionElement.contains(target) || this._element.contains(target)) { + if (!event.shiftKey && controls[0]) { + event.preventDefault() + controls[0].focus() + } else { + this.hide() + } + + return + } + + const boundary = event.shiftKey ? controls[0] : controls.at(-1) + if (target === boundary) { + this.hide() + if (this._isShown) { + event.preventDefault() + } else { + this._focusTrigger() + if (event.shiftKey) { + event.preventDefault() + } + } + } } protected _buildCalendarOptions(): Options { @@ -421,7 +789,7 @@ class Datepicker extends BaseComponent { // Vanilla Calendar Pro v3.4+ ships optional features as extensions. Bootstrap // supports only `months`, which `displayMonthsCount` needs. extensions: [months], - inputMode: !this._isInline, + inputMode: false, positionToInput: this._config.placement, firstWeekday: this._config.firstWeekday, locale: this._config.locale, @@ -432,25 +800,17 @@ class Datepicker extends BaseComponent { selectedTheme: vcpTheme, themeAttrDetect: '[data-bs-theme]', onClickDate: (self, event) => this._handleDateClick(self, event), - onInit: self => { - this._syncThemeAttribute(self.context.mainElement) - }, - onShow: () => { - if (!this._calendar) { - return - } - - this._isShown = true - this._syncThemeAttribute(this._calendar.context.mainElement) - }, - onHide: () => { - this._isShown = false - } + onInit: self => this._syncPopupState(self), + onUpdate: self => this._syncPopupState(self) } // Navigate to the month of the first selected date if (this._config.selectedDates.length > 0) { - const firstDate = this._parseDate(this._config.selectedDates[0]) + const firstDate = this._parseIsoDate(this._config.selectedDates[0]) + if (!firstDate) { + throw new TypeError('Invalid ISO date') + } + // `getMonth()` is always 0-11, which TypeScript cannot narrow on its own calendarOptions.selectedMonth = firstDate.getMonth() as Range<12> calendarOptions.selectedYear = firstDate.getFullYear() @@ -476,7 +836,9 @@ class Datepicker extends BaseComponent { event }) - this._maybeHideAfterSelection(selectedDates) + if (this._calendar && this._element) { + this._maybeHideAfterSelection(selectedDates) + } } protected _maybeHideAfterSelection(selectedDates: string[]): void { @@ -485,17 +847,38 @@ class Datepicker extends BaseComponent { } const shouldHide = - (this._config.selectionMode === 'single' && selectedDates.length > 0) || - (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) + (this._config.selectionMode === 'single' && selectedDates.length > 0) || (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2) if (shouldHide) { - setTimeout(() => this.hide(), HIDE_DELAY) + clearTimeout(this._hideTimeout ?? undefined) + this._hideTimeout = setTimeout(() => { + this._hideTimeout = null + this.hide() + }, HIDE_DELAY) } } - protected _parseDate(dateStr: string): Date { - const [year, month, day]: any[] = dateStr.split('-') - return new Date(year, month - 1, day) + protected _parseIsoDate(value: string): Date | null { + const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!parts) { + return null + } + + const year = Number(parts[1]) + const month = Number(parts[2]) - 1 + const day = Number(parts[3]) + const date = new Date(0) + date.setHours(0, 0, 0, 0) + date.setFullYear(year, month, day) + return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day ? date : null + } + + protected _parseDate(value: string): Date { + return this._parseIsoDate(value) ?? new Date(Number.NaN) + } + + protected _toIsoDate(date: Date): string { + return `${String(date.getFullYear()).padStart(4, '0')}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } protected _formatDate(dateStr: string): string { @@ -532,23 +915,35 @@ class Datepicker extends BaseComponent { } protected _parseInputValue(): void { - // Try to parse the input value as a date const value = this._element.value.trim() - if (!value) { - return + let date = this._parseIsoDate(value) + // Only recognize numeric strings that round-trip through our own formatter. + // Intl is not a general parser, and custom formatters need canonical input. + if (!date && !/^\d{4}-/.test(value) && typeof this._config.dateFormat !== 'function') { + const numbers = value.match(/\d+/g) + const locale = this._config.locale === 'default' ? undefined : this._config.locale + const order = new Intl.DateTimeFormat(locale, this._config.dateFormat ?? undefined) + .formatToParts(new Date(2000, 0, 1)) + .filter(part => ['year', 'month', 'day'].includes(part.type)) + if (numbers?.length === 3 && order.length === 3) { + const parts = Object.fromEntries(order.map((part, index) => [part.type, numbers[index]])) + if (parts.year.length === 4) { + const iso = `${parts.year}-${parts.month.padStart(2, '0')}-${parts.day.padStart(2, '0')}` + const candidate = this._parseIsoDate(iso) + if (candidate && this._formatDate(iso) === value) { + date = candidate + } + } + } } - const dateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) - const date = dateOnlyMatch ? - new Date(Number(dateOnlyMatch[1]), Number(dateOnlyMatch[2]) - 1, Number(dateOnlyMatch[3])) : - 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] }) + const dates = date ? [this._toIsoDate(date)] : [] + this._calendar?.set({ + selectedDates: dates, + ...(date ? { selectedMonth: date.getMonth() as Range<12>, selectedYear: date.getFullYear() } : {}) + }) + if (this._boundInput) { + this._boundInput.value = dates.join(',') } } } diff --git a/js/src/dialog-base.ts b/js/src/dialog-base.ts index 7b4b56023cd6..c312f3711fd2 100644 --- a/js/src/dialog-base.ts +++ b/js/src/dialog-base.ts @@ -267,11 +267,11 @@ class DialogBase extends BaseComponent { }, this._element) } - // Hide any tooltips, popovers, or toasts inside the dialog before closing. + // Hide child popups before closing the dialog. // These components append to the dialog (for top-layer rendering) and would // otherwise persist visibly after close(). protected _hideChildComponents(): void { - const selector = '[data-bs-toggle="tooltip"], [data-bs-toggle="popover"]' + const selector = '[data-bs-toggle="tooltip"], [data-bs-toggle="popover"], [data-bs-toggle="datepicker"]' for (const el of SelectorEngine.find(selector, this._element)) { const instance = Data.get(el, `bs.${el.getAttribute('data-bs-toggle')}`) diff --git a/js/tests/fixtures/datepicker-no-effects.scss b/js/tests/fixtures/datepicker-no-effects.scss new file mode 100644 index 000000000000..e7f951df9738 --- /dev/null +++ b/js/tests/fixtures/datepicker-no-effects.scss @@ -0,0 +1,4 @@ +@use "../../../scss/bootstrap" with ( + $enable-rounded: false, + $enable-shadows: false +); diff --git a/js/tests/fixtures/datepicker-weights.scss b/js/tests/fixtures/datepicker-weights.scss new file mode 100644 index 000000000000..45dce9927d65 --- /dev/null +++ b/js/tests/fixtures/datepicker-weights.scss @@ -0,0 +1,3 @@ +@use "../../../scss/bootstrap" with ( + $font-weights: ("normal": 450, "medium": 550, "semibold": 650) +); diff --git a/js/tests/unit/datepicker-contract.spec.js b/js/tests/unit/datepicker-contract.spec.js new file mode 100644 index 000000000000..1a187c06e70f --- /dev/null +++ b/js/tests/unit/datepicker-contract.spec.js @@ -0,0 +1,216 @@ +import { userEvent } from 'vitest/browser' +import { vi } from 'vitest' +import css from '../../../scss/bootstrap.scss?inline' +import noEffects from '../fixtures/datepicker-no-effects.scss?inline' +import weights from '../fixtures/datepicker-weights.scss?inline' +import Datepicker from '../../src/datepicker.js' + +describe('Datepicker rendered contract', () => { + let host + let style + let pickers + + const calendar = picker => picker._calendar.context.mainElement + const make = (config = {}, parent = host, tag = 'div') => { + const element = document.createElement(tag) + parent.append(element) + const picker = new Datepicker(element, { inline: tag === 'div', ...config }) + pickers.push(picker) + return picker + } + + const range = { selectionMode: 'multiple-ranged', selectedDates: ['2026-06-10', '2026-06-18'] } + const day = root => root.querySelector('[data-vc-date-selected] [data-vc-date-btn]') + + beforeEach(() => { + style = document.createElement('style') + style.textContent = css + document.head.append(style) + host = document.createElement('div') + document.body.append(host) + pickers = [] + }) + + afterEach(() => { + for (const picker of pickers) { + if (picker._element) { + picker.dispose() + } + } + + host.remove() + style.remove() + }) + + it('PG372: uses the surrounding theme for selected dates', () => { + host.className = 'theme-success' + const root = calendar(make({ selectedDates: ['2026-06-10'] })) + const button = document.createElement('button') + button.className = 'btn-solid' + host.append(button) + expect(getComputedStyle(day(root)).backgroundColor).toBe(getComputedStyle(button).backgroundColor) + }) + + it('PG185: scales the calendar text with its font-size token', () => { + const root = calendar(make()) + const cell = root.querySelector('[data-vc-date-btn]') + const before = Number.parseFloat(getComputedStyle(cell).fontSize) + root.style.setProperty('--datepicker-font-size', '2rem') + expect(Number.parseFloat(getComputedStyle(cell).fontSize)).toBeGreaterThan(before) + }) + + it('PG186: reads configured font weights', () => { + style.textContent = weights + const root = calendar(make()) + expect(getComputedStyle(root.querySelector('[data-vc="month"]')).fontWeight).toBe('650') + expect(getComputedStyle(root.querySelector('[data-vc-date]:not([data-vc-date-today]) [data-vc-date-btn]')).fontWeight).toBe('450') + }) + + it('PG181: inherits a dark wrapper without a theme attribute', () => { + host.style.colorScheme = 'dark' + const root = calendar(make()) + expect(getComputedStyle(root).colorScheme).toBe('dark') + }) + + it('PG181: follows a theme ancestor added after initialization', async () => { + const root = calendar(make()) + host.setAttribute('data-bs-theme', 'dark') + await vi.waitFor(() => expect(getComputedStyle(root).colorScheme).toBe('dark')) + }) + + it('PG174: removes day and range rounding when rounding is disabled', () => { + style.textContent = noEffects + const root = calendar(make(range)) + const radii = [...root.querySelectorAll('[data-vc-date-btn]')].map(element => getComputedStyle(element).borderRadius) + expect(new Set(radii)).toEqual(new Set(['0px'])) + }) + + it('PG173: removes the popup shadow when shadows are disabled', async () => { + style.textContent = noEffects + const picker = make({}, host, 'input') + picker._element.click() + await picker.show() + await vi.waitFor(() => expect(calendar(picker)).not.toBe(picker._element)) + expect(getComputedStyle(calendar(picker)).boxShadow).toBe('none') + }) + + it('PG139: mirrors the arrows and selected range corners in RTL', () => { + const left = calendar(make(range)) + const rtl = document.createElement('div') + rtl.dir = 'rtl' + host.append(rtl) + const right = calendar(make(range, rtl)) + const rotation = (root, direction) => getComputedStyle(root.querySelector(`[data-vc-arrow="${direction}"]`), '::before').transform + expect(rotation(right, 'prev')).toBe(rotation(left, 'next')) + const first = right.querySelector('[data-vc-date-selected="first"] [data-vc-date-btn]') + expect(getComputedStyle(first).borderTopLeftRadius).toBe('0px') + expect(Number.parseFloat(getComputedStyle(first).borderTopRightRadius)).toBeGreaterThan(0) + }) + + // Resolve each paint layer before applying the button's group opacity. Text + // and the button background are composited together, not faded twice. + const contrast = element => { + const context = document.createElement('canvas').getContext('2d') + context.canvas.width = 1 + context.canvas.height = 1 + const color = value => { + context.clearRect(0, 0, 1, 1) + context.fillStyle = value + context.fillRect(0, 0, 1, 1) + return [...context.getImageData(0, 0, 1, 1).data] + } + + const blend = (front, back, alpha = front[3] / 255) => front.slice(0, 3).map((value, i) => { + const frontPart = value * alpha + const backPart = back[i] * (1 - alpha) + return frontPart + backPart + }) + const ancestors = [] + for (let node = element.parentElement; node; node = node.parentElement) { + ancestors.push(node) + } + + let background = [255, 255, 255] + for (const node of ancestors.toReversed()) { + background = blend(color(getComputedStyle(node).backgroundColor), background) + } + + const computed = getComputedStyle(element) + const surface = blend(color(computed.backgroundColor), background) + const foreground = blend(color(computed.color), surface) + const opacity = Number(computed.opacity) + const painted = [blend([...foreground, 255], background, opacity), blend([...surface, 255], background, opacity)] + const luminance = channels => { + let sum = 0 + for (const [i, value] of channels.entries()) { + const unit = value / 255 + sum += [0.2126, 0.7152, 0.0722][i] * (unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4) + } + + return sum + } + + const values = painted.map(luminance).sort((a, b) => b - a) + return (values[0] + 0.05) / (values[1] + 0.05) + } + + for (const theme of ['light', 'dark']) { + for (const [part, selector] of [ + ['outside-month', '[data-vc-date-month="prev"] [data-vc-date-btn], [data-vc-date-month="next"] [data-vc-date-btn]'], + ['middle-range', '[data-vc-date-selected="middle"] [data-vc-date-btn]'] + ]) { + it(`PG265/208: keeps enabled ${part} days readable in ${theme}`, () => { + const root = calendar(make({ ...range, datepickerTheme: theme })) + expect(contrast(root.querySelector(selector))).toBeGreaterThanOrEqual(4.5) + }) + } + } + + it('PG324: preserves the original node, value, and listeners on disposal', () => { + const picker = make({}, host, 'input') + const input = picker._element + input.value = 'Application value' + const listener = vi.fn() + input.addEventListener('input', listener) + picker.dispose() + expect(input.isConnected).toBe(true) + expect(input.value).toBe('Application value') + input.dispatchEvent(new Event('input')) + expect(listener).toHaveBeenCalledOnce() + }) + + it('PG155: emits hide events on Escape and show events on a later click', async () => { + const picker = make({}, host, 'input') + const input = picker._element + input.dataset.bsToggle = 'datepicker' + const events = [] + for (const event of ['show', 'shown', 'hide', 'hidden']) { + input.addEventListener(`${event}.bs.datepicker`, () => events.push(event)) + } + + await userEvent.click(input) + await vi.waitFor(() => expect(calendar(picker).matches('[data-vc="calendar"]')).toBe(true)) + await userEvent.keyboard('{Escape}') + await userEvent.click(input) + expect(events).toEqual(['show', 'shown', 'hide', 'hidden', 'show', 'shown']) + }) + + it('PG356: continues to the field after the popup on Tab', async () => { + const picker = make({}, host, 'input') + const input = picker._element + input.dataset.bsToggle = 'datepicker' + const next = document.createElement('input') + next.setAttribute('aria-label', 'After date') + host.append(next) + await userEvent.click(input) + await vi.waitFor(() => expect(calendar(picker).matches('[data-vc="calendar"]')).toBe(true)) + for (let press = 0; press < 8 && document.activeElement !== next; press++) { + // Each Tab depends on the previous focus move. + // eslint-disable-next-line no-await-in-loop + await userEvent.tab() + } + + expect(document.activeElement).toBe(next) + expect(picker._isShown).toBe(false) + }) +}) diff --git a/js/tests/unit/datepicker-interaction.spec.js b/js/tests/unit/datepicker-interaction.spec.js new file mode 100644 index 000000000000..6e36d6e2d73d --- /dev/null +++ b/js/tests/unit/datepicker-interaction.spec.js @@ -0,0 +1,362 @@ +import { userEvent } from 'vitest/browser' +import { vi } from 'vitest' +import css from '../../../scss/bootstrap.scss?inline' +import Datepicker from '../../src/datepicker.js' +import Dialog from '../../src/dialog.js' + +describe('Datepicker browser interactions', () => { + let host + let style + let pickers + const make = (html = '', config = {}) => { + host.innerHTML = html + const input = host.querySelector('input:not([type="hidden"])') + const picker = new Datepicker(input, { locale: 'en-GB', ...config }) + pickers.push(picker) + return { + input, picker, form: host.querySelector('form'), popup: picker._calendar.context.mainElement + } + } + + const formHtml = '
' + + beforeEach(() => { + host = document.createElement('div') + document.body.append(host) + style = document.createElement('style') + style.textContent = css + document.head.append(style) + pickers = [] + }) + + afterEach(() => { + for (const picker of pickers) { + if (picker._element) { + picker.dispose() + } + } + + host.remove() + style.remove() + }) + + it('submits ISO dates with duplicate names before and after focus', async () => { + const { picker, input, form } = make(formHtml) + expect(input.value).toBe('05/10/2026') + expect(new FormData(form).getAll('date')).toEqual(['2026-10-05', 'other']) + await picker.show() + expect(new FormData(form).getAll('date')).toEqual(['2026-10-05', 'other']) + picker.setSelectedDates(['2026-12-06']) + expect(input.value).toBe('06/12/2026') + expect(new FormData(form).getAll('date')).toEqual(['2026-12-06', 'other']) + }) + + it('serializes before existing application formdata listeners', () => { + host.innerHTML = formHtml + const form = host.querySelector('form') + let observed + form.addEventListener('formdata', event => { + observed = [...event.formData] + }) + const picker = new Datepicker(host.querySelector('input'), { locale: 'en-GB' }) + pickers.push(picker) + expect(new FormData(form).get('date')).toBe('2026-10-05') + expect(observed).toEqual([['date', '2026-10-05'], ['date', 'other']]) + }) + + it('omits disabled inputs immediately and after enabling', () => { + const { input, form } = make(formHtml) + input.disabled = true + expect(new FormData(form).getAll('date')).toEqual(['other']) + input.disabled = false + expect(new FormData(form).getAll('date')).toEqual(['2026-10-05', 'other']) + }) + + it('uses native disabled fieldset and external form rules', () => { + const { form, input } = make('
') + expect(new FormData(form).has('date')).toBe(false) + input.closest('fieldset').disabled = false + expect(new FormData(form).get('date')).toBe('2026-10-05') + }) + + it('resets selection and submission through a native reset button', async () => { + const { picker, input, form } = make(formHtml) + picker.setSelectedDates(['2026-12-06']) + await userEvent.click(form.querySelector('button')) + await vi.waitFor(() => expect(picker.getSelectedDates()).toEqual(['2026-10-05'])) + expect(input.value).toBe('05/10/2026') + expect(new FormData(form).getAll('date')).toEqual(['2026-10-05', 'other']) + }) + + it('honors a canceled reset', async () => { + const { picker, form } = make(formHtml) + picker.setSelectedDates(['2026-12-06']) + form.addEventListener('reset', event => event.preventDefault()) + await userEvent.click(form.querySelector('button')) + await new Promise(resolve => { + setTimeout(resolve, 10) + }) + expect(picker.getSelectedDates()).toEqual(['2026-12-06']) + }) + + it('supports ISO and its own numeric output, and clears invalid manual dates', () => { + const { picker, input, form } = make(formHtml) + for (const [value, expected] of [['06/12/2026', '2026-12-06'], ['2026-06-12', '2026-06-12'], ['2026-02-31', ''], ['', '']]) { + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + expect(picker.getSelectedDates()).toEqual(expected ? [expected] : []) + expect(new FormData(form).getAll('date')).toEqual([expected, 'other']) + expect(input.value).toBe(value) + } + }) + + it('does not normalize an impossible date through the public setter', () => { + const { picker } = make() + expect(() => picker.setSelectedDates(['2026-02-31'])).toThrow(TypeError) + expect(picker.getSelectedDates()).toEqual(['2026-10-05']) + }) + + it('preserves ordered date entries alongside file fields', () => { + const { picker, form } = make('
') + const file = new File(['contents'], 'date.txt', { type: 'text/plain' }) + const transfer = new DataTransfer() + transfer.items.add(file) + form.querySelector('[type="file"]').files = transfer.files + const values = [...new FormData(form)] + expect(values.map(([name]) => name)).toEqual(['date', 'upload', 'date']) + expect(values[1][1].name).toBe('date.txt') + expect(values[0][1]).toBe(picker.getSelectedDates()[0]) + }) + + it('submits multiple and range selections as ISO lists', () => { + const { picker, form } = make('
', { selectionMode: 'multiple' }) + picker.setSelectedDates(['2026-06-10', '2026-06-18']) + expect(new FormData(form).get('date')).toBe(picker.getSelectedDates().join(',')) + }) + + it('keeps formatter HTML as literal text', () => { + const { input } = make(undefined, { dateFormat: () => 'date' }) + expect(input.value).toBe('date') + expect(host.querySelector('b')).toBeNull() + }) + + it('keeps the submitted name and live node after disposal', () => { + const { picker, input, form } = make(formHtml) + picker.dispose() + expect(document.querySelector('[data-vc="calendar"]')).toBeNull() + expect(host.querySelector('input')).toBe(input) + expect(input.name).toBe('date') + expect(form.querySelector('input[type="hidden"]')).toBeNull() + expect(new FormData(form).getAll('date')).toEqual([input.value, 'other']) + }) + + it('preserves an authored inline hidden input and wrapper content', () => { + host.innerHTML = '
Calendar label
' + const wrapper = host.firstElementChild + const hidden = wrapper.querySelector('input') + const picker = new Datepicker(wrapper, { inline: true, selectedDates: ['2026-06-10'] }) + pickers.push(picker) + expect(hidden.value).toBe('2026-06-10') + picker.dispose() + expect(wrapper.querySelector('input')).toBe(hidden) + expect(wrapper.querySelector('span').textContent).toBe('Calendar label') + }) + + it('restores an inline form selection after native reset', async () => { + host.innerHTML = '
' + const picker = new Datepicker(host.querySelector('div'), { inline: true, selectedDates: ['2026-06-10'] }) + pickers.push(picker) + picker.setSelectedDates(['2026-06-18']) + await userEvent.click(host.querySelector('button[type="reset"]')) + await vi.waitFor(() => expect(picker.getSelectedDates()).toEqual(['2026-06-10'])) + expect(new FormData(host.querySelector('form')).get('date')).toBe('2026-06-10') + }) + + it('opens a prefilled field on first keyboard focus with a visible calendar', async () => { + const { input, picker, popup } = make('') + await userEvent.tab() + expect(document.activeElement).toBe(input) + await vi.waitFor(() => expect(picker._isShown).toBe(true)) + expect(popup.getBoundingClientRect().width).toBeGreaterThan(0) + expect(popup.querySelector('[data-vc-date="2026-10-05"]')).not.toBeNull() + await userEvent.tab() + expect(popup.contains(document.activeElement)).toBe(true) + await userEvent.tab({ shift: true }) + expect(document.activeElement).toBe(input) + expect(picker._isShown).toBe(false) + }) + + it('opens a programmatically initialized input on keyboard focus without a data toggle', async () => { + const { picker, input } = make('') + await userEvent.tab() + expect(document.activeElement).toBe(input) + await vi.waitFor(() => expect(picker._isShown).toBe(true)) + }) + + it('keeps keyboard, dismissal, and inherited themes working inside an open shadow root', async () => { + host.innerHTML = '
' + const root = host.querySelector('div').attachShadow({ mode: 'open' }) + const sheet = new CSSStyleSheet() + sheet.replaceSync(css) + root.adoptedStyleSheets = [sheet] + root.innerHTML = '' + const input = root.querySelector('input') + const picker = new Datepicker(input) + pickers.push(picker) + const popup = picker._calendar.context.mainElement + await userEvent.click(input) + expect(picker._isShown).toBe(true) + expect(popup.getRootNode()).toBe(root) + await userEvent.tab() + expect(popup.contains(root.activeElement)).toBe(true) + await userEvent.keyboard('{Escape}') + expect(picker._isShown).toBe(false) + expect(root.activeElement).toBe(input) + await userEvent.click(input) + host.querySelector('div').style.colorScheme = 'dark' + await vi.waitFor(() => expect(getComputedStyle(popup).colorScheme).toBe('dark')) + await userEvent.click(host.querySelector('input')) + expect(picker._isShown).toBe(false) + }) + + it('keeps wrapper direction and theme when mounted outside the wrapper', async () => { + const { picker, popup } = make('
') + await picker.show() + expect(popup.dir).toBe('rtl') + expect(popup.style.getPropertyValue('--theme-bg')).not.toBe('') + host.querySelector('[dir]').dir = 'ltr' + await vi.waitFor(() => expect(popup.dir).toBe('ltr')) + }) + + it('keeps adorn clicks inside the popup interaction boundary', async () => { + const { picker, input } = make('
Calendar icon
') + const hidden = vi.fn() + input.addEventListener('hidden.bs.datepicker', hidden) + await userEvent.click(input) + await userEvent.click(host.querySelector('span')) + expect(picker._isShown).toBe(true) + expect(hidden).not.toHaveBeenCalled() + await userEvent.click(host.querySelector('button')) + expect(picker._isShown).toBe(false) + expect(hidden).toHaveBeenCalledOnce() + }) + + it('keeps a closed popup out of keyboard navigation', () => { + const { popup } = make() + expect(popup.inert).toBe(true) + expect(getComputedStyle(popup).display).toBe('none') + }) + + it('honors show/hide cancellation and ignores recursive lifecycle calls', async () => { + const { picker, input } = make() + const cancel = event => event.preventDefault() + input.addEventListener('show.bs.datepicker', cancel) + await picker.show() + expect(picker._isShown).toBe(false) + input.removeEventListener('show.bs.datepicker', cancel) + input.addEventListener('show.bs.datepicker', () => picker.show()) + await picker.show() + input.addEventListener('hide.bs.datepicker', cancel) + await picker.hide() + expect(picker._isShown).toBe(true) + input.removeEventListener('hide.bs.datepicker', cancel) + input.addEventListener('hide.bs.datepicker', () => picker.hide()) + await picker.hide() + expect(picker._isShown).toBe(false) + }) + + it('does not emit shown after an interrupted show', async () => { + const { picker, input } = make() + const shown = vi.fn() + input.addEventListener('shown.bs.datepicker', shown) + const opening = picker.show() + await picker.hide() + await opening + expect(shown).not.toHaveBeenCalled() + }) + + it('cancels selection timers and handles disposal in a change callback', async () => { + const { picker, input } = make() + await picker.show() + picker._handleDateClick(picker._calendar, new MouseEvent('click')) + expect(picker._hideTimeout).not.toBeNull() + input.addEventListener('change.bs.datepicker', () => picker.dispose()) + picker._handleDateClick(picker._calendar, new MouseEvent('click')) + await new Promise(resolve => { + setTimeout(resolve, 150) + }) + expect(input.isConnected).toBe(true) + expect(Datepicker.getInstance(input)).toBeNull() + }) + + it('restores application ARIA attributes after disposal', () => { + const { picker, input } = make('') + picker.dispose() + expect(input.getAttribute('aria-haspopup')).toBe('grid') + expect(input.getAttribute('aria-expanded')).toBe('false') + }) + + for (const placement of ['left', 'right']) { + it(`preserves physical ${placement} alignment in an RTL wrapper`, async () => { + host.style.cssText = 'position:absolute;left:24px;top:80px;width:280px' + const { picker, input, popup } = make('
', { placement }) + await picker.show() + expect(Math.abs(popup.getBoundingClientRect()[placement] - input.getBoundingClientRect()[placement])).toBeLessThan(2) + }) + } + + it('tracks a scrolling anchor without clipping the popup', async () => { + const { picker, input, popup } = make('
') + await picker.show() + const before = popup.getBoundingClientRect().top + input.parentElement.parentElement.scrollTop = 40 + await vi.waitFor(() => expect(popup.getBoundingClientRect().top).not.toBe(before)) + expect(Math.abs(popup.getBoundingClientRect().top - input.getBoundingClientRect().bottom - 4)).toBeLessThan(2) + }) + + it('closes on a native dialog close and can reopen after it', async () => { + const { picker, popup } = make('') + const dialog = host.querySelector('dialog') + dialog.showModal() + await picker.show() + dialog.close() + await vi.waitFor(() => expect(picker._isShown).toBe(false)) + expect(popup.inert).toBe(true) + dialog.showModal() + await picker.show() + expect(popup.matches(':popover-open')).toBe(true) + dialog.close() + }) + + it('closes only the popup on Escape inside a non-modal dialog', async () => { + const { picker, input } = make('') + const element = host.querySelector('dialog') + const dialog = new Dialog(element, { modal: false }) + await dialog.show() + await userEvent.click(input) + await userEvent.keyboard('{Escape}') + expect(picker._isShown).toBe(false) + expect(element.open).toBe(true) + await dialog.hide() + dialog.dispose() + }) + + it('keeps the popup above a clipped modal and closes it with the dialog', async () => { + const { picker, popup } = make('') + const element = host.querySelector('dialog') + const dialog = new Dialog(element) + await dialog.show() + await picker.show() + const rect = popup.getBoundingClientRect() + expect(popup.closest('dialog')).toBe(element) + expect(rect.width).toBeLessThan(360) + expect(rect.width).toBeGreaterThan(200) + const x = rect.left + (rect.width / 2) + const y = rect.top + (rect.height / 2) + expect(popup.contains(document.elementFromPoint(x, y))).toBe(true) + await dialog.hide() + expect(picker._isShown).toBe(false) + expect(popup.inert).toBe(true) + dialog.dispose() + }) +}) diff --git a/js/tests/unit/datepicker.spec.js b/js/tests/unit/datepicker.spec.js index c38c07f50fa2..8e6da27fbd2d 100644 --- a/js/tests/unit/datepicker.spec.js +++ b/js/tests/unit/datepicker.spec.js @@ -856,7 +856,7 @@ describe('Datepicker', () => { it('should not mark inline calendars with data-vc-input', () => { const divEl = createInlineCalendar() - expect(divEl.getAttribute('data-vc')).toEqual('calendar') + expect(divEl.querySelector('[data-vc="calendar"]')).not.toBeNull() expect(divEl.hasAttribute('data-vc-input')).toBeFalse() }) @@ -1131,12 +1131,12 @@ describe('Datepicker', () => { expect(datepicker._isShown).toBeFalse() }) - it('should not register a trigger listener for input triggers', () => { + it('should register an input click listener for reopening', () => { fixtureEl.innerHTML = '' const datepicker = new Datepicker(fixtureEl.querySelector('input')) - expect(datepicker._onTriggerClick).toBeUndefined() + expect(datepicker._onTriggerClick).toEqual(jasmine.any(Function)) }) it('should remove the trigger listener on dispose', () => { diff --git a/scss/_datepicker.scss b/scss/_datepicker.scss index f1539f399ab5..3b2471665283 100644 --- a/scss/_datepicker.scss +++ b/scss/_datepicker.scss @@ -3,6 +3,7 @@ @use "functions" as *; @use "config" as *; @use "mixins/border-radius" as *; +@use "mixins/box-shadow" as *; @use "mixins/focus-ring" as *; @use "mixins/mask-icon" as *; @use "mixins/tokens" as *; @@ -23,11 +24,11 @@ $datepicker-tokens: defaults( --datepicker-font-size: var(--font-size-sm), --datepicker-min-width: 280px, --datepicker-zindex: var(--z-menu), - --datepicker-header-font-weight: 600, + --datepicker-header-font-weight: var(--font-weight-semibold), --datepicker-weekday-color: var(--fg-3), --datepicker-day-hover-bg: var(--bg-1), - --datepicker-day-selected-bg: var(--primary-bg), - --datepicker-day-selected-color: var(--primary-contrast), + --datepicker-day-selected-bg: var(--theme-bg, var(--primary-bg)), + --datepicker-day-selected-color: var(--theme-contrast, var(--primary-contrast)), --datepicker-day-today-bg: var(--bg-2), --datepicker-day-today-color: var(--fg-1), --datepicker-day-disabled-color: var(--fg-4), @@ -41,20 +42,22 @@ $datepicker-tokens: defaults( @include tokens($datepicker-tokens); position: absolute; + inset: auto; z-index: var(--datepicker-zindex); box-sizing: border-box; display: flex; flex-direction: column; min-width: var(--datepicker-min-width); padding: var(--datepicker-padding); + margin: 0; font-family: var(--body-font-family); font-size: var(--datepicker-font-size); color: var(--datepicker-color); - color-scheme: light dark; + color-scheme: inherit; background-color: var(--datepicker-bg); border: var(--datepicker-border-width) solid var(--datepicker-border-color); - box-shadow: var(--datepicker-box-shadow); opacity: 1; + @include box-shadow(var(--datepicker-box-shadow)); @include border-radius(var(--datepicker-border-radius)); // Respond to Bootstrap's color mode system @@ -75,8 +78,7 @@ $datepicker-tokens: defaults( } [data-vc-calendar-hidden] { - pointer-events: none; - opacity: 0; + display: none; } // Inline calendars @@ -90,12 +92,12 @@ $datepicker-tokens: defaults( box-shadow: none; } - [data-vc-position="bottom"] { - margin-block-start: .25rem; + [data-vc="calendar"][data-vc-position="bottom"] { + margin-block-start: 0; } - [data-vc-position="top"] { - margin-block-end: -.25rem; + [data-vc="calendar"][data-vc-position="top"] { + margin-block-end: 0; } [data-vc-arrow] { @@ -131,6 +133,14 @@ $datepicker-tokens: defaults( transform: rotate(-90deg); } + [data-vc-arrow="prev"]:dir(rtl)::before { + transform: rotate(-90deg); + } + + [data-vc-arrow="next"]:dir(rtl)::before { + transform: rotate(90deg); + } + // Grid layout [data-vc="controls"] { position: absolute; @@ -185,7 +195,7 @@ $datepicker-tokens: defaults( [data-vc="year"] { padding: .25rem .5rem; margin-inline: -.125rem; - font-size: 1rem; + font-size: calc(var(--datepicker-font-size) * 8 / 7); font-weight: var(--datepicker-header-font-weight); color: var(--datepicker-color); // cursor: pointer; @@ -246,9 +256,9 @@ $datepicker-tokens: defaults( width: 100%; height: 2.5rem; padding: .25rem; - font-size: .75rem; - font-weight: 600; - line-height: 1rem; + font-size: calc(var(--datepicker-font-size) * 6 / 7); + font-weight: var(--font-weight-semibold); + line-height: 1.333333; color: var(--datepicker-weekday-color); text-align: center; word-break: break-all; @@ -294,9 +304,9 @@ $datepicker-tokens: defaults( min-width: 1.875rem; padding: 0; margin: 0; - font-size: .75rem; - font-weight: 600; - line-height: 1rem; + font-size: calc(var(--datepicker-font-size) * 6 / 7); + font-weight: var(--font-weight-semibold); + line-height: 1.333333; color: var(--datepicker-weekday-color); background-color: transparent; border: 0; @@ -346,14 +356,14 @@ $datepicker-tokens: defaults( height: 100%; min-height: 1.875rem; padding: 0; - font-size: .75rem; - font-weight: 400; - line-height: 1rem; + font-size: calc(var(--datepicker-font-size) * 6 / 7); + font-weight: var(--font-weight-normal); + line-height: 1.333333; color: var(--datepicker-color); cursor: pointer; background-color: transparent; border: 0; - border-radius: var(--radius-5); + @include border-radius(var(--radius-5), 0); &:hover { background-color: var(--datepicker-day-hover-bg); @@ -362,7 +372,7 @@ $datepicker-tokens: defaults( // Today [data-vc-date-today] [data-vc-date-btn] { - font-weight: 600; + font-weight: var(--font-weight-semibold); color: var(--datepicker-day-today-color); background-color: var(--datepicker-day-today-bg); } @@ -370,7 +380,7 @@ $datepicker-tokens: defaults( // Outside month [data-vc-date-month="next"] [data-vc-date-btn], [data-vc-date-month="prev"] [data-vc-date-btn] { - opacity: .5; + color: var(--fg-2); } // Disabled @@ -385,22 +395,15 @@ $datepicker-tokens: defaults( } [data-vc-date-hover="first"] [data-vc-date-btn] { - border-start-start-radius: var(--radius-5); - border-end-start-radius: var(--radius-5); + @include border-start-radius(var(--radius-5)); } [data-vc-date-hover="last"] [data-vc-date-btn] { - border-start-end-radius: var(--radius-5); - border-end-end-radius: var(--radius-5); + @include border-end-radius(var(--radius-5)); } [data-vc-date-hover="first-and-last"] [data-vc-date-btn] { - border-radius: var(--radius-5); - } - - [data-vc-date-selected="middle"] [data-vc-date-btn] { - border-radius: 0; - opacity: .8; + @include border-radius(var(--radius-5), 0); } // Selected @@ -410,21 +413,23 @@ $datepicker-tokens: defaults( } + [data-vc-date-selected="middle"] [data-vc-date-btn] { + color: var(--theme-fg-emphasis, var(--primary-fg-emphasis)); + background-color: var(--theme-bg-subtle, var(--primary-bg-subtle)); + border-radius: 0; + } + [data-vc-date-selected="first"] [data-vc-date-btn] { - border-top-left-radius: var(--radius-5); - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-bottom-left-radius: var(--radius-5); + border-radius: 0; + @include border-start-radius(var(--radius-5)); } [data-vc-date-selected="last"] [data-vc-date-btn] { - border-top-left-radius: 0; - border-top-right-radius: var(--radius-5); - border-bottom-right-radius: var(--radius-5); - border-bottom-left-radius: 0; + border-radius: 0; + @include border-end-radius(var(--radius-5)); } [data-vc-date-selected="first-and-last"] [data-vc-date-btn] { - border-radius: var(--radius-5); + @include border-radius(var(--radius-5), 0); } } diff --git a/site/src/content/docs/forms/datepicker.mdx b/site/src/content/docs/forms/datepicker.mdx index 950eb719c6d3..178bc1e89058 100644 --- a/site/src/content/docs/forms/datepicker.mdx +++ b/site/src/content/docs/forms/datepicker.mdx @@ -5,13 +5,17 @@ toc: true css_layer: components js: required deps: + - title: Floating UI + url: https://floating-ui.com/ - title: Vanilla Calendar Pro url: https://vanilla-calendar.pro/ --- ## 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. +Use a native `` for a single date when it meets your needs. The browser handles its calendar, keyboard interaction, and ISO date value. + +Bootstrap Datepicker uses [Vanilla Calendar Pro](https://vanilla-calendar.pro/) for custom calendars, including inline, multiple-date, and range selection. Bootstrap handles popup positioning, focus, and events. Datepicker `} /> @@ -26,10 +30,29 @@ Note that we’re using a width utility of `.w-12` to ensure the input is wide e - Selecting a date updates the input value and closes the picker - The picker also closes on a click outside it, on Escape, or when focus moves to an element outside it - 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 +- Use `vcpOptions` for supported [Vanilla Calendar Pro options](https://vanilla-calendar.pro/docs/reference/settings). Bootstrap controls popup behavior and callbacks. ## Examples +### Form submission + +Give the text input a `name` to submit selected dates in `YYYY-MM-DD` format. The visible value uses your locale. Multiple dates and ranges submit comma-separated ISO dates. An empty selection submits an empty string. + + + + + + +`} /> + +On initialization, Bootstrap moves the input’s name to an internal form binding. Form submission and `new FormData(form)` use that name with the ISO value. Disabled fields are omitted. A form reset restores the initial selection. `dispose()` removes the binding and restores the original input and its name. The disposed input then submits its visible value. + +Use `getSelectedDates()` to read selected dates in application code. Do not query the generated hidden input. Dispose and initialize the component again if you move an initialized input between forms. Initialize Datepicker explicitly if you need this binding before the first focus. Prefilled fields are not all initialized on page load. + +Use ISO dates for initial values. Manual input accepts valid ISO dates. It also accepts numeric Gregorian dates with ASCII digits and a four-digit year that exactly match the component’s own locale format. It does not parse arbitrary date phrases, month names, custom formatter output, or multiple-date text. Invalid manual input clears the selection and submitted value. Use the calendar or `setSelectedDates()` for multiple dates and ranges. + +Always validate submitted dates and application rules on your server. Client-side limits do not protect server endpoints. + ### With form field Use the [form field]([[docsref:/forms/field]]) component to layout and wrap your inputs with a label, description, and validation feedback messages in a vertical stack. @@ -195,6 +218,8 @@ const datepicker = new bootstrap.Datepicker(element, { }) ``` +Formatter results are inserted as text, including strings that contain HTML. Formatting changes the display, not the submitted ISO dates. + ## 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. @@ -233,6 +258,12 @@ root.innerHTML = '' new bootstrap.Datepicker(root.getElementById('shadowDatepicker')) ``` +## Accessibility + +Give each input an associated label. When the popup opens, Tab enters its controls and continues to the next field after the last control. Shift+Tab from the first control returns to the input. Escape closes the popup and returns focus to the input. Hidden calendars do not receive keyboard focus. + +The popup uses the browser’s top layer inside dialogs. Bootstrap positions it with Floating UI and updates its position during scrolling. + ## CSS ### Variables @@ -250,6 +281,19 @@ Add `data-bs-toggle="datepicker"` to any input element to initialize it as a dat | --- | --- | | `data-bs-toggle="datepicker"` | Initializes the datepicker on the input (or button) element. | | `data-bs-inline` | When `true`, renders the calendar inline instead of a popup. | +| `data-bs-selected-dates` | JSON array of ISO dates. Initializes the field on page load. | +| `data-bs-selection-mode` | `single`, `multiple`, or `multiple-ranged`. | +| `data-bs-display-months-count` | Number of displayed months. | +| `data-bs-first-weekday` | First weekday, from `0` (Sunday) to `6` (Saturday). | +| `data-bs-date-min` | Minimum date in `YYYY-MM-DD` format. | +| `data-bs-date-max` | Maximum date in `YYYY-MM-DD` format. | +| `data-bs-locale` | Locale used for displayed dates. | +| `data-bs-date-format` | JSON object of `Intl.DateTimeFormat` options. Functions require JavaScript. | +| `data-bs-display-element` | Display target selector, or `false` to disable it. | +| `data-bs-position-element` | Position target selector. | +| `data-bs-placement` | `left`, `center`, `right`, or `auto`. | +| `data-bs-datepicker-theme` | `light`, `dark`, or `auto`. Omit it to inherit. | +| `data-bs-vcp-options` | JSON object of supported Vanilla Calendar Pro settings. | ```html @@ -290,12 +334,12 @@ const datepicker = new bootstrap.Datepicker(datepickerEl, { | `selectionMode` | string | `'single'` | Selection mode: `'single'`, `'multiple'`, or `'multiple-ranged'` | | `placement` | string | `'left'` | Calendar position relative to input: `'left'`, `'center'`, `'right'`, `'auto'` | | `datepickerTheme` | string | `null` | Force datepicker popup theme: `'light'`, `'dark'`, `'auto'`, or `null` to inherit from ancestor `[data-bs-theme]` | -| `vcpOptions` | object | `{}` | Pass-through object for any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings) | +| `vcpOptions` | object | `{}` | Pass-through object for supported [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 supported settings not exposed by Bootstrap’s options, use `vcpOptions`: ```js const datepicker = new bootstrap.Datepicker(element, { @@ -308,7 +352,9 @@ 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 available settings. Bootstrap overrides input mode, placement, themes, extensions, and its initialization, update, and date-click callbacks to keep its public behavior consistent. + +Vanilla Calendar Pro templates, including `layouts`, contain trusted HTML. Do not populate them with untrusted content or user-supplied JSON. This also applies to templates inside `data-bs-vcp-options`. Bootstrap does not sanitize these templates. Use formatter functions for plain text. Vanilla Calendar Pro ships its optional features as extensions. Bootstrap uses only the `months` extension, which `displayMonthsCount` needs. Options from other extensions are not supported, and Vanilla Calendar Pro throws an error when you set them. These include `animation`, `enableSwipe`, `selectionTimeMode`, `popups`, and the week view options. diff --git a/site/src/content/docs/guides/migration.mdx b/site/src/content/docs/guides/migration.mdx index 5ab6a1a5db54..9267b79b94c4 100644 --- a/site/src/content/docs/guides/migration.mdx +++ b/site/src/content/docs/guides/migration.mdx @@ -263,6 +263,7 @@ The prefix syntax does not identify the query type. Utilities, grid columns, con - **Added new Combobox component.** A searchable select built on top of Menu, with single and multi-select support. See the [Combobox docs]([[docsref:/forms/combobox]]). - Replaced Popper.js (`@popperjs/core`) with [Floating UI](https://floating-ui.com/) (`@floating-ui/dom`) for menu, tooltip, and popover positioning. The `popperConfig` option on Tooltip, Popover, and Menu (formerly Dropdown) has been renamed to `floatingConfig`. Update any custom positioning configuration accordingly. - Added [Vanilla Calendar Pro](https://vanilla-calendar.pro/) (`vanilla-calendar-pro`) as a peer dependency for the new Datepicker component. Popup datepickers close on outside interaction, on Escape, or when focus leaves the input and calendar. +- Initialized named Datepicker inputs submit ISO dates through an internal form binding. The visible text remains localized. Use `getSelectedDates()` to read dates. Disposal restores the input’s name and removes the binding. Bootstrap owns popup focus and positioning; `vcpOptions` cannot override its lifecycle callbacks. - Removed the `jspm` configuration from `package.json`. - Added `"sideEffects"` metadata to `package.json` to enable tree shaking in bundlers while preserving the Data API event listeners that Bootstrap’s plugins register at the top level. - Added `"exports"` map to `package.json` for explicit subpath access to source, dist, and Sass files. From e584e568aebc31dcb4f2f6e78dbd1ba2e5a67964 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 6 Nov 2026 21:57:44 -0800 Subject: [PATCH 2/2] chore(bundlewatch): account for Datepicker fixes The Datepicker changes increase three JavaScript bundles beyond their gzip budgets. Raise those limits to the next quarter-kilobyte boundary above each measured size. --- .bundlewatch.config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 3d52a9ba24fe..e4f3deb2e32a 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,11 +34,11 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "75.25 kB" + "maxSize": "78.0 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "59.25 kB" + "maxSize": "61.5 kB" }, { "path": "./dist/js/bootstrap.js", @@ -46,7 +46,7 @@ }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "35.5 kB" + "maxSize": "37.75 kB" } ], "ci": {