BM UAT on v0.1.1 showed a date-only calendar for datetime fields, a stuck discard dialog, and raw ISO timestamps when columns.yaml omitted type. The picker now edits time in the popover, confirm sits above the calendar, and omitted time.Time / Date / TimeOfDay columns compile as datetime / date / time. Co-authored-by: Cursor <cursoragent@cursor.com>
397 lines
15 KiB
Vue
397 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import {
|
|
DatePickerCalendar,
|
|
DatePickerCell,
|
|
DatePickerCellTrigger,
|
|
DatePickerContent,
|
|
DatePickerField,
|
|
DatePickerGrid,
|
|
DatePickerGridBody,
|
|
DatePickerGridHead,
|
|
DatePickerGridRow,
|
|
DatePickerHeadCell,
|
|
DatePickerHeader,
|
|
DatePickerHeading,
|
|
DatePickerInput,
|
|
DatePickerNext,
|
|
DatePickerPrev,
|
|
DatePickerRoot,
|
|
DatePickerTrigger,
|
|
TimeFieldInput,
|
|
TimeFieldRoot,
|
|
} from 'reka-ui'
|
|
import { CalendarDateTime, Time, type DateValue } from '@internationalized/date'
|
|
import { Calendar, ChevronLeft, ChevronRight, Clock, X } from '@lucide/vue'
|
|
import { currentLocale, t } from '../../../app/i18n'
|
|
import {
|
|
boundValue,
|
|
dateMode,
|
|
dayBounds,
|
|
emitFieldValue,
|
|
formatDisplay,
|
|
parseFieldValue,
|
|
startValue,
|
|
weekStart,
|
|
type FieldDateValue,
|
|
} from '../../../app/dateFormat'
|
|
import { controlClass, type FieldControlProps } from '../control'
|
|
|
|
// `type: datepicker` (Phase 12.2, D-18 to D-21; UI-SPEC section 1). Date and
|
|
// datetime modes are a Reka DatePicker (segments plus a calendar popover),
|
|
// time mode a Reka TimeField. Values are @internationalized/date objects in
|
|
// the picker and plain strings in the form: a date is YYYY-MM-DD, a time
|
|
// HH:MM:SS, a datetime an RFC 3339 UTC instant shown in the browser's time
|
|
// zone (or, with ignoreTimezone, its wall clock unchanged). Segment order
|
|
// follows the admin locale; the field's display format only shapes the
|
|
// read-only text. No native date input and no global Date are used.
|
|
const props = defineProps<FieldControlProps>()
|
|
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
|
|
|
const mode = computed(() => dateMode(props.field.mode))
|
|
const ignoreTimezone = computed(() => mode.value === 'datetime' && !!props.field.ignoreTimezone)
|
|
const locale = computed(() => currentLocale.value || 'pl')
|
|
|
|
const readOnly = computed(() => {
|
|
const attribute = props.field.attributes?.readonly
|
|
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
|
|
})
|
|
|
|
const value = computed<FieldDateValue | null>(() =>
|
|
parseFieldValue(mode.value, props.modelValue, ignoreTimezone.value),
|
|
)
|
|
const dateValue = computed(() => (mode.value === 'time' ? null : (value.value as DateValue | null)))
|
|
const timeValue = computed(() => (mode.value === 'time' ? (value.value as Time | null) : null))
|
|
|
|
const bounds = computed(() => dayBounds(props.field))
|
|
const minValue = computed(() => boundValue(bounds.value.min, mode.value, ignoreTimezone.value, false))
|
|
const maxValue = computed(() => boundValue(bounds.value.max, mode.value, ignoreTimezone.value, true))
|
|
const placeholderValue = startValue(mode.value, ignoreTimezone.value)
|
|
const weekStartsOn = computed(() => weekStart(locale.value, props.field.firstDay))
|
|
const hourCycle = computed(() => (props.field.twelveHour ? 12 : 24))
|
|
const granularity = computed(() => (mode.value === 'date' ? 'day' : 'minute'))
|
|
|
|
const display = computed(() => formatDisplay(value.value, mode.value, props.field.displayFormat, locale.value))
|
|
const showClear = computed(() => !props.field.required && value.value !== null && !readOnly.value)
|
|
|
|
// A value outside minDate/maxDate is flagged before the save (the server
|
|
// refuses it too).
|
|
const outOfRange = computed(() => {
|
|
const current = dateValue.value
|
|
if (!current) {
|
|
return false
|
|
}
|
|
return (!!minValue.value && current.compare(minValue.value) < 0) || (!!maxValue.value && current.compare(maxValue.value) > 0)
|
|
})
|
|
|
|
// Some segments typed and others still empty: Reka holds no value then, the
|
|
// form gets null, and the field stays marked invalid until every segment is
|
|
// filled or all are cleared.
|
|
const NUMBER_PARTS = new Set(['day', 'month', 'year', 'hour', 'minute', 'second'])
|
|
const container = ref<HTMLElement | null>(null)
|
|
const partial = ref(false)
|
|
|
|
function refreshPartial(): void {
|
|
const root = container.value
|
|
if (!root) {
|
|
partial.value = false
|
|
return
|
|
}
|
|
// Only the editable number segments count: literals, the zone name, the
|
|
// 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
|
|
}
|
|
|
|
function scheduleRefresh(): void {
|
|
void nextTick(refreshPartial)
|
|
}
|
|
|
|
watch(value, scheduleRefresh)
|
|
|
|
const markedInvalid = computed(() => !!props.invalid || outOfRange.value || partial.value)
|
|
|
|
/** The stored string of the current form value, normalised like an emit. */
|
|
const currentText = computed(() => emitFieldValue(mode.value, value.value, ignoreTimezone.value))
|
|
|
|
function onUpdate(next: DateValue | Time | undefined | null): void {
|
|
const text = emitFieldValue(mode.value, (next ?? null) as FieldDateValue | null, ignoreTimezone.value)
|
|
scheduleRefresh()
|
|
if (text === currentText.value && (text !== null || props.modelValue === null)) {
|
|
return
|
|
}
|
|
emit('update:modelValue', text)
|
|
}
|
|
|
|
/** A calendar day click replaces the instant; keep the time already in the field. */
|
|
function onDatePickerUpdate(next: DateValue | undefined | null): void {
|
|
if (!next || mode.value !== 'datetime') {
|
|
onUpdate(next)
|
|
return
|
|
}
|
|
const previous = dateValue.value
|
|
const clock = popoverTime.value
|
|
const dateChanged =
|
|
!previous ||
|
|
previous.year !== next.year ||
|
|
previous.month !== next.month ||
|
|
previous.day !== next.day
|
|
const timeDiffers = !('hour' in next) || next.hour !== clock.hour || next.minute !== clock.minute
|
|
if (dateChanged && timeDiffers) {
|
|
next = withClock(next, clock)
|
|
}
|
|
pickedDate.value = next
|
|
onUpdate(next)
|
|
}
|
|
|
|
function clear(): void {
|
|
emit('update:modelValue', null)
|
|
scheduleRefresh()
|
|
}
|
|
|
|
/** The clock shown in the datetime popover, or midnight when the day is empty. */
|
|
const pickedDate = ref<DateValue | null>(null)
|
|
watch(dateValue, (value) => {
|
|
pickedDate.value = value
|
|
}, { immediate: true })
|
|
|
|
const popoverTime = computed((): Time => {
|
|
const current = pickedDate.value ?? dateValue.value
|
|
if (current && 'hour' in current) {
|
|
return new Time(current.hour, current.minute)
|
|
}
|
|
return new Time(0, 0)
|
|
})
|
|
|
|
function withClock(value: DateValue, clock: Time): DateValue {
|
|
if ('hour' in value) {
|
|
return (value as CalendarDateTime).set({ hour: clock.hour, minute: clock.minute, second: 0, millisecond: 0 })
|
|
}
|
|
return new CalendarDateTime(value.year, value.month, value.day, clock.hour, clock.minute)
|
|
}
|
|
|
|
function onPopoverTime(next: { hour: number; minute: number } | undefined | null): void {
|
|
if (!next) {
|
|
return
|
|
}
|
|
const clock = next instanceof Time ? next : new Time(next.hour, next.minute)
|
|
const current = (pickedDate.value ?? dateValue.value ?? placeholderValue) as DateValue
|
|
const updated = withClock(current, clock)
|
|
pickedDate.value = updated
|
|
onUpdate(updated)
|
|
}
|
|
|
|
function ucfirst(text: string): string {
|
|
return text.charAt(0).toLocaleUpperCase(locale.value) + text.slice(1)
|
|
}
|
|
|
|
const containerClass = computed(() => [
|
|
controlClass(markedInvalid.value),
|
|
'h-input flex min-w-0 items-center gap-1 pl-3.5 pr-1.5',
|
|
'focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
|
|
'has-[[data-invalid]]:border-danger',
|
|
])
|
|
|
|
function segmentClass(part: string): string {
|
|
if (part === 'literal') {
|
|
return 'text-muted'
|
|
}
|
|
if (part === 'timeZoneName') {
|
|
return 'ml-2 min-w-0 truncate text-[13px] text-muted'
|
|
}
|
|
return 'rounded-[4px] px-0.5 tabular-nums outline-none focus:bg-sel focus:text-text focus-visible:outline-none data-[placeholder]:text-placeholder focus:data-[placeholder]:text-text'
|
|
}
|
|
|
|
const iconButton =
|
|
'flex size-8 shrink-0 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text'
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Read-only: the value as text in the display format, or a muted dash. -->
|
|
<p
|
|
v-if="readOnly"
|
|
:id="controlId"
|
|
data-datepicker-readonly
|
|
:aria-describedby="describedBy || undefined"
|
|
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 tabular-nums"
|
|
>
|
|
<span v-if="display !== ''">{{ display }}</span>
|
|
<span v-else class="text-muted" data-empty>—</span>
|
|
</p>
|
|
|
|
<!-- Time of day: segments only, a clock icon where the trigger would be. -->
|
|
<div
|
|
v-else-if="mode === 'time'"
|
|
ref="container"
|
|
data-datepicker
|
|
data-mode="time"
|
|
:class="containerClass"
|
|
@keyup="scheduleRefresh"
|
|
@focusout="scheduleRefresh"
|
|
>
|
|
<TimeFieldRoot
|
|
:id="controlId"
|
|
v-slot="{ segments }"
|
|
:model-value="timeValue"
|
|
:locale="locale"
|
|
:hour-cycle="hourCycle"
|
|
granularity="minute"
|
|
:name="field.name"
|
|
:required="field.required || undefined"
|
|
:aria-invalid="markedInvalid ? 'true' : undefined"
|
|
:aria-describedby="describedBy || undefined"
|
|
class="flex min-w-0 flex-1 items-center"
|
|
@update:model-value="onUpdate"
|
|
>
|
|
<TimeFieldInput
|
|
v-for="item in segments"
|
|
:key="item.part"
|
|
:part="item.part"
|
|
:class="segmentClass(item.part)"
|
|
>
|
|
{{ item.value }}
|
|
</TimeFieldInput>
|
|
</TimeFieldRoot>
|
|
<button v-if="showClear" type="button" data-action="clear" :aria-label="t('backend::lang.datepicker.clear')" :class="iconButton" @click="clear">
|
|
<X :size="16" aria-hidden="true" />
|
|
</button>
|
|
<span class="flex size-8 shrink-0 items-center justify-center text-muted" aria-hidden="true">
|
|
<Clock :size="16" />
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Date and datetime: segments plus the calendar popover. -->
|
|
<DatePickerRoot
|
|
v-else
|
|
:id="controlId"
|
|
:model-value="dateValue"
|
|
:default-placeholder="placeholderValue"
|
|
:locale="locale"
|
|
:granularity="granularity"
|
|
:hour-cycle="hourCycle"
|
|
:hide-time-zone="mode !== 'datetime' || ignoreTimezone"
|
|
:min-value="minValue"
|
|
:max-value="maxValue"
|
|
:week-starts-on="weekStartsOn"
|
|
:name="field.name"
|
|
:required="field.required || undefined"
|
|
:close-on-select="mode === 'date'"
|
|
prevent-deselect
|
|
@update:model-value="onDatePickerUpdate"
|
|
>
|
|
<div
|
|
ref="container"
|
|
data-datepicker
|
|
:data-mode="mode"
|
|
:class="containerClass"
|
|
@keyup="scheduleRefresh"
|
|
@focusout="scheduleRefresh"
|
|
>
|
|
<DatePickerField
|
|
v-slot="{ segments }"
|
|
:aria-invalid="markedInvalid ? 'true' : undefined"
|
|
:aria-describedby="describedBy || undefined"
|
|
class="flex min-w-0 flex-1 items-center overflow-hidden"
|
|
>
|
|
<DatePickerInput
|
|
v-for="item in segments"
|
|
:key="item.part"
|
|
:part="item.part"
|
|
:class="segmentClass(item.part)"
|
|
>
|
|
{{ item.value }}
|
|
</DatePickerInput>
|
|
</DatePickerField>
|
|
<button v-if="showClear" type="button" data-action="clear" :aria-label="t('backend::lang.datepicker.clear')" :class="iconButton" @click="clear">
|
|
<X :size="16" aria-hidden="true" />
|
|
</button>
|
|
<DatePickerTrigger data-action="open-calendar" :aria-label="t('backend::lang.datepicker.open_calendar')" :class="iconButton">
|
|
<Calendar :size="16" aria-hidden="true" />
|
|
</DatePickerTrigger>
|
|
</div>
|
|
|
|
<DatePickerContent
|
|
data-datepicker-calendar
|
|
:side-offset="4"
|
|
align="end"
|
|
class="z-50 w-[284px] rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
|
|
>
|
|
<DatePickerCalendar v-slot="{ weekDays, grid }">
|
|
<DatePickerHeader class="flex h-9 items-center justify-between">
|
|
<DatePickerPrev :aria-label="t('backend::lang.datepicker.prev_month')" :class="iconButton">
|
|
<ChevronLeft :size="16" aria-hidden="true" />
|
|
</DatePickerPrev>
|
|
<DatePickerHeading v-slot="{ headingValue }" class="font-semibold">
|
|
{{ ucfirst(headingValue) }}
|
|
</DatePickerHeading>
|
|
<DatePickerNext :aria-label="t('backend::lang.datepicker.next_month')" :class="iconButton">
|
|
<ChevronRight :size="16" aria-hidden="true" />
|
|
</DatePickerNext>
|
|
</DatePickerHeader>
|
|
<DatePickerGrid
|
|
v-for="month in grid"
|
|
:key="month.value.toString()"
|
|
class="mt-2 w-full border-collapse select-none"
|
|
>
|
|
<DatePickerGridHead>
|
|
<DatePickerGridRow class="flex">
|
|
<DatePickerHeadCell
|
|
v-for="day in weekDays"
|
|
:key="day"
|
|
class="flex h-9 w-9 items-center justify-center text-[13px] font-normal text-muted"
|
|
>
|
|
{{ day }}
|
|
</DatePickerHeadCell>
|
|
</DatePickerGridRow>
|
|
</DatePickerGridHead>
|
|
<DatePickerGridBody>
|
|
<DatePickerGridRow
|
|
v-for="(weekDates, index) in month.rows"
|
|
:key="`week-${index}`"
|
|
class="flex"
|
|
>
|
|
<DatePickerCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate" class="p-0">
|
|
<DatePickerCellTrigger
|
|
:day="weekDate"
|
|
:month="month.value"
|
|
class="flex size-9 items-center justify-center rounded-pager tabular-nums text-text transition-colors duration-150 ease-out hover:bg-hover data-[outside-view]:text-placeholder data-[today]:font-semibold data-[today]:shadow-[inset_0_0_0_1px_var(--c-border-strong)] data-[selected]:bg-primary data-[selected]:font-semibold data-[selected]:text-on-primary data-[disabled]:cursor-not-allowed data-[disabled]:text-placeholder data-[disabled]:opacity-40 data-[disabled]:hover:bg-transparent data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40"
|
|
/>
|
|
</DatePickerCell>
|
|
</DatePickerGridRow>
|
|
</DatePickerGridBody>
|
|
</DatePickerGrid>
|
|
</DatePickerCalendar>
|
|
<div
|
|
v-if="mode === 'datetime'"
|
|
data-datepicker-time
|
|
class="mt-3 flex items-center gap-2 border-t border-border pt-3"
|
|
>
|
|
<Clock :size="16" class="shrink-0 text-muted" aria-hidden="true" />
|
|
<TimeFieldRoot
|
|
v-slot="{ segments }"
|
|
:model-value="popoverTime"
|
|
:locale="locale"
|
|
:hour-cycle="hourCycle"
|
|
granularity="minute"
|
|
hide-time-zone
|
|
class="flex min-w-0 items-center"
|
|
@update:model-value="onPopoverTime"
|
|
>
|
|
<TimeFieldInput
|
|
v-for="item in segments"
|
|
:key="item.part"
|
|
:part="item.part"
|
|
:class="segmentClass(item.part)"
|
|
>
|
|
{{ item.value }}
|
|
</TimeFieldInput>
|
|
</TimeFieldRoot>
|
|
</div>
|
|
</DatePickerContent>
|
|
</DatePickerRoot>
|
|
</template>
|