diff --git a/.changeset/fix-datepicker-resize-initial-value.md b/.changeset/fix-datepicker-resize-initial-value.md new file mode 100644 index 000000000..399107871 --- /dev/null +++ b/.changeset/fix-datepicker-resize-initial-value.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed `Datepicker` popup snapping back to the input on resize and an initial `value` not being formatted. diff --git a/core/js/src/datepicker.ts b/core/js/src/datepicker.ts index af8d6380c..903487190 100644 --- a/core/js/src/datepicker.ts +++ b/core/js/src/datepicker.ts @@ -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() + // 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) diff --git a/core/js/tests/unit/datepicker.spec.ts b/core/js/tests/unit/datepicker.spec.ts index 4216ea81f..3078bdbcc 100644 --- a/core/js/tests/unit/datepicker.spec.ts +++ b/core/js/tests/unit/datepicker.spec.ts @@ -402,4 +402,86 @@ describe('Datepicker', () => { await new Promise((resolve) => setTimeout(resolve, 150)) }) }) + + describe('resize and initial value', () => { + const groupMarkup = '
' + + it('keeps the popup aligned with the wrapper after a resize', async () => { + fixtureEl.innerHTML = groupMarkup + const wrapper = fixtureEl.querySelector('.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 = '' + 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 = '' + + 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 = '' + 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 = '' + 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 = '' + + new Datepicker(input()) + + expect(input().value).toBe('not a date') + }) + }) })