feat(12.2-04): add the datepicker field and stored date and time list cells
- pin @internationalized/date 3.12.4 as a direct admin dependency (approved) - dateFormat.ts parses and emits date, datetime (local display, UTC emit, ignoreTimezone wall clock) and time values without the global Date - DatepickerField on Reka DatePicker and TimeField with locale segments, calendar popover, clear button, min/max and yearRange bounds - list cells of type date and time render the stored string - datepicker lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
316
admin/src/components/form/fields/DatepickerField.vue
Normal file
316
admin/src/components/form/fields/DatepickerField.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<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 type { DateValue, Time } 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.
|
||||
const segments = Array.from(root.querySelectorAll<HTMLElement>('[data-reka-date-field-segment]')).filter((el) =>
|
||||
NUMBER_PARTS.has(el.dataset.rekaDateFieldSegment ?? ''),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
emit('update:modelValue', null)
|
||||
scheduleRefresh()
|
||||
}
|
||||
|
||||
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 '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
|
||||
prevent-deselect
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<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>
|
||||
</DatePickerContent>
|
||||
</DatePickerRoot>
|
||||
</template>
|
||||
@@ -8,9 +8,11 @@
|
||||
// hold no form value either, but they render on create and update.
|
||||
// Phase 12.2 adds the fileupload control: it edits its files through the file
|
||||
// routes against the form's session key (D-02, D-03), so it holds no form
|
||||
// value either and renders on create and update.
|
||||
// value either and renders on create and update. The datepicker control is
|
||||
// a plain value field: its string value is part of the save body (D-18).
|
||||
import type { Component } from 'vue'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
import DatepickerField from './fields/DatepickerField.vue'
|
||||
import DropdownField from './fields/DropdownField.vue'
|
||||
import FileuploadField from './fields/FileuploadField.vue'
|
||||
import NumberField from './fields/NumberField.vue'
|
||||
@@ -44,6 +46,7 @@ const renderers = new Map<string, Component>([
|
||||
['widget', WidgetField],
|
||||
['partial', PartialField],
|
||||
['fileupload', FileuploadField],
|
||||
['datepicker', DatepickerField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
|
||||
@@ -7,6 +7,9 @@ import { t } from '../../app/i18n'
|
||||
// One cell rendered by column type (D-12): text by default (arrays
|
||||
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
|
||||
// tabular numbers, switch as the Tak/Nie pills. No per-value icon pills.
|
||||
// Phase 12.2: a `date` column shows the stored YYYY-MM-DD and a `time`
|
||||
// column the stored HH:mm, both as strings: a calendar date or a time of day
|
||||
// has no zone, so it never goes through the global Date (Pitfall 12).
|
||||
const props = defineProps<{ column: ListColumn; value: unknown }>()
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
@@ -44,6 +47,24 @@ function datetime(value: unknown): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/** A stored date as-is (YYYY-MM-DD); anything after the day is dropped. */
|
||||
function storedDate(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const match = /^\d{4}-\d{2}-\d{2}/.exec(value.trim())
|
||||
return match ? match[0] : value.trim()
|
||||
}
|
||||
|
||||
/** A stored time of day as HH:mm (seconds dropped). */
|
||||
function storedTime(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
const match = /(\d{2}):(\d{2})/.exec(value)
|
||||
return match ? `${match[1]}:${match[2]}` : value.trim()
|
||||
}
|
||||
|
||||
function truthy(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
|
||||
@@ -58,10 +79,27 @@ const kind = computed(() => {
|
||||
if (props.column.type === 'datetime') {
|
||||
return 'datetime'
|
||||
}
|
||||
if (props.column.type === 'date') {
|
||||
return 'date'
|
||||
}
|
||||
if (props.column.type === 'time') {
|
||||
return 'time'
|
||||
}
|
||||
return 'text'
|
||||
})
|
||||
|
||||
const display = computed(() => (kind.value === 'datetime' ? datetime(props.value) : text(props.value)))
|
||||
const display = computed(() => {
|
||||
switch (kind.value) {
|
||||
case 'datetime':
|
||||
return datetime(props.value)
|
||||
case 'date':
|
||||
return storedDate(props.value)
|
||||
case 'time':
|
||||
return storedTime(props.value)
|
||||
default:
|
||||
return text(props.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,6 +119,6 @@ const display = computed(() => (kind.value === 'datetime' ? datetime(props.value
|
||||
>
|
||||
</template>
|
||||
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
|
||||
<span v-else-if="kind === 'datetime'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
|
||||
<span v-else-if="kind === 'datetime' || kind === 'date' || kind === 'time'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
|
||||
<template v-else>{{ display }}</template>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user