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:
Jakub Zych
2026-10-02 20:37:38 +02:00
parent c8d41a68c6
commit 5b06b20ac3
4 changed files with 211 additions and 6 deletions

View File

@@ -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
}

View 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)
})
})