fix(12.2-05): mark a partly filled time-mode datepicker invalid
The partial-segment check read only DateField segments, so a time-mode field (Reka TimeField, data-reka-time-field-segment) with some segments typed was never flagged. Read both segment attributes. DatepickerField tests cover both UI-SPEC backstops (Escape focus and disabled days, the fixed-zone datetime round trip) and the partly filled time case.
This commit is contained in:
@@ -98,10 +98,11 @@ function refreshPartial(): void {
|
||||
return
|
||||
}
|
||||
// Only the editable number segments count: literals, the zone name, the
|
||||
// day period and the calendar trigger carry the same data attribute.
|
||||
const segments = Array.from(root.querySelectorAll<HTMLElement>('[data-reka-date-field-segment]')).filter((el) =>
|
||||
NUMBER_PARTS.has(el.dataset.rekaDateFieldSegment ?? ''),
|
||||
)
|
||||
// day period and the calendar trigger carry the same data attribute. A
|
||||
// date or datetime renders DateField segments, time mode TimeField ones.
|
||||
const segments = Array.from(
|
||||
root.querySelectorAll<HTMLElement>('[data-reka-date-field-segment], [data-reka-time-field-segment]'),
|
||||
).filter((el) => NUMBER_PARTS.has(el.dataset.rekaDateFieldSegment ?? el.dataset.rekaTimeFieldSegment ?? ''))
|
||||
const filled = segments.filter((el) => !el.hasAttribute('data-placeholder')).length
|
||||
partial.value = filled > 0 && filled < segments.length
|
||||
}
|
||||
|
||||
204
admin/tests/form/DatepickerField.test.ts
Normal file
204
admin/tests/form/DatepickerField.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// 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> = {}): FormField {
|
||||
return { name: 'starts_at', label: 'Starts at', type: 'datepicker', mode: 'datetime', ...extra } as FormField
|
||||
}
|
||||
|
||||
function mountField(f: FormField, modelValue: unknown, extra: Record<string, unknown> = {}) {
|
||||
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<Element> {
|
||||
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<HTMLElement>('[data-datepicker-calendar]')
|
||||
expect(calendar).not.toBeNull()
|
||||
const day = calendar!.querySelector<HTMLElement>('[data-reka-calendar-cell-trigger][data-selected]') ??
|
||||
calendar!.querySelector<HTMLElement>('[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<HTMLElement>('[data-datepicker-calendar]')!
|
||||
const cells = Array.from(calendar.querySelectorAll<HTMLElement>('[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('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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user