import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' import * as VanillaCalendarPro from 'vanilla-calendar-pro' import { clearFixture, getFixture } from '../helpers/fixture' import Datepicker from '../../src/datepicker' import EventHandler from '../../src/bootstrap/dom/event-handler' describe('Datepicker', () => { let fixtureEl: HTMLElement beforeAll(() => { fixtureEl = getFixture() }) beforeEach(() => { // The page loads the plugin as a global; the tests hand it over the same way. window.VanillaCalendarPro = VanillaCalendarPro }) afterEach(() => { clearFixture() delete window.VanillaCalendarPro // The plugin mounts popups on , outside the fixture for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) { calendarEl.remove() } }) const input = (): HTMLInputElement => fixtureEl.querySelector('input')! const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)) describe('NAME', () => { it('should return plugin name', () => { expect(Datepicker.NAME).toBe('datepicker') }) }) describe('Default', () => { it('should match the Bootstrap 6 defaults', () => { expect(Datepicker.Default.dateMin).toBeNull() expect(Datepicker.Default.dateMax).toBeNull() expect(Datepicker.Default.selectionMode).toBe('single') expect(Datepicker.Default.firstWeekday).toBe(1) expect(Datepicker.Default.locale).toBe('default') expect(Datepicker.Default.placement).toBe('left') expect(Datepicker.Default.inline).toBe(false) expect(Datepicker.Default.displayMonthsCount).toBe(1) }) }) describe('constructor', () => { it('should create the calendar on an input', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(instance.calendar).not.toBeNull() expect(instance._isInput).toBe(true) }) it('should register the extensions the library exports', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) const calendar = instance.calendar as VanillaCalendarPro.Calendar expect(calendar.extensions).toContain(VanillaCalendarPro.months) expect(calendar.extensions).toContain(VanillaCalendarPro.time) expect(calendar.type).toBe('multiple') expect(calendar.displayMonthsCount).toBe(2) }) it('should keep the extensions passed in vcpOptions once', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input(), { vcpOptions: { extensions: [VanillaCalendarPro.months] } }) const extensions = (instance.calendar as VanillaCalendarPro.Calendar).extensions expect(extensions.filter((extension) => extension === VanillaCalendarPro.months)).toHaveLength(1) expect(extensions).toContain(VanillaCalendarPro.motion) }) it('should stay inert without the plugin', () => { delete window.VanillaCalendarPro fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(instance.calendar).toBeNull() }) it('should detect a button trigger and use it as the display element', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(fixtureEl.querySelector('button')!) expect(instance._isInput).toBe(false) expect(instance._displayElement).toBe(fixtureEl.querySelector('button')) }) it('should prefer a display child inside a button', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(fixtureEl.querySelector('button')!) expect(instance._displayElement).toBe(fixtureEl.querySelector('span')) }) it('should read config from data attributes', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(instance._config.selectionMode).toBe('multiple-ranged') expect(instance._config.firstWeekday).toBe(0) }) it('should turn week numbers on from a data attribute', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(Datepicker.Default.weekNumbers).toBe(false) expect(instance._config.weekNumbers).toBe(true) expect(instance._buildCalendarOptions().enableWeekNumbers).toBe(true) }) it('should keep week numbers enabled through vcpOptions', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input(), { vcpOptions: { enableWeekNumbers: true } }) expect(instance._buildCalendarOptions().enableWeekNumbers).toBe(true) }) it('should let a passed config win over data attributes', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input(), { selectionMode: 'multiple' }) expect(instance._config.selectionMode).toBe('multiple') expect(instance._config.firstWeekday).toBe(0) }) it('should write preselected dates into the input', () => { fixtureEl.innerHTML = `` const instance = new Datepicker(input()) expect(instance._config.selectedDates).toEqual(['2026-06-10', '2026-06-18']) expect(instance.getSelectedDates()).toEqual(['2026-06-10', '2026-06-18']) expect(input().value).toContain(' – ') }) it('should read a YYYY-MM-DD value as a local date', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(instance.getSelectedDates()).toEqual(['2020-06-20']) }) it('should position the popup against the .input-icon wrapper', () => { fixtureEl.innerHTML = '
' const instance = new Datepicker(input()) expect(instance._positionElement).toBe(fixtureEl.querySelector('.input-icon')) }) it('should bind a hidden input inside an inline calendar', () => { fixtureEl.innerHTML = '
' const instance = new Datepicker(fixtureEl.querySelector('div')!, { selectedDates: ['2026-03-04'] }) expect(instance._isInline).toBe(true) expect(input().isConnected).toBe(true) expect(input().value).toBe('2026-03-04') }) }) describe('show and hide', () => { it('should fire show and shown, then hide and hidden', async () => { fixtureEl.innerHTML = '' const seen: string[] = [] for (const name of ['show', 'shown', 'hide', 'hidden']) { input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name)) } const instance = new Datepicker(input()) await instance.show() expect(instance._isShown).toBe(true) await instance.hide() expect(instance._isShown).toBe(false) expect(seen).toEqual(['show', 'shown', 'hide', 'hidden']) }) it('should stay closed when show is prevented', async () => { fixtureEl.innerHTML = '' input().addEventListener('show.bs.datepicker', (event) => event.preventDefault()) const instance = new Datepicker(input()) await instance.show() expect(instance._isShown).toBe(false) }) it('should not show a disabled input', async () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) const spy = vi.spyOn(EventHandler, 'trigger') await instance.show() expect(spy).not.toHaveBeenCalled() spy.mockRestore() }) it('should do nothing for an inline calendar', async () => { fixtureEl.innerHTML = '
' const instance = new Datepicker(fixtureEl.querySelector('div')!) await instance.toggle() expect(instance._isShown).toBe(false) }) it('should hide when focus leaves the input and the calendar', async () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) await instance.show() fixtureEl.querySelector('button')!.focus() expect(instance._isShown).toBe(false) }) it('should show again after the plugin throws while showing', async () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) const pluginShow = vi.spyOn(instance.calendar!, 'show').mockImplementationOnce(() => { throw new Error('plugin failed') }) await expect(instance.show()).rejects.toThrow('plugin failed') expect(instance._isShowing).toBe(false) await instance.show() expect(pluginShow).toHaveBeenCalledTimes(2) expect(instance._isShown).toBe(true) }) it('should hide again after the plugin throws while hiding', async () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) await instance.show() vi.spyOn(instance.calendar!, 'hide').mockImplementationOnce(() => { throw new Error('plugin failed') }) await expect(instance.hide()).rejects.toThrow('plugin failed') expect(instance._isHiding).toBe(false) await instance.hide() expect(instance._isShown).toBe(false) }) }) describe('setSelectedDates', () => { it('should update the calendar selection', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) instance.setSelectedDates(['2026-01-15']) expect(instance.getSelectedDates()).toEqual(['2026-01-15']) }) }) describe('dispose', () => { it('should destroy the calendar and drop the instance', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) const destroy = vi.spyOn(instance.calendar!, 'destroy') instance.dispose() expect(destroy).toHaveBeenCalled() expect(Datepicker.getInstance(input())).toBeNull() }) }) describe('data-api', () => { it('should open on focus of a toggle input', async () => { fixtureEl.innerHTML = '' input().focus() await new Promise((resolve) => setTimeout(resolve, 0)) const instance = Datepicker.getInstance(input()) as Datepicker | null expect(instance).not.toBeNull() expect(instance!._isShown).toBe(true) }) it('should toggle on click of a button trigger', async () => { fixtureEl.innerHTML = '' const button = fixtureEl.querySelector('button')! button.click() await new Promise((resolve) => setTimeout(resolve, 0)) const instance = Datepicker.getInstance(button) as Datepicker | null expect(instance).not.toBeNull() expect(instance!._isShown).toBe(true) }) it('should close on the second click of a button trigger', async () => { fixtureEl.innerHTML = '' const button = fixtureEl.querySelector('button')! const seen: string[] = [] for (const name of ['show', 'shown', 'hide', 'hidden']) { button.addEventListener(`${name}.bs.datepicker`, () => seen.push(name)) } button.click() await tick() button.click() await tick() const instance = Datepicker.getInstance(button) as Datepicker expect(instance._isShown).toBe(false) expect(instance.calendar!.context.isShowInInputMode).toBe(false) expect(seen).toEqual(['show', 'shown', 'hide', 'hidden']) button.click() await tick() expect(instance._isShown).toBe(true) }) }) describe('closed by the plugin', () => { const pressEscape = (): void => { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) } it('should fire hide and hidden on Escape', async () => { fixtureEl.innerHTML = '' const seen: string[] = [] for (const name of ['hide', 'hidden']) { input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name)) } const instance = new Datepicker(input()) await instance.show() pressEscape() expect(instance._isShown).toBe(false) expect(seen).toEqual(['hide', 'hidden']) }) it('should stay open when hide is prevented', async () => { fixtureEl.innerHTML = '' const seen: string[] = [] input().addEventListener('hide.bs.datepicker', (event) => event.preventDefault()) for (const name of ['shown', 'hidden']) { input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name)) } const instance = new Datepicker(input()) await instance.show() pressEscape() expect(instance._isShown).toBe(true) expect(instance.calendar!.context.isShowInInputMode).toBe(true) expect(seen).toEqual(['shown']) }) }) describe('audit fixes', () => { it('accepts a Date for dateMin and dateMax', () => { fixtureEl.innerHTML = '' expect(() => new Datepicker(input(), { dateMin: new Date(2024, 0, 1), dateMax: new Date(2024, 11, 31) })).not.toThrow() }) it('keeps the live input in the DOM after dispose', () => { fixtureEl.innerHTML = '' const original = input() const instance = new Datepicker(original) instance.setSelectedDates(['2024-06-20']) instance.dispose() expect(document.querySelector('#picker')).toBe(original) expect(original.value).not.toBe('') }) it('keeps a single bound input for an inline calendar after dispose', () => { fixtureEl.innerHTML = '
' const instance = new Datepicker(fixtureEl.firstElementChild as HTMLElement) instance.setSelectedDates(['2024-06-20']) instance.dispose() const bound = fixtureEl.querySelectorAll('input[name="d"]') expect(bound.length).toBe(1) expect((bound[0] as HTMLInputElement).value).toBe('2024-06-20') }) it('writes the field and opens on the month of setSelectedDates', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) instance.setSelectedDates([new Date(1990, 5, 20)]) expect(instance.getSelectedDates()).toEqual(['1990-06-20']) expect(input().value).not.toBe('') expect(instance.calendar!.context.selectedMonth).toBe(5) expect(instance.calendar!.context.selectedYear).toBe(1990) }) it('opens on the month of an initial value', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) expect(instance.calendar!.context.selectedMonth).toBe(5) expect(instance.calendar!.context.selectedYear).toBe(1990) }) it('clears the field when the picked date is deselected', () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) instance.setSelectedDates(['2024-06-20']) expect(input().value).not.toBe('') instance._calendar!.context.selectedDates = [] instance._handleDateClick(instance._calendar!, new MouseEvent('click')) expect(input().value).toBe('') }) it('does not run the hide timer after dispose', async () => { fixtureEl.innerHTML = '' const instance = new Datepicker(input()) instance._calendar!.context.selectedDates = ['2024-06-20'] instance._handleDateClick(instance._calendar!, new MouseEvent('click')) instance.dispose() 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') }) }) })