// DatepickerField (Phase 12.2, D-18 to D-21; UI-SPEC section 1) mounted on // its own. The zone is fixed to Europe/Warsaw so a datetime shows a local // wall clock different from UTC. Covers the two UI-SPEC backstops: the // calendar popover returns focus to its trigger on Escape and refuses a // disabled day, and a datetime round trip shows local time but emits UTC // (ignoreTimezone keeps the wall clock). import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { flushPromises, mount, type DOMWrapper, type VueWrapper } from '@vue/test-utils' import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date' import DatepickerField from '../../src/components/form/fields/DatepickerField.vue' import type { FormField } from '../../src/api/types' import { keydown, resetState } from '../helpers' function field(extra: Partial = {}): FormField { return { name: 'starts_at', label: 'Starts at', type: 'datepicker', mode: 'datetime', ...extra } as FormField } function mountField(f: FormField, modelValue: unknown, extra: Record = {}) { return mount(DatepickerField, { props: { field: f, modelValue, controlId: 'field-x', describedBy: 'field-x-comment', ...extra }, attachTo: document.body, }) } // Date and datetime segments come from Reka's DateField, time segments // from its TimeField, each with its own data attribute. function segment(wrapper: VueWrapper, part: string): DOMWrapper { const date = wrapper.find(`[data-reka-date-field-segment="${part}"]`) return date.exists() ? date : wrapper.find(`[data-reka-time-field-segment="${part}"]`) } function parts(wrapper: VueWrapper): string[] { return wrapper .findAll('[data-reka-date-field-segment]') .map((el) => el.attributes('data-reka-date-field-segment') ?? '') .filter((part) => part !== 'literal' && part !== 'trigger') } function emitted(wrapper: VueWrapper): unknown[] { return (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0]) } beforeEach(() => { resetState() setLocalTimeZone('Europe/Warsaw') }) afterEach(() => { resetLocalTimeZone() document.body.innerHTML = '' }) describe('datetime round trip (backstop 2)', () => { it('shows the stored UTC instant as local wall clock and emits UTC', async () => { const wrapper = mountField(field(), '2026-10-02T10:30:00Z') expect(segment(wrapper, 'hour').text()).toBe('12') expect(segment(wrapper, 'minute').text()).toBe('30') expect(segment(wrapper, 'timeZoneName').exists()).toBe(true) await segment(wrapper, 'minute').trigger('keydown', { key: 'ArrowUp' }) await flushPromises() expect(emitted(wrapper).at(-1)).toBe('2026-10-02T10:31:00Z') wrapper.unmount() }) it('keeps the wall clock unchanged with ignoreTimezone', async () => { const wrapper = mountField(field({ ignoreTimezone: true }), '2026-10-02T10:30:00Z') expect(segment(wrapper, 'hour').text()).toBe('10') expect(segment(wrapper, 'timeZoneName').exists()).toBe(false) await segment(wrapper, 'minute').trigger('keydown', { key: 'ArrowUp' }) await flushPromises() expect(emitted(wrapper).at(-1)).toBe('2026-10-02T10:31:00Z') wrapper.unmount() }) it('emits a date as YYYY-MM-DD in Polish segment order', async () => { const wrapper = mountField(field({ name: 'released_on', mode: 'date' }), null) expect(parts(wrapper)).toEqual(['day', 'month', 'year']) for (const [part, digits] of [['day', '02'], ['month', '10'], ['year', '2026']] as const) { for (const key of digits) { await segment(wrapper, part).trigger('keydown', { key }) } } await flushPromises() expect(emitted(wrapper).at(-1)).toBe('2026-10-02') wrapper.unmount() }) }) describe('calendar popover (backstop 1)', () => { it('returns focus to the trigger on Escape', async () => { const wrapper = mountField(field({ name: 'released_on', mode: 'date' }), '2026-10-02') const trigger = wrapper.find('[data-action="open-calendar"]') ;(trigger.element as HTMLElement).focus() await trigger.trigger('click') await flushPromises() const calendar = document.body.querySelector('[data-datepicker-calendar]') expect(calendar).not.toBeNull() const day = calendar!.querySelector('[data-reka-calendar-cell-trigger][data-selected]') ?? calendar!.querySelector('[data-reka-calendar-cell-trigger]') day?.focus() keydown(document.activeElement ?? calendar!, 'Escape') await flushPromises() expect(document.body.querySelector('[data-datepicker-calendar]')).toBeNull() expect(document.activeElement).toBe(trigger.element) wrapper.unmount() }) it('marks days outside minDate and maxDate disabled and ignores a click on them', async () => { const wrapper = mountField(field({ name: 'released_on', mode: 'date', minDate: '2026-10-05', maxDate: '2026-10-20' }), '2026-10-10') await wrapper.find('[data-action="open-calendar"]').trigger('click') await flushPromises() const calendar = document.body.querySelector('[data-datepicker-calendar]')! const cells = Array.from(calendar.querySelectorAll('[data-reka-calendar-cell-trigger]')) const before = cells.find((el) => el.textContent?.trim() === '4' && !el.hasAttribute('data-outside-view'))! const inside = cells.find((el) => el.textContent?.trim() === '12' && !el.hasAttribute('data-outside-view'))! expect(before.hasAttribute('data-disabled')).toBe(true) expect(inside.hasAttribute('data-disabled')).toBe(false) before.click() await flushPromises() expect(emitted(wrapper)).toEqual([]) inside.click() await flushPromises() expect(emitted(wrapper).at(-1)).toBe('2026-10-12') wrapper.unmount() }) it('shows a time picker in the datetime popover and keeps the popover open after a day click', async () => { const wrapper = mountField(field(), '2026-10-02T10:30:00Z') await wrapper.find('[data-action="open-calendar"]').trigger('click') await flushPromises() const popover = document.body.querySelector('[data-datepicker-calendar]') expect(popover).not.toBeNull() const clock = popover!.querySelector('[data-datepicker-time]') expect(clock).not.toBeNull() expect(clock!.querySelector('[data-reka-time-field-segment="hour"]')?.textContent).toBe('12') expect(clock!.querySelector('[data-reka-time-field-segment="minute"]')?.textContent).toBe('30') const day = Array.from(popover!.querySelectorAll('[data-reka-calendar-cell-trigger]')).find( (el) => el.textContent?.trim() === '3' && !el.hasAttribute('data-outside-view'), ) day?.click() await flushPromises() expect(document.body.querySelector('[data-datepicker-calendar]')).not.toBeNull() expect(emitted(wrapper).at(-1)).toBe('2026-10-03T10:30:00Z') const minute = clock!.querySelector('[data-reka-time-field-segment="minute"]')! minute.focus() keydown(minute, 'ArrowUp') await flushPromises() expect(emitted(wrapper).at(-1)).toBe('2026-10-03T10:31:00Z') wrapper.unmount() }) it('starts the week on the field firstDay, else on the locale first day', async () => { const heads = async (f: FormField) => { const wrapper = mountField(f, '2026-10-10') await wrapper.find('[data-action="open-calendar"]').trigger('click') await flushPromises() const first = document.body.querySelector('[data-datepicker-calendar] th')?.textContent?.trim() wrapper.unmount() document.body.innerHTML = '' return first } const monday = await heads(field({ name: 'released_on', mode: 'date' })) const sunday = await heads(field({ name: 'released_on', mode: 'date', firstDay: 0 })) expect(monday).not.toBe(sunday) expect(sunday?.toLowerCase()).toMatch(/^n/) expect(monday?.toLowerCase()).toMatch(/^p/) }) }) describe('field states', () => { it('renders read-only text in the display format, or a muted dash', () => { const shown = mountField(field({ name: 'released_on', mode: 'date', readOnly: true, displayFormat: 'D MMMM YYYY' }), '2026-10-02') const text = shown.find('[data-datepicker-readonly]') expect(text.attributes('id')).toBe('field-x') expect(text.text()).toBe('2 października 2026') expect(shown.find('[data-reka-date-field-segment]').exists()).toBe(false) const empty = mountField(field({ readOnly: true }), null) expect(empty.find('[data-empty]').text()).toBe('—') const attr = mountField(field({ attributes: { readonly: true } }), null) expect(attr.find('[data-datepicker-readonly]').exists()).toBe(true) }) it('offers the clear button only on an optional field with a value', async () => { expect(mountField(field(), '2026-10-02T10:30:00Z').find('[data-action="clear"]').exists()).toBe(true) expect(mountField(field(), null).find('[data-action="clear"]').exists()).toBe(false) expect(mountField(field({ required: true }), '2026-10-02T10:30:00Z').find('[data-action="clear"]').exists()).toBe(false) const wrapper = mountField(field(), '2026-10-02T10:30:00Z') await wrapper.find('[data-action="clear"]').trigger('click') expect(emitted(wrapper)).toEqual([null]) }) it('is aria-invalid on a server error, a partly filled value and an out-of-range value', async () => { const invalid = mountField(field(), '2026-10-02T10:30:00Z', { invalid: true }) expect(invalid.find('[aria-invalid="true"]').exists()).toBe(true) const fine = mountField(field(), '2026-10-02T10:30:00Z') expect(fine.find('[aria-invalid="true"]').exists()).toBe(false) const range = mountField(field({ name: 'released_on', mode: 'date', minDate: '2026-11-01' }), '2026-10-02') await flushPromises() expect(range.find('[aria-invalid="true"]').exists()).toBe(true) const partial = mountField(field({ name: 'released_on', mode: 'date' }), null) await segment(partial, 'day').trigger('keydown', { key: '2' }) await partial.find('[data-datepicker]').trigger('keyup') await flushPromises() expect(partial.find('[aria-invalid="true"]').exists()).toBe(true) }) it('renders time mode with a day period when twelveHour is set and emits HH:MM:SS', async () => { const wrapper = mountField(field({ name: 'opens_at', mode: 'time', twelveHour: true }), '14:30:00') expect(wrapper.find('[data-datepicker][data-mode="time"]').exists()).toBe(true) expect(wrapper.find('[data-action="open-calendar"]').exists()).toBe(false) expect(segment(wrapper, 'dayPeriod').exists()).toBe(true) expect(segment(wrapper, 'hour').text()).toBe('2') await segment(wrapper, 'minute').trigger('keydown', { key: 'ArrowUp' }) await flushPromises() expect(emitted(wrapper).at(-1)).toBe('14:31:00') const day = mountField(field({ name: 'opens_at', mode: 'time' }), '14:30:00') expect(segment(day, 'dayPeriod').exists()).toBe(false) }) it('marks a partly filled time invalid', async () => { const wrapper = mountField(field({ name: 'opens_at', mode: 'time' }), null) expect(wrapper.find('[aria-invalid="true"]').exists()).toBe(false) await segment(wrapper, 'hour').trigger('keydown', { key: '9' }) await wrapper.find('[data-datepicker]').trigger('keyup') await flushPromises() expect(wrapper.find('[aria-invalid="true"]').exists()).toBe(true) }) })