Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions resources/js/components/ui/Calendar/Calendar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ const gridStyle = computed(() => {
'grid-template-rows': 'auto'
};
});

/** Popover uses slight negative inset to align with card; inline skips that inside padded layouts. */
const calendarHeaderClass = computed(() =>
props.inline
? 'flex items-center justify-between pb-3.5 ms-1 -me-1.5 -mt-1'
: 'flex items-center justify-between ps-3.5 pe-1 pb-3.5 -mt-1.5',
);

/** Month grid wrapper: popover matches narrow card; inline allows shrink in tight form layouts. */
const calendarGridClass = computed(() =>
props.inline
? 'w-full border-collapse space-y-1 select-none -ms-2'
: 'w-full border-collapse space-y-1 select-none',
);
</script>

<template>
Expand All @@ -88,7 +102,7 @@ const gridStyle = computed(() => {
:number-of-months="numberOfMonths"
@update:model-value="emit('update:modelValue', $event)"
>
<Component :is="components.CalendarHeader" class="flex items-center justify-between ps-3 pe-1 pb-3.5 -mt-1">
<Component :is="components.CalendarHeader" :class="calendarHeaderClass">
<Component :is="components.CalendarHeading" class="text-sm font-medium text-gray-925 dark:text-white" />
<div>
<Component
Expand All @@ -111,7 +125,7 @@ const gridStyle = computed(() => {
:is="components.CalendarGrid"
v-for="month in grid"
:key="month.value.toString()"
class="w-full border-collapse space-y-1 select-none"
:class="calendarGridClass"
>
<Component :is="components.CalendarGridHead">
<ui-badge class="mb-2" v-if="numberOfMonths > 1">
Expand Down
126 changes: 125 additions & 1 deletion resources/js/components/ui/DatePicker/DatePicker.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup>
import { config } from '@api';
import { computed } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { getLocalTimeZone, now, parseAbsolute, toCalendarDate } from '@internationalized/date';
import {
DatePickerAnchor,
DatePickerContent,
Expand Down Expand Up @@ -86,6 +87,9 @@ const inputEvents = computed(() => ({
},
}));

/** Synced with DatePickerRoot so we can re-open after "Today" despite close-on-select. */
const pickerOpen = ref(false);

const calendarEvents = computed(() => ({
'update:model-value': (event) => {
if (props.granularity === 'day') {
Expand All @@ -99,6 +103,99 @@ const calendarEvents = computed(() => ({
},
}));

/** When `min` is later than today, "today" is outside the allowed range. */
const isTodayBeforeMin = computed(() => {
if (props.min == null) {
return false;
}
try {
const minValue = typeof props.min === 'string' ? parseAbsolute(props.min) : props.min;
const todayCal = toCalendarDate(now(getLocalTimeZone()));
const minCal = toCalendarDate(minValue);
return todayCal.compare(minCal) < 0;
} catch {
return false;
}
});

/** When `max` is earlier than today, "today" is outside the allowed range. */
const isTodayAfterMax = computed(() => {
if (props.max == null) {
return false;
}
try {
const maxValue = typeof props.max === 'string' ? parseAbsolute(props.max) : props.max;
const todayCal = toCalendarDate(now(getLocalTimeZone()));
const maxCal = toCalendarDate(maxValue);
return todayCal.compare(maxCal) > 0;
} catch {
return false;
}
});

const todayShortcutDisabled = computed(
() => props.disabled || props.readOnly || isTodayBeforeMin.value || isTodayAfterMax.value,
);

const isTodaySelected = computed(() => {
const mv = props.modelValue;
if (mv == null || typeof mv !== 'object') {
return false;
}
try {
const todayCal = toCalendarDate(now(getLocalTimeZone()));
const selectedCal = toCalendarDate(mv);
return (
selectedCal.year === todayCal.year &&
selectedCal.month === todayCal.month &&
selectedCal.day === todayCal.day
);
} catch {
return false;
}
});

const todayShortcutLabel = computed(() => {
if (props.inline) {
return __('Today');
}
return isTodaySelected.value ? __('Apply') : __('Today');
});

const emitTodayValue = () => {
if (todayShortcutDisabled.value) {
return;
}
let value = now(getLocalTimeZone()).set({ millisecond: 0 });
if (props.granularity === 'day') {
value = value.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
}

emit('update:modelValue', value);

if (!props.inline) {
nextTick(() => {
pickerOpen.value = true;
});
}
};

const onTodayShortcutClick = () => {
if (todayShortcutDisabled.value) {
return;
}
if (isTodaySelected.value) {
if (!props.inline) {
pickerOpen.value = false;
} else {
// Inline has no popover to dismiss; re-apply today's value so "Apply" does something useful.
emitTodayValue();
}
return;
}
emitTodayValue();
};

const timeZoneName = computed(() => props.modelValue?.timeZone ?? null);

const timeZoneLabel = computed(() => {
Expand Down Expand Up @@ -153,6 +250,7 @@ const getInputLabel = (part) => {
:aria-label="__('Date picker')"
:aria-required="required"
close-on-select
v-model:open="pickerOpen"
>
<DatePickerField v-slot="{ segments }" class="w-full">
<DatePickerAnchor as-child>
Expand Down Expand Up @@ -233,11 +331,37 @@ const getInputLabel = (part) => {
>
<Card class="w-[20rem]">
<Calendar v-bind="calendarBindings" v-on="calendarEvents" />
<div
class="flex justify-end"
>
<Button
type="button"
variant="subtle"
size="2xs"
class="me-1"
:text="todayShortcutLabel"
:disabled="todayShortcutDisabled"
@click="onTodayShortcutClick"
/>
</div>
</Card>
</DatePickerContent>

<Card v-if="inline" class="mt-2">
<Calendar v-bind="calendarBindings" v-on="calendarEvents" />
<div
class="flex justify-end"
>
<Button
type="button"
variant="subtle"
size="2xs"
class="-me-1"
:text="todayShortcutLabel"
:disabled="todayShortcutDisabled"
@click="onTodayShortcutClick"
/>
</div>
</Card>
</DatePickerRoot>
</div>
Expand Down
Loading
Loading