migrate datepickers to shadcn, Fixes #877, #807

This commit is contained in:
Gregor Vostrak
2026-02-05 15:02:01 +01:00
parent f82f5e780c
commit 66681066bc
14 changed files with 613 additions and 93 deletions

View File

@@ -1,11 +1,21 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { computed, inject, ref, type ComputedRef } from 'vue';
import {
getLocalizedDayJs,
formatDate,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';
import { Calendar } from '@/Components/ui/calendar';
import { Button } from '@/Components/ui/button';
import { CalendarIcon } from 'lucide-vue-next';
import { parseDate, type DateValue } from '@internationalized/date';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
class?: string;
tabindex?: string;
class?: string;
}>();
// This has to be a localized timestamp, not UTC
@@ -13,61 +23,64 @@ const model = defineModel<string | null>({
default: null,
});
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
const emit = defineEmits(['changed']);
watch(model, (value) => {
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
const open = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const dateString = computed(() => {
if (!model.value) return null;
return getLocalizedDayJs(model.value).format('YYYY-MM-DD');
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = getDayJsInstance()(newValue);
if (newDate.isValid()) {
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('month', newDate.month())
.set('date', newDate.date())
.format();
emit('changed', model.value);
}
const calendarDate = computed(() => {
if (!dateString.value) return undefined;
return parseDate(dateString.value);
});
const displayDate = computed(() => {
if (!dateString.value) return '';
return formatDate(dateString.value, organization?.value?.date_format);
});
function handleDateSelect(newDate: DateValue | undefined) {
if (!newDate) return;
// If model.value is null, start from current date/time
const baseDate = model.value ? getLocalizedDayJs(model.value) : getLocalizedDayJs();
const newValue = baseDate
.set('year', newDate.year)
.set('month', newDate.month - 1) // CalendarDate months are 1-indexed, dayjs is 0-indexed
.set('date', newDate.day)
.format();
model.value = newValue;
emit('changed', newValue);
open.value = false;
}
const datePicker = ref<HTMLInputElement | null>(null);
function updateTempValue(event: Event) {
const target = event.target as HTMLInputElement;
tempDate.value = target.value;
}
const emit = defineEmits(['changed']);
</script>
<template>
<div class="flex items-center text-text-secondary">
<input
id="start"
ref="datePicker"
:tabindex="tabindex"
:class="
twMerge(
'bg-input-background border text-text-primary border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
props.class
)
"
type="date"
name="trip-start"
:value="tempDate"
@change="updateTempValue"
@blur="updateDate"
@keydown.enter="updateDate" />
<div class="w-full">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="input"
size="sm"
:tabindex="tabindex"
:class="['w-full px-2 gap-1.5', props.class]">
<CalendarIcon class="!size-3 text-muted-foreground" />
<span>{{ displayDate || 'Pick a date' }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="center">
<Calendar
mode="single"
:model-value="calendarDate"
:week-starts-on="weekStartsOn"
@update:model-value="handleDateSelect" />
</PopoverContent>
</Popover>
</div>
</template>
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -6,11 +6,18 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import {
getDayJsInstance,
getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = defineProps<{
start: string;
end: string;
@@ -207,7 +214,8 @@ watch(open, (value) => {
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="today" />
:max-value="today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>

View File

@@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import Button from '../Buttons/Button.vue';
import { Button } from '@/Components/ui/button';
const props = defineProps<{
start: string;
@@ -61,9 +61,10 @@ const dropdownContent = ref();
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div class="px-2" @keydown.enter.prevent="nextTick(() => emit('close'))">
<div class="font-semibold text-text-primary text-sm pb-2">Start</div>
<div class="space-y-2">
<div class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempStart"
class="w-full"
data-testid="time_entry_range_start"
tabindex="0"
:focus
@@ -71,35 +72,37 @@ const dropdownContent = ref();
@changed="updateTimeEntry"></TimePickerSimple>
<DatePicker
v-model="tempStart"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry"
@blur.stop.prevent="emit('close')"></DatePicker>
class="w-full"
@changed="updateTimeEntry"></DatePicker>
</div>
</div>
<div class="px-2">
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
<div
v-if="end !== null && tempEnd !== null"
class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempEnd"
class="w-full"
data-testid="time_entry_range_end"
@changed="updateTimeEntry"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
class="w-full"
@changed="updateTimeEntry"></DatePicker>
</div>
<div v-else-if="end === null && !showEndTimePicker">
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
</div>
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
<div
v-else-if="showEndTimePicker && tempEnd !== null"
class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempEnd"
class="w-full"
data-testid="time_entry_range_end"
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@keydown.enter.prevent="confirmEndTime"></DatePicker>
<DatePicker v-model="tempEnd" class="w-full"></DatePicker>
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
Confirm
</Button>

View File

@@ -235,22 +235,22 @@ const billableProxy = computed({
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</div>

View File

@@ -251,22 +251,25 @@ const billableProxy = computed({
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</div>

View File

@@ -26,9 +26,9 @@ const forwardedProps = useForwardProps(delegatedProps);
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selection Start
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
'data-[selection-start]:bg-quaternary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-quaternary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
// Selection End
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
'data-[selection-end]:bg-quaternary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-quaternary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
// Disabled

View File

@@ -21,6 +21,9 @@ export type DateFormat =
| 'hyphen-separated-mm-dd-yyyy'
| 'hyphen-separated-yyyy-mm-dd';
// Day of week index type for calendar components (0 = Sunday, 6 = Saturday)
export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const dateFormatMap: Record<DateFormat, string> = {
'point-separated-d-m-yyyy': 'D.M.YYYY',
'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',