event-calendar
Event calendar with create and edit dialog
Month calendar with event CRUD in one dialog: open from an empty day, an event, or Add event, commit via apiRef. Title, color, start, duration, all-day fields. ReUI EventCalendar, Nav, Toolbar; shadcn Dialog, Field, Select, Switch. Scheduling apps.
Загрузка превью…
src/event-calendar/c-event-calendar-3.vue
<!--
ПОРТ c-event-calendar-3 ("Event calendar with create and edit dialog").
Диалог закрыт по умолчанию (`open = false`, `draft = null`), `DialogContent`
в оригинале рендерится только внутри `{draft && ...}` — здесь то же самое
через `v-if="draft"` внутри `<Dialog v-model:open="open">`; закрытое
состояние не монтирует `DialogContent` вовсе (Radix/reka Portal+Presence),
поэтому диф проверяет только тулбар с кнопкой "Add event" (см.
docs/PORTING.md §10/§31 — тот же класс плавающего слоя). IconPlaceholder
("plus") заменён инлайновым `<svg>` (путь lucide "plus" v0.545.0).
Month-вид получает `maxEventsPerCell: 3` через `view-config`-проп
`EventCalendar` (см. c-event-calendar-1.vue и комментарий в
`EventCalendar.vue`). `timeZone="UTC"` и фиксированный якорь
`Date.UTC(2026, 5, 15)` — те же причины, что и в `ui-event-calendar`.
-->
<script setup lang="ts">
import { ref } from "vue"
import {
addDays,
addMinutes,
differenceInMinutes,
format,
setHours,
startOfDay,
startOfWeek,
} from "date-fns"
import { EventCalendar, EventCalendarContent, EventCalendarNav, type CalendarEvent, type EventCalendarApi, type EventCalendarOccurrence, type EventCalendarSlotInfo } from "@/components/reui/event-calendar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
const ANCHOR = new Date(Date.UTC(2026, 5, 15))
const COLORS = [
{ value: "var(--color-blue-500)", label: "Blue" },
{ value: "var(--color-violet-500)", label: "Violet" },
{ value: "var(--color-emerald-500)", label: "Emerald" },
{ value: "var(--color-amber-500)", label: "Amber" },
{ value: "var(--color-rose-500)", label: "Rose" },
]
const START_HOURS = Array.from({ length: 13 }, (_, i) => i + 7)
const DURATIONS = [
{ value: 30, label: "30 min" },
{ value: 60, label: "1 hour" },
{ value: 90, label: "1.5 hours" },
{ value: 120, label: "2 hours" },
]
interface EventDraft {
id: string | null
title: string
date: Date
startHour: number
duration: number
allDay: boolean
color: string
}
function buildEvents(anchor: Date): CalendarEvent[] {
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 })
const at = (dayOffset: number, hour: number) => setHours(addDays(week, dayOffset), hour)
return [
{ id: "kickoff", title: "Project kickoff", start: at(1, 10), end: at(1, 11), color: COLORS[0]!.value },
{ id: "1on1", title: "1:1 with Mia", start: at(2, 14), end: at(2, 15), color: COLORS[1]!.value },
{ id: "review", title: "Design review", start: at(4, 11), end: at(4, 12), color: COLORS[2]!.value },
{ id: "standup", title: "Team standup", start: at(3, 9), end: addMinutes(at(3, 9), 30), color: COLORS[3]!.value },
{ id: "interview", title: "Candidate interview", start: at(5, 13), end: at(5, 14), color: COLORS[4]!.value },
{ id: "retro", title: "Sprint retro", start: at(5, 16), end: at(5, 17), color: COLORS[1]!.value },
]
}
const events = buildEvents(ANCHOR)
const calRef = ref<{ api: EventCalendarApi } | null>(null)
let counter = 0
const open = ref(false)
const draft = ref<EventDraft | null>(null)
function seedCreate(date: Date) {
draft.value = {
id: null,
title: "",
date: startOfDay(date),
startHour: 9,
duration: 60,
allDay: false,
color: COLORS[0]!.value,
}
open.value = true
}
function openCreate(slot: EventCalendarSlotInfo) {
seedCreate(slot.date)
}
function openEdit(occurrence: EventCalendarOccurrence) {
const event = occurrence.event
draft.value = {
id: event.id,
title: event.title,
date: startOfDay(event.start),
startHour: event.start.getHours(),
duration: Math.max(30, differenceInMinutes(event.end, event.start)),
allDay: event.allDay ?? false,
color: event.color ?? COLORS[0]!.value,
}
open.value = true
}
function save() {
const api = calRef.value?.api
const d = draft.value
if (!api || !d || !d.title.trim()) return
const start = d.allDay ? d.date : setHours(d.date, d.startHour)
const end = d.allDay ? addDays(d.date, 1) : addMinutes(start, d.duration)
const patch = { title: d.title.trim(), start, end, allDay: d.allDay, color: d.color }
if (d.id === null) {
api.addEvent({ id: `evt-${counter++}`, ...patch })
} else {
api.updateEvent(d.id, patch)
}
open.value = false
}
function remove() {
const api = calRef.value?.api
if (!api || !draft.value?.id) return
api.removeEvent(draft.value.id)
open.value = false
}
</script>
<template>
<div class="w-full p-4">
<Card class="w-full py-0">
<CardContent class="p-0">
<EventCalendar
ref="calRef"
:events="events"
view="month"
:on-slot-click="openCreate"
:on-event-click="(occurrence: EventCalendarOccurrence, e: MouseEvent) => { e.preventDefault(); openEdit(occurrence) }"
:interactions="{ drag: true, resize: true, selectSlot: false }"
:view-config="{ maxEventsPerCell: 3 }"
time-zone="UTC"
class="h-[640px] w-full"
>
<div class="flex flex-wrap items-center gap-2 pe-2">
<EventCalendarNav class="min-w-0 flex-1" />
<div data-slot="event-calendar-toolbar" class="flex items-center gap-2">
<Button size="sm" @click="seedCreate(ANCHOR)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
Add event
</Button>
</div>
</div>
<EventCalendarContent />
</EventCalendar>
</CardContent>
</Card>
<Dialog v-model:open="open">
<DialogContent v-if="draft" class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ draft.id != null ? "Edit event" : "New event" }}</DialogTitle>
<DialogDescription>{{ format(draft.date, "EEEE, MMMM d, yyyy") }}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel for="ec-crud-title">Title</FieldLabel>
<Input id="ec-crud-title" placeholder="Add a title" v-model="draft.title" autofocus />
</Field>
<Field>
<FieldLabel>Color</FieldLabel>
<div class="flex gap-2">
<button
v-for="color in COLORS"
:key="color.value"
type="button"
:aria-label="color.label"
:aria-pressed="draft.color === color.value"
:style="{ backgroundColor: color.value }"
:class="cn('ring-offset-background size-6 rounded-full transition', draft.color === color.value && 'ring-ring ring-2 ring-offset-2')"
@click="draft.color = color.value"
/>
</div>
</Field>
<div v-if="!draft.allDay" class="grid grid-cols-2 gap-3">
<Field>
<FieldLabel for="ec-crud-start">Start</FieldLabel>
<Select :model-value="String(draft.startHour)" @update:model-value="(v: unknown) => { if (draft) draft.startHour = Number(v as string) }">
<SelectTrigger id="ec-crud-start" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="hour in START_HOURS" :key="hour" :value="String(hour)">{{ `${String(hour).padStart(2, "0")}:00` }}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel for="ec-crud-duration">Duration</FieldLabel>
<Select :model-value="String(draft.duration)" @update:model-value="(v: unknown) => { if (draft) draft.duration = Number(v as string) }">
<SelectTrigger id="ec-crud-duration" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in DURATIONS" :key="option.value" :value="String(option.value)">{{ option.label }}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field orientation="horizontal">
<FieldLabel for="ec-crud-allday" class="font-normal">All day</FieldLabel>
<Switch id="ec-crud-allday" v-model="draft.allDay" />
</Field>
</FieldGroup>
<DialogFooter class="sm:justify-between">
<Button v-if="draft.id != null" variant="ghost" size="sm" class="text-destructive hover:text-destructive" @click="remove">Delete</Button>
<span v-else />
<div class="flex gap-2">
<DialogClose as-child>
<Button variant="outline" size="sm">Cancel</Button>
</DialogClose>
<Button size="sm" :disabled="!draft.title.trim()" @click="save">{{ draft.id != null ? "Save changes" : "Create event" }}</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>