Files
tabler/core/js/tests/unit/datepicker.spec.ts

538 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <body>, outside the fixture
for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) {
calendarEl.remove()
}
})
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const tick = (): Promise<void> => 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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker" data-bs-display-months-count="2">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker">'
const instance = new Datepicker(input())
expect(instance.calendar).toBeNull()
})
it('should detect a button trigger and use it as the display element', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
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 = '<button type="button" data-bs-toggle="datepicker"><span data-bs-datepicker-display>Select date</span></button>'
const instance = new Datepicker(fixtureEl.querySelector('button')!)
expect(instance._displayElement).toBe(fixtureEl.querySelector('span'))
})
it('should read config from data attributes', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-first-weekday="0">'
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 = '<input type="text" data-bs-toggle="datepicker" data-bs-week-numbers="true">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker" data-bs-first-weekday="0">'
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 = `<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-selected-dates='["2026-06-10", "2026-06-18"]'>`
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 = '<input type="text" data-bs-toggle="datepicker" value="2020-06-20">'
const instance = new Datepicker(input())
expect(instance.getSelectedDates()).toEqual(['2020-06-20'])
})
it('should position the popup against the .input-icon wrapper', () => {
fixtureEl.innerHTML = '<div class="input-icon"><input type="text" data-bs-toggle="datepicker"><span class="input-icon-addon"></span></div>'
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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"><input type="hidden" name="date"></div>'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker" disabled>'
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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
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 = '<input type="text" data-bs-toggle="datepicker"><button type="button">Next</button>'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 type="text" data-bs-toggle="datepicker">'
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 = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
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 = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text" data-bs-toggle="datepicker">'
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 = '<input type="text">'
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 = '<input type="text" id="picker">'
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 = '<div data-bs-toggle="datepicker" data-bs-inline="true"><input type="hidden" name="d"></div>'
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 = '<input type="text">'
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 = '<input type="text" value="1990-06-20">'
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 = '<input type="text">'
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 = '<input type="text">'
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 = '<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')
})
})
})