Skip to content

Commit dd6f6f0

Browse files
committed
feat(calendar): add month/year drill-down navigation
Click the heading in the day view to surface a 12-month grid; click the year heading there to surface a 10-year grid (decade boundaries match Grafana, e.g. 2021–2030). Prev/next page by month, year, and decade respectively. Replaces the prior native-select dropdowns in the calendar header.
1 parent b9a021a commit dd6f6f0

4 files changed

Lines changed: 406 additions & 135 deletions

File tree

Lines changed: 118 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
<script lang="ts" setup>
22
import type { CalendarRootEmits, CalendarRootProps, DateValue } from "reka-ui"
33
import type { HTMLAttributes, Ref } from "vue"
4-
import type { LayoutTypes } from "."
4+
import { ref } from "vue"
55
import { getLocalTimeZone, today } from "@internationalized/date"
6-
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core"
6+
import { reactiveOmit, useVModel } from "@vueuse/core"
77
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui"
8-
import { createYear, createYearRange, toDate } from "reka-ui/date"
9-
import { computed, toRaw } from "vue"
10-
import { cn } from "@/lib/utils"
11-
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
12-
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
8+
import { toDate } from "reka-ui/date"
9+
import {
10+
CalendarCell,
11+
CalendarCellTrigger,
12+
CalendarGrid,
13+
CalendarGridBody,
14+
CalendarGridHead,
15+
CalendarGridRow,
16+
CalendarHeadCell,
17+
CalendarHeader,
18+
CalendarNextButton,
19+
CalendarPrevButton,
20+
} from "."
21+
import CalendarMonthGrid from "./CalendarMonthGrid.vue"
22+
import CalendarYearGrid from "./CalendarYearGrid.vue"
1323
14-
const props = withDefaults(defineProps<CalendarRootProps & { class?: HTMLAttributes["class"], layout?: LayoutTypes, yearRange?: DateValue[] }>(), {
15-
modelValue: undefined,
16-
layout: undefined,
17-
})
24+
const props = withDefaults(
25+
defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>(),
26+
{
27+
modelValue: undefined,
28+
},
29+
)
1830
const emits = defineEmits<CalendarRootEmits>()
1931
20-
const delegatedProps = reactiveOmit(props, "class", "layout", "placeholder")
32+
const delegatedProps = reactiveOmit(props, "class", "placeholder")
2133
2234
const placeholder = useVModel(props, "placeholder", emits, {
2335
passive: true,
@@ -26,137 +38,110 @@ const placeholder = useVModel(props, "placeholder", emits, {
2638
2739
const formatter = useDateFormatter(props.locale ?? "en")
2840
29-
const yearRange = computed(() => {
30-
return props.yearRange ?? createYearRange({
31-
start: props?.minValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
32-
.cycle("year", -100),
41+
type View = "days" | "months" | "years"
42+
const view = ref<View>("days")
3343
34-
end: props?.maxValue ?? (toRaw(props.placeholder) ?? props.defaultPlaceholder ?? today(getLocalTimeZone()))
35-
.cycle("year", 10),
36-
})
37-
})
44+
function drillUp() {
45+
if (view.value === "days") view.value = "months"
46+
else if (view.value === "months") view.value = "years"
47+
}
3848
39-
const [DefineMonthTemplate, ReuseMonthTemplate] = createReusableTemplate<{ date: DateValue }>()
40-
const [DefineYearTemplate, ReuseYearTemplate] = createReusableTemplate<{ date: DateValue }>()
49+
function step(direction: -1 | 1) {
50+
const amount = view.value === "months" ? direction : direction * 10
51+
placeholder.value = placeholder.value.add({ years: amount })
52+
}
53+
54+
function pickMonth(month: number) {
55+
placeholder.value = placeholder.value.set({ month })
56+
view.value = "days"
57+
}
58+
59+
function pickYear(year: number) {
60+
placeholder.value = placeholder.value.set({ year })
61+
view.value = "months"
62+
}
4163
4264
const forwarded = useForwardPropsEmits(delegatedProps, emits)
4365
</script>
4466

4567
<template>
46-
<DefineMonthTemplate v-slot="{ date }">
47-
<div class="**:data-[slot=native-select-icon]:right-1">
48-
<div class="relative">
49-
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
50-
{{ formatter.custom(toDate(date), { month: 'short' }) }}
51-
</div>
52-
<NativeSelect
53-
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
54-
:model-value="date.month"
55-
@change="(e: Event) => {
56-
placeholder = placeholder.set({
57-
month: Number((e?.target as any)?.value),
58-
})
59-
}"
68+
<div :class="props.class" data-slot="calendar">
69+
<CalendarRoot
70+
v-show="view === 'days'"
71+
v-slot="{ grid, weekDays }"
72+
v-bind="forwarded"
73+
v-model:placeholder="placeholder"
74+
class="p-3"
75+
>
76+
<CalendarHeader class="pt-0">
77+
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between pointer-events-none">
78+
<CalendarPrevButton class="pointer-events-auto">
79+
<slot name="calendar-prev-icon" />
80+
</CalendarPrevButton>
81+
<CalendarNextButton class="pointer-events-auto">
82+
<slot name="calendar-next-icon" />
83+
</CalendarNextButton>
84+
</nav>
85+
<button
86+
type="button"
87+
aria-label="Switch to month view"
88+
class="block mx-auto h-7 px-2 text-sm font-medium rounded-md hover:bg-accent"
89+
@click="drillUp"
6090
>
61-
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
62-
{{ formatter.custom(toDate(month), { month: 'short' }) }}
63-
</NativeSelectOption>
64-
</NativeSelect>
65-
</div>
66-
</div>
67-
</DefineMonthTemplate>
91+
{{ formatter.custom(toDate(placeholder), { month: 'long', year: 'numeric' }) }}
92+
</button>
93+
</CalendarHeader>
6894

69-
<DefineYearTemplate v-slot="{ date }">
70-
<div class="**:data-[slot=native-select-icon]:right-1">
71-
<div class="relative">
72-
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
73-
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
74-
</div>
75-
<NativeSelect
76-
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
77-
:model-value="date.year"
78-
@change="(e: Event) => {
79-
placeholder = placeholder.set({
80-
year: Number((e?.target as any)?.value),
81-
})
82-
}"
83-
>
84-
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
85-
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
86-
</NativeSelectOption>
87-
</NativeSelect>
95+
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
96+
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
97+
<CalendarGridHead>
98+
<CalendarGridRow>
99+
<CalendarHeadCell v-for="day in weekDays" :key="day">
100+
{{ day }}
101+
</CalendarHeadCell>
102+
</CalendarGridRow>
103+
</CalendarGridHead>
104+
<CalendarGridBody>
105+
<CalendarGridRow
106+
v-for="(weekDates, index) in month.rows"
107+
:key="`weekDate-${index}`"
108+
class="mt-2 w-full"
109+
>
110+
<CalendarCell
111+
v-for="weekDate in weekDates"
112+
:key="weekDate.toString()"
113+
:date="weekDate"
114+
>
115+
<CalendarCellTrigger :day="weekDate" :month="month.value" />
116+
</CalendarCell>
117+
</CalendarGridRow>
118+
</CalendarGridBody>
119+
</CalendarGrid>
88120
</div>
89-
</div>
90-
</DefineYearTemplate>
91-
92-
<CalendarRoot
93-
v-slot="{ grid, weekDays, date }"
94-
v-bind="forwarded"
95-
v-model:placeholder="placeholder"
96-
data-slot="calendar"
97-
:class="cn('p-3', props.class)"
98-
>
99-
<CalendarHeader class="pt-0">
100-
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
101-
<CalendarPrevButton>
102-
<slot name="calendar-prev-icon" />
103-
</CalendarPrevButton>
104-
<CalendarNextButton>
105-
<slot name="calendar-next-icon" />
106-
</CalendarNextButton>
107-
</nav>
121+
</CalendarRoot>
108122

109-
<slot name="calendar-heading" :date="date" :month="ReuseMonthTemplate" :year="ReuseYearTemplate">
110-
<template v-if="layout === 'month-and-year'">
111-
<div class="flex items-center justify-center gap-1">
112-
<ReuseMonthTemplate :date="date" />
113-
<ReuseYearTemplate :date="date" />
114-
</div>
115-
</template>
116-
<template v-else-if="layout === 'month-only'">
117-
<div class="flex items-center justify-center gap-1">
118-
<ReuseMonthTemplate :date="date" />
119-
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
120-
</div>
121-
</template>
122-
<template v-else-if="layout === 'year-only'">
123-
<div class="flex items-center justify-center gap-1">
124-
{{ formatter.custom(toDate(date), { month: 'short' }) }}
125-
<ReuseYearTemplate :date="date" />
126-
</div>
127-
</template>
128-
<template v-else>
129-
<CalendarHeading />
130-
</template>
131-
</slot>
132-
</CalendarHeader>
123+
<CalendarMonthGrid
124+
v-if="view === 'months'"
125+
:placeholder="placeholder"
126+
:model-value="props.modelValue"
127+
:min-value="props.minValue"
128+
:max-value="props.maxValue"
129+
:locale="props.locale"
130+
@prev="step(-1)"
131+
@next="step(1)"
132+
@drill-up="drillUp"
133+
@pick="pickMonth"
134+
/>
133135

134-
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
135-
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
136-
<CalendarGridHead>
137-
<CalendarGridRow>
138-
<CalendarHeadCell
139-
v-for="day in weekDays" :key="day"
140-
>
141-
{{ day }}
142-
</CalendarHeadCell>
143-
</CalendarGridRow>
144-
</CalendarGridHead>
145-
<CalendarGridBody>
146-
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
147-
<CalendarCell
148-
v-for="weekDate in weekDates"
149-
:key="weekDate.toString()"
150-
:date="weekDate"
151-
>
152-
<CalendarCellTrigger
153-
:day="weekDate"
154-
:month="month.value"
155-
/>
156-
</CalendarCell>
157-
</CalendarGridRow>
158-
</CalendarGridBody>
159-
</CalendarGrid>
160-
</div>
161-
</CalendarRoot>
136+
<CalendarYearGrid
137+
v-if="view === 'years'"
138+
:placeholder="placeholder"
139+
:model-value="props.modelValue"
140+
:min-value="props.minValue"
141+
:max-value="props.maxValue"
142+
@prev="step(-1)"
143+
@next="step(1)"
144+
@pick="pickYear"
145+
/>
146+
</div>
162147
</template>

0 commit comments

Comments
 (0)