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>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-event-calendar-3.json

npm-зависимости

  • date-fns

Источник: порт из ReUI (Keenthemes, MIT)