Fix Datepicker popup snapping back on resize and an unformatted initial value (#3230)

This commit is contained in:
Bartosz-Do authored and GitHub committed 2026-09-28 22:27:58 +02:00
1 parent 4f471ddccf
commit 9fc77b9ac8
3 files changed
+104 -3

No files matched your search

+17 -3
View File
@@ -47,6 +47,8 @@ const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
// 3.3 exports none and gets no `extensions` option.
const EXTENSION_NAMES = ['annotations', 'months', 'motion', 'time', 'weeks'] as const
const writtenSelections = new WeakMap<HTMLElement, { text: string; dates: string[] }>()
// The public API is typed with local copies of the Vanilla Calendar Pro types.
// `vanilla-calendar-pro` is an optional peer dependency, so the published
// `dist/types` must not import it: a project that never loads the datepicker
@@ -178,6 +180,7 @@ class Datepicker extends BaseComponent {
_displayElement: HTMLElement | false | null = null
_themeObserver: MutationObserver | null = null
_onFocusIn: ((event: Event) => void) | null = null
_onResize = (): void => this._alignToPositionElement()
// The plugin builds a popup lazily, so its context has no selection until
// the first show; this mirror answers `getSelectedDates()` before that.
_selectedDates: string[] = []
@@ -280,6 +283,7 @@ class Datepicker extends BaseComponent {
dispose(): void {
window.clearTimeout(this._hideTimeout)
this._themeObserver?.disconnect()
window.removeEventListener('resize', this._onResize)
if (this._onFocusIn) {
EventHandler.off(document, EVENT_FOCUSIN, this._onFocusIn)
@@ -363,12 +367,11 @@ class Datepicker extends BaseComponent {
}
_updateDisplayWithSelectedDates(): void {
const { selectedDates } = this._config
if (!selectedDates || selectedDates.length === 0) {
if (this._selectedDates.length === 0) {
return
}
this._writeSelection(selectedDates)
this._writeSelection(this._selectedDates)
}
_writeSelection(selectedDates: string[]): void {
@@ -376,6 +379,7 @@ class Datepicker extends BaseComponent {
if (this._isInput) {
this._element.value = formattedDate
writtenSelections.set(this._element, { text: formattedDate, dates: [...selectedDates] })
}
if (this._boundInput) {
@@ -537,6 +541,8 @@ class Datepicker extends BaseComponent {
this._isShown = true
this._syncThemeAttribute(calendar.context.mainElement)
this._alignToPositionElement()
window.removeEventListener('resize', this._onResize)
window.addEventListener('resize', this._onResize)
if (!wasShown) {
EventHandler.trigger(this._element, EVENT_SHOWN)
@@ -559,6 +565,7 @@ class Datepicker extends BaseComponent {
}
this._isShown = false
window.removeEventListener('resize', this._onResize)
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
@@ -701,6 +708,13 @@ class Datepicker extends BaseComponent {
return
}
const written = writtenSelections.get(this._element)
if (written && written.text.trim() === value) {
this._selectedDates = [...written.dates]
this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) })
return
}
// `YYYY-MM-DD` is read as a local date: `new Date('2020-06-20')` would be
// UTC midnight and land on the previous day west of Greenwich.
const date = DATE_PATTERN.test(value) ? this._parseDate(value) : new Date(value)
+82
View File
@@ -402,4 +402,86 @@ describe('Datepicker', () => {
await new Promise((resolve) => setTimeout(resolve, 150))
})
})
describe('resize and initial value', () => {
const groupMarkup = '<div class="input-group" style="display: flex; margin-left: 40px"><span style="width: 120px"></span><input type="text"></div>'
it('keeps the popup aligned with the wrapper after a resize', async () => {
fixtureEl.innerHTML = groupMarkup
const wrapper = fixtureEl.querySelector<HTMLElement>('.input-group')!
const instance = new Datepicker(input())
await instance.show()
window.dispatchEvent(new Event('resize'))
const expected = wrapper.getBoundingClientRect().left + window.scrollX
expect(instance.calendar!.context.mainElement.style.left).toBe(`${expected}px`)
})
it('stops aligning on resize once hidden or disposed', async () => {
fixtureEl.innerHTML = groupMarkup
const instance = new Datepicker(input())
const spy = vi.spyOn(instance, '_alignToPositionElement')
await instance.show()
await instance.hide()
spy.mockClear()
window.dispatchEvent(new Event('resize'))
expect(spy).not.toHaveBeenCalled()
await instance.show()
instance.dispose()
spy.mockClear()
window.dispatchEvent(new Event('resize'))
expect(spy).not.toHaveBeenCalled()
})
it('formats an initial value like a picked date', () => {
fixtureEl.innerHTML = '<input type="text" value="2024-06-20">'
const dateFormat = { year: 'numeric', month: 'long', day: 'numeric' } as const
new Datepicker(input(), { dateFormat, locale: 'en-US' })
expect(input().value).toBe('June 20, 2024')
})
it('writes the value, not selectedDates, when both are set', () => {
fixtureEl.innerHTML = '<input type="text" value="2024-06-20">'
const instance = new Datepicker(input(), { selectedDates: ['2025-01-01'], locale: 'en-US' })
expect(instance.getSelectedDates()).toEqual(['2024-06-20'])
expect(input().value).toBe(new Date(2024, 5, 20).toLocaleDateString('en-US'))
})
it('keeps a formatted value selected when the input is initialised again', () => {
fixtureEl.innerHTML = '<input type="text" value="2024-06-20">'
new Datepicker(input(), { locale: 'pl-PL' }).dispose()
const instance = new Datepicker(input(), { locale: 'pl-PL' })
expect(input().value).toBe('20.06.2024')
expect(instance.getSelectedDates()).toEqual(['2024-06-20'])
})
it('keeps a picked range selected when the input is initialised again', () => {
fixtureEl.innerHTML = '<input type="text">'
const config = { locale: 'pl-PL', selectionMode: 'multiple-ranged' } as const
const first = new Datepicker(input(), config)
first.setSelectedDates(['2024-06-10', '2024-06-18'])
first.dispose()
const instance = new Datepicker(input(), config)
expect(instance.getSelectedDates()).toEqual(['2024-06-10', '2024-06-18'])
})
it('leaves an unparsable value as it is', () => {
fixtureEl.innerHTML = '<input type="text" value="not a date">'
new Datepicker(input())
expect(input().value).toBe('not a date')
})
})
})