UIPackage

Event Calendar

block dashboard
Edit on GitHub

Month-view event calendar with stats strip, drag/shift range select, side rail (selected day or range summary), upcoming list, and per-cell/per-event context menus. v-model:events binds the source of truth; eventTypes config maps each type key to its label/icon/color theme so swapping the visual palette is one prop. Built on the use-month-grid headless composable.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/event-calendar.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/event-calendar

Examples

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CalendarEvent
interface CalendarEvent {
  id: string
  title: string
  /** YYYY-MM-DD */
  date: string
  /** HH:mm */
  start: string
  /** HH:mm */
  end: string
  type: string
  description?: string
  location?: string
  attendees?: string[]
  /** Free-form status tag rendered next to the type label in the dialog. */
  status?: string
}
EventTypeMeta
interface EventTypeMeta {
  label: string
  /** Lucide icon component for the stat tile + chip + dialog header. */
  icon: Component
  /** Tailwind classes for the colored chip on event rows + dialog header.
   *  Shape: `bg-X/10 text-X ring-X/20` is what the default theme uses. */
  chip: string
  /** Tailwind class for the vertical bar to the left of side-rail rows. */
  bar: string
  /** Tailwind class for the legend / stat-tile dot. */
  dot: string
  /** Tailwind class for the soft watermark glow behind stat tiles. */
  glow: string
  /** Tailwind class for the small text accent on "Next: ..." date labels. */
  text: string
  /** Tailwind class for the stat-tile hover ring. */
  ring: string
}

Files (4)

  • app/components/blocks/event-calendar/EventCalendar.vue 37.3 kB
    <script setup lang="ts">
    /**
     * EventCalendar -- a full month-view event calendar with stats strip,
     * drag/shift range select, side rail (selected day or range summary),
     * upcoming list, and per-event/per-cell context menus.
     *
     * Configurable surface:
     *   - v-model:events  CalendarEvent[]   the source of truth
     *   - :event-types    Record<typeKey, EventTypeMeta>  visual config per type
     *   - :initial-date   YYYY-MM-DD        which month opens first
     *   - :week-starts-on 0 | 1              Sunday vs Monday
     *   - :loading                          skeleton state during fetch
     *   - :title / :description / :show-* toggles per pane
     *
     * Slots:
     *   - #header-actions     extra controls to the right of search/view/new
     *   - #event-dialog       full override of the detail dialog body
     *   - #empty              text shown when a selected day has no events
     *
     * Emits:
     *   - event-click(event)
     *   - event-create(dateKey)             requested from "New event" or
     *                                       the per-cell context menu
     *   - event-edit / event-duplicate / event-delete(event)
     *   - range-change({ lo, hi })          fires when the selection range moves
     */
    import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
    import {
      ChevronLeft,
      ChevronRight,
      Plus,
      Clock,
      MapPin,
      Users,
      Search,
      CalendarDays,
      ListFilter,
      Sparkles,
      Copy,
      X,
      CalendarPlus,
      ArrowRight,
      MousePointer2,
      Pencil,
      Trash2,
      Eye,
      CopyPlus,
    } from 'lucide-vue-next'
    import { useMonthGrid, dateFromKey } from '@/composables/useMonthGrid'
    import { Button } from '@/components/ui/button'
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
    } from '@/components/ui/dialog'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { Avatar, AvatarFallback } from '@/components/ui/avatar'
    import { Separator } from '@/components/ui/separator'
    import { Skeleton } from '@/components/ui/skeleton'
    import { Input } from '@/components/ui/input'
    import {
      ContextMenu,
      ContextMenuContent,
      ContextMenuItem,
      ContextMenuSeparator,
      ContextMenuShortcut,
      ContextMenuTrigger,
      ContextMenuLabel,
    } from '@/components/ui/context-menu'
    import type { CalendarEvent, EventTypeMeta } from './types'
    import { defaultEventTypes } from './defaults'
    
    const props = withDefaults(
      defineProps<{
        /** Source of truth. Pair with v-model:events. */
        events?: CalendarEvent[]
        /** Visual + semantic theme per event type. Falls back to defaultEventTypes. */
        eventTypes?: Record<string, EventTypeMeta>
        /** YYYY-MM-DD -- month to open first. Defaults to today. */
        initialDate?: string
        /** 0 = Sunday (default), 1 = Monday. */
        weekStartsOn?: 0 | 1
        /** Show the skeleton placeholders instead of cells / rail rows. */
        loading?: boolean
        title?: string
        description?: string
        /** Default = true. Stats strip across the top. */
        showStats?: boolean
        /** Default = true. Side rail to the right of the grid. */
        showSideRail?: boolean
        /** Default = true. Search input in the header. */
        showSearch?: boolean
        /** Default = true. Month/Week/Day select. Week/Day not implemented yet
         *  -- the select is purely visual until you wire up the modes. */
        showViewSelect?: boolean
        /** Default = true. Legend strip under the grid. */
        showLegend?: boolean
        /** Default = true. "Up next" pane under the side rail. */
        showUpcoming?: boolean
      }>(),
      {
        events: () => [],
        eventTypes: () => defaultEventTypes,
        weekStartsOn: 0,
        loading: false,
        title: 'Calendar',
        description: 'Schedule, meetings, and deadlines. Drag or shift-click to select a range.',
        showStats: true,
        showSideRail: true,
        showSearch: true,
        showViewSelect: true,
        showLegend: true,
        showUpcoming: true,
      },
    )
    
    const emit = defineEmits<{
      'update:events': [value: CalendarEvent[]]
      'event-click': [event: CalendarEvent]
      'event-create': [dateKey: string]
      'event-edit': [event: CalendarEvent]
      'event-duplicate': [event: CalendarEvent]
      'event-delete': [event: CalendarEvent]
      'range-change': [bounds: { lo: string; hi: string }]
    }>()
    
    const view = ref<'month' | 'week' | 'day'>('month')
    const eventOpen = ref(false)
    const selectedEvent = ref<CalendarEvent | null>(null)
    const search = ref('')
    
    const {
      todayKey,
      cursor,
      monthLabel,
      gridDays,
      weekdays,
      rangeStart,
      rangeBounds,
      rangeDayCount,
      isRange,
      inRange,
      prevMonth,
      nextMonth,
      goToToday,
      selectWeekOf,
      clearRange,
      onCellMouseDown,
      onCellMouseEnter,
      endDrag,
    } = useMonthGrid({ initialDate: props.initialDate, weekStartsOn: props.weekStartsOn })
    
    watch(rangeBounds, (b) => emit('range-change', b))
    
    // Fall through to a neutral style for unknown event types so the calendar
    // doesn't crash when a consumer passes a type that's missing from the map.
    const neutralMeta: EventTypeMeta = {
      label: 'Event',
      icon: CalendarDays,
      chip: 'bg-muted text-muted-foreground ring-muted-foreground/20',
      bar: 'bg-muted-foreground',
      dot: 'bg-muted-foreground',
      glow: 'bg-muted-foreground/10',
      text: 'text-muted-foreground',
      ring: 'hover:ring-muted-foreground/30',
    }
    function metaFor(type: string): EventTypeMeta {
      return props.eventTypes[type] ?? neutralMeta
    }
    
    const typeKeys = computed(() => Object.keys(props.eventTypes))
    
    const filtered = computed(() => {
      const q = search.value.trim().toLowerCase()
      if (!q) return props.events
      return props.events.filter(
        (e) =>
          e.title.toLowerCase().includes(q) ||
          (e.description ?? '').toLowerCase().includes(q) ||
          (e.location ?? '').toLowerCase().includes(q),
      )
    })
    
    const eventsByDate = computed(() => {
      const map = new Map<string, CalendarEvent[]>()
      for (const e of filtered.value) {
        if (!map.has(e.date)) map.set(e.date, [])
        map.get(e.date)!.push(e)
      }
      for (const arr of map.values()) arr.sort((a, b) => a.start.localeCompare(b.start))
      return map
    })
    
    const monthCounts = computed(() => {
      const y = cursor.value.getFullYear()
      const m = cursor.value.getMonth()
      const out: Record<string, number> & { total: number } = { total: 0 }
      for (const k of typeKeys.value) out[k] = 0
      for (const e of props.events) {
        const d = new Date(e.date)
        if (d.getFullYear() === y && d.getMonth() === m) {
          if (out[e.type] !== undefined) out[e.type]!++
          out.total++
        }
      }
      return out
    })
    
    const typeStats = computed(() => {
      const y = cursor.value.getFullYear()
      const m = cursor.value.getMonth()
      const out: Record<string, { weeks: number[]; next: CalendarEvent | null }> = {}
      for (const k of typeKeys.value) out[k] = { weeks: [0, 0, 0, 0, 0, 0], next: null }
      const firstOffset = new Date(y, m, 1).getDay()
    
      for (const e of props.events) {
        const d = new Date(e.date)
        if (d.getFullYear() === y && d.getMonth() === m && out[e.type]) {
          const week = Math.floor((d.getDate() - 1 + firstOffset) / 7)
          out[e.type]!.weeks[week]!++
        }
      }
      for (const t of typeKeys.value) {
        const next = props.events
          .filter((e) => e.type === t && e.date >= todayKey)
          .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))[0]
        out[t]!.next = next ?? null
      }
      return out
    })
    
    function fmtNextDate(key: string) {
      if (key === todayKey) return 'Today'
      return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
    }
    
    const rangeEvents = computed(() => {
      const { lo, hi } = rangeBounds.value
      return filtered.value
        .filter((e) => e.date >= lo && e.date <= hi)
        .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))
    })
    
    const rangeTypeCounts = computed(() => {
      const init: Record<string, number> = {}
      for (const k of typeKeys.value) init[k] = 0
      for (const e of rangeEvents.value) {
        if (init[e.type] !== undefined) init[e.type]!++
      }
      return init
    })
    
    const selectedDayEvents = computed(() => eventsByDate.value.get(rangeStart.value) ?? [])
    
    const upcoming = computed(() =>
      filtered.value
        .filter((e) => e.date > todayKey)
        .sort((a, b) => a.date.localeCompare(b.date) || a.start.localeCompare(b.start))
        .slice(0, 4),
    )
    
    function openEvent(e: CalendarEvent) {
      selectedEvent.value = e
      eventOpen.value = true
      emit('event-click', e)
    }
    
    function copyDate(key: string) {
      if (typeof navigator !== 'undefined' && navigator.clipboard) {
        navigator.clipboard.writeText(key).catch(() => {
          /* clipboard may be denied; nothing to do */
        })
      }
    }
    
    function fmtDayLong(key: string) {
      return dateFromKey(key).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
    }
    function fmtDayShort(key: string) {
      return dateFromKey(key).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
    }
    
    function initials(name: string) {
      return name
        .split(' ')
        .map((n) => n[0])
        .join('')
        .slice(0, 2)
    }
    
    function cellRangeClass(key: string, inMonth: boolean) {
      const { lo, hi } = rangeBounds.value
      if (!inRange(key)) return inMonth ? 'bg-background hover:bg-accent/40' : 'bg-muted/20 hover:bg-muted/30'
      if (key === lo && key === hi) return 'bg-accent/30 ring-1 ring-inset ring-primary/60'
      let cls = 'bg-primary/10 hover:bg-primary/15'
      if (key === lo) cls += ' ring-1 ring-inset ring-primary/60'
      if (key === hi) cls += ' ring-1 ring-inset ring-primary/60'
      return cls
    }
    
    // Window listeners are also used inside useMonthGrid for mouseup; mirror
    // them at the block level so the drag also ends when the user releases
    // the mouse outside the grid surface.
    onMounted(() => {
      if (typeof window !== 'undefined') {
        window.addEventListener('mouseup', endDrag)
        window.addEventListener('mouseleave', endDrag)
      }
    })
    onBeforeUnmount(() => {
      if (typeof window !== 'undefined') {
        window.removeEventListener('mouseup', endDrag)
        window.removeEventListener('mouseleave', endDrag)
      }
    })
    </script>
    
    <template>
      <div class="flex flex-col gap-5" @mouseup="endDrag">
        <!-- Header -->
        <header class="flex flex-wrap items-end justify-between gap-4">
          <div>
            <h1 class="text-2xl font-semibold tracking-tight">{{ title }}</h1>
            <p v-if="description" class="text-muted-foreground mt-1 text-sm">{{ description }}</p>
          </div>
          <div class="flex items-center gap-2">
            <div v-if="showSearch" class="relative">
              <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
              <Input v-model="search" placeholder="Search events" class="h-8 w-56 pl-8 text-xs" />
            </div>
            <Select v-if="showViewSelect" v-model="view">
              <SelectTrigger class="h-8 w-24 text-xs">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="month">Month</SelectItem>
                <SelectItem value="week">Week</SelectItem>
                <SelectItem value="day">Day</SelectItem>
              </SelectContent>
            </Select>
            <slot name="header-actions">
              <Button size="sm" class="gap-1.5" @click="emit('event-create', rangeStart)">
                <Plus class="size-3.5" />
                New event
              </Button>
            </slot>
          </div>
        </header>
    
        <!-- Stats strip -->
        <div
          v-if="showStats"
          class="grid grid-cols-1 gap-3"
          :class="`sm:grid-cols-2 lg:grid-cols-${Math.min(typeKeys.length, 4)}`"
        >
          <button
            v-for="key in typeKeys"
            :key="key"
            type="button"
            :class="[
              'group bg-card/40 hover:bg-card relative isolate flex flex-col overflow-hidden rounded-xl border p-4 text-left ring-1 ring-transparent backdrop-blur transition-all ring-inset hover:-translate-y-px hover:shadow-lg',
              eventTypes[key]!.ring,
            ]"
            @click="
              search =
                eventTypes[key]!.label.toLowerCase() === search.toLowerCase() ? '' : eventTypes[key]!.label.toLowerCase()
            "
          >
            <component
              :is="eventTypes[key]!.icon"
              :class="[
                'pointer-events-none absolute -top-4 -right-4 size-24 opacity-[0.06] transition-opacity group-hover:opacity-[0.12]',
                eventTypes[key]!.text,
              ]"
            />
            <div
              :class="[
                'pointer-events-none absolute -right-12 -bottom-12 size-32 rounded-full blur-2xl',
                eventTypes[key]!.glow,
              ]"
            />
    
            <div class="flex items-center justify-between gap-2">
              <div class="flex items-center gap-1.5">
                <span :class="['size-1.5 rounded-full', eventTypes[key]!.dot]" />
                <p class="text-muted-foreground text-[10px] font-medium tracking-[0.14em] uppercase">
                  {{ eventTypes[key]!.label }}
                </p>
              </div>
              <div :class="['flex size-7 items-center justify-center rounded-md ring-1 ring-inset', eventTypes[key]!.chip]">
                <component :is="eventTypes[key]!.icon" class="size-3.5" />
              </div>
            </div>
    
            <div class="mt-2 flex items-baseline gap-1.5">
              <p class="text-3xl leading-none font-semibold tabular-nums">{{ monthCounts[key] ?? 0 }}</p>
              <p class="text-muted-foreground text-[10px] tracking-wider uppercase">this month</p>
            </div>
    
            <div class="mt-3 flex h-6 items-end gap-1">
              <span
                v-for="(n, i) in typeStats[key]!.weeks.slice(0, 6)"
                :key="i"
                :class="[
                  'flex-1 rounded-sm transition-colors',
                  n > 0 ? eventTypes[key]!.bar : 'bg-muted/40',
                  n > 0 && 'opacity-70 group-hover:opacity-100',
                ]"
                :style="{ height: n > 0 ? `${Math.min(100, 30 + n * 35)}%` : '20%' }"
                :title="`Week ${i + 1}: ${n} ${eventTypes[key]!.label.toLowerCase()}${n === 1 ? '' : 's'}`"
              />
            </div>
    
            <div class="border-border/50 mt-3 min-h-[2.25rem] border-t pt-2">
              <template v-if="typeStats[key]!.next">
                <p class="text-muted-foreground text-[10px] tracking-wider uppercase">
                  Next <span :class="eventTypes[key]!.text">{{ fmtNextDate(typeStats[key]!.next!.date) }}</span>
                </p>
                <p class="mt-0.5 truncate text-xs font-medium">{{ typeStats[key]!.next!.title }}</p>
              </template>
              <template v-else>
                <p class="text-muted-foreground text-[10px] tracking-wider uppercase">No upcoming</p>
                <p class="text-muted-foreground/70 mt-0.5 text-xs">All clear</p>
              </template>
            </div>
          </button>
        </div>
    
        <!-- Main: calendar + side rail -->
        <div :class="['grid gap-4', showSideRail && 'lg:grid-cols-[minmax(0,1fr)_320px]']">
          <!-- Month grid -->
          <div class="bg-card/40 overflow-hidden rounded-xl border">
            <div class="bg-muted/30 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5">
              <div class="flex items-center gap-2">
                <Button variant="outline" size="icon" class="size-7" @click="prevMonth"
                  ><ChevronLeft class="size-4"
                /></Button>
                <Button variant="outline" size="icon" class="size-7" @click="nextMonth"
                  ><ChevronRight class="size-4"
                /></Button>
                <Button variant="ghost" size="sm" class="h-7 text-xs" @click="goToToday">Today</Button>
                <h2 class="ml-2 text-sm font-semibold">{{ monthLabel }}</h2>
              </div>
              <div class="text-muted-foreground flex items-center gap-3 text-[11px]">
                <div
                  v-if="isRange"
                  class="bg-primary/10 text-primary ring-primary/20 flex items-center gap-1.5 rounded-full px-2 py-0.5 ring-1 ring-inset"
                >
                  <MousePointer2 class="size-3" />
                  <span>{{ rangeDayCount }} days, {{ rangeEvents.length }} events</span>
                  <button class="hover:text-foreground ml-0.5" @click="clearRange"><X class="size-3" /></button>
                </div>
                <div class="flex items-center gap-1.5">
                  <Sparkles class="size-3" />
                  <span>{{ monthCounts.total }} event{{ monthCounts.total === 1 ? '' : 's' }} this month</span>
                </div>
              </div>
            </div>
    
            <div class="bg-muted/10 text-muted-foreground grid grid-cols-7 border-b text-[10px] tracking-wider uppercase">
              <div v-for="w in weekdays" :key="w" class="px-2 py-2 font-medium">{{ w }}</div>
            </div>
    
            <div class="grid grid-cols-7 select-none">
              <template v-if="loading">
                <div v-for="i in 42" :key="i" class="h-24 border-r border-b p-1.5 last:border-r-0">
                  <Skeleton class="h-3 w-6" />
                  <Skeleton class="mt-2 h-3 w-full" />
                </div>
              </template>
              <template v-else>
                <ContextMenu v-for="(d, i) in gridDays" :key="d.key">
                  <ContextMenuTrigger as-child>
                    <button
                      type="button"
                      :class="[
                        'group focus-visible:ring-ring relative flex h-24 flex-col gap-1 border-r border-b p-1.5 text-left transition-colors focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none',
                        (i + 1) % 7 === 0 && 'border-r-0',
                        i >= 35 && 'border-b-0',
                        cellRangeClass(d.key, d.inMonth),
                        !d.inMonth && !inRange(d.key) && 'text-muted-foreground/60',
                      ]"
                      @mousedown="onCellMouseDown(d.key, $event)"
                      @mouseenter="onCellMouseEnter(d.key)"
                    >
                      <div class="flex items-center justify-between">
                        <span
                          :class="[
                            'inline-flex size-5 items-center justify-center rounded-full text-[11px] tabular-nums',
                            d.key === todayKey && 'bg-primary text-primary-foreground font-semibold',
                            d.key !== todayKey && d.inMonth && 'text-foreground',
                            !d.inMonth && 'text-muted-foreground/60',
                          ]"
                          >{{ d.date.getDate() }}</span
                        >
                        <span
                          v-if="(eventsByDate.get(d.key)?.length ?? 0) > 0"
                          class="text-muted-foreground text-[9px] tabular-nums"
                        >
                          {{ eventsByDate.get(d.key)!.length }}
                        </span>
                      </div>
                      <div class="flex flex-col gap-0.5">
                        <ContextMenu v-for="e in (eventsByDate.get(d.key) ?? []).slice(0, 2)" :key="e.id">
                          <ContextMenuTrigger as-child>
                            <span
                              :class="[
                                'flex cursor-pointer items-center gap-1 truncate rounded px-1 py-0.5 text-[10px] ring-1 ring-inset',
                                metaFor(e.type).chip,
                              ]"
                              @mousedown.stop
                              @click.stop="openEvent(e)"
                              @contextmenu.stop
                            >
                              <span class="tabular-nums opacity-70">{{ e.start }}</span>
                              <span class="truncate">{{ e.title }}</span>
                            </span>
                          </ContextMenuTrigger>
                          <ContextMenuContent class="w-52">
                            <ContextMenuLabel class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
                              <span :class="['size-1.5 rounded-full', metaFor(e.type).dot]" />
                              <span class="truncate">{{ e.title }}</span>
                            </ContextMenuLabel>
                            <ContextMenuSeparator />
                            <ContextMenuItem class="gap-2" @select="openEvent(e)">
                              <Eye class="size-3.5" /> View details
                              <ContextMenuShortcut>Enter</ContextMenuShortcut>
                            </ContextMenuItem>
                            <ContextMenuItem class="gap-2" @select="emit('event-edit', e)"
                              ><Pencil class="size-3.5" /> Edit</ContextMenuItem
                            >
                            <ContextMenuItem class="gap-2" @select="emit('event-duplicate', e)"
                              ><CopyPlus class="size-3.5" /> Duplicate</ContextMenuItem
                            >
                            <ContextMenuItem class="gap-2" @select="copyDate(e.date)">
                              <Copy class="size-3.5" /> Copy date
                            </ContextMenuItem>
                            <ContextMenuSeparator />
                            <ContextMenuItem
                              class="text-destructive focus:text-destructive gap-2"
                              @select="emit('event-delete', e)"
                            >
                              <Trash2 class="size-3.5" /> Cancel event
                            </ContextMenuItem>
                          </ContextMenuContent>
                        </ContextMenu>
                        <span
                          v-if="(eventsByDate.get(d.key)?.length ?? 0) > 2"
                          class="text-muted-foreground px-1 text-[10px]"
                        >
                          +{{ eventsByDate.get(d.key)!.length - 2 }} more
                        </span>
                      </div>
                    </button>
                  </ContextMenuTrigger>
                  <ContextMenuContent class="w-56">
                    <ContextMenuLabel class="text-muted-foreground text-[11px]">{{ fmtDayLong(d.key) }}</ContextMenuLabel>
                    <ContextMenuSeparator />
                    <ContextMenuItem class="gap-2" @select="emit('event-create', d.key)">
                      <CalendarPlus class="size-3.5" /> New event
                      <ContextMenuShortcut>N</ContextMenuShortcut>
                    </ContextMenuItem>
                    <ContextMenuItem class="gap-2" @select="selectWeekOf(d.key)">
                      <CalendarDays class="size-3.5" /> Select this week
                    </ContextMenuItem>
                    <ContextMenuSeparator />
                    <ContextMenuItem class="gap-2" @select="copyDate(d.key)">
                      <Copy class="size-3.5" /> Copy date
                      <ContextMenuShortcut class="tabular-nums">{{ d.key }}</ContextMenuShortcut>
                    </ContextMenuItem>
                    <ContextMenuItem class="gap-2" @select="goToToday">
                      <ArrowRight class="size-3.5" /> Go to today
                    </ContextMenuItem>
                    <ContextMenuItem
                      v-if="isRange"
                      class="text-destructive focus:text-destructive gap-2"
                      @select="clearRange"
                    >
                      <X class="size-3.5" /> Clear range
                    </ContextMenuItem>
                  </ContextMenuContent>
                </ContextMenu>
              </template>
            </div>
    
            <div
              v-if="showLegend"
              class="bg-muted/20 text-muted-foreground flex flex-wrap items-center gap-3 border-t px-4 py-2 text-[10px]"
            >
              <ListFilter class="size-3" />
              <div v-for="key in typeKeys" :key="key" class="flex items-center gap-1.5">
                <span :class="['size-2 rounded-full', eventTypes[key]!.dot]" />
                {{ eventTypes[key]!.label }}
              </div>
              <span class="ml-auto text-[10px]">Tip: drag or shift-click to range. Right-click for actions.</span>
            </div>
          </div>
    
          <!-- Side rail -->
          <aside v-if="showSideRail" class="flex flex-col gap-4">
            <div v-if="!isRange" class="bg-card/40 overflow-hidden rounded-xl border">
              <div class="bg-muted/30 border-b px-4 py-3">
                <div class="flex items-center justify-between">
                  <div>
                    <p class="text-muted-foreground text-[10px] tracking-wider uppercase">
                      {{ rangeStart === todayKey ? 'Today' : 'Selected' }}
                    </p>
                    <p class="mt-0.5 text-sm font-semibold">{{ fmtDayLong(rangeStart) }}</p>
                  </div>
                  <div class="text-right">
                    <p class="text-muted-foreground text-[10px] tracking-wider uppercase">Events</p>
                    <p class="text-sm font-semibold tabular-nums">{{ selectedDayEvents.length }}</p>
                  </div>
                </div>
              </div>
              <div class="max-h-[420px] overflow-y-auto p-3">
                <template v-if="loading">
                  <div v-for="i in 3" :key="i" class="mb-2 space-y-2 rounded-lg border p-3">
                    <Skeleton class="h-3 w-32" />
                    <Skeleton class="h-2 w-20" />
                  </div>
                </template>
                <template v-else-if="selectedDayEvents.length === 0">
                  <slot name="empty">
                    <div class="flex flex-col items-center justify-center gap-2 py-8 text-center">
                      <div class="bg-muted flex size-10 items-center justify-center rounded-full">
                        <CalendarDays class="text-muted-foreground size-5" />
                      </div>
                      <p class="text-sm font-medium">Nothing scheduled</p>
                      <p class="text-muted-foreground text-xs">Click a date or add a new event.</p>
                      <Button
                        size="sm"
                        variant="outline"
                        class="mt-1 h-7 gap-1.5 text-xs"
                        @click="emit('event-create', rangeStart)"
                      >
                        <Plus class="size-3" /> New event
                      </Button>
                    </div>
                  </slot>
                </template>
                <template v-else>
                  <ContextMenu v-for="e in selectedDayEvents" :key="e.id">
                    <ContextMenuTrigger as-child>
                      <div
                        class="group hover:bg-accent/50 relative flex cursor-pointer gap-3 rounded-lg p-2.5 transition-colors"
                        @click="openEvent(e)"
                      >
                        <div :class="['w-0.5 shrink-0 rounded-full', metaFor(e.type).bar]" />
                        <div class="min-w-0 flex-1 space-y-1">
                          <div class="flex items-start justify-between gap-2">
                            <p class="text-sm leading-tight font-medium">{{ e.title }}</p>
                            <span
                              :class="[
                                'shrink-0 rounded px-1.5 py-0.5 text-[9px] tracking-wider uppercase ring-1 ring-inset',
                                metaFor(e.type).chip,
                              ]"
                            >
                              {{ metaFor(e.type).label }}
                            </span>
                          </div>
                          <div class="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
                            <span class="inline-flex items-center gap-1"
                              ><Clock class="size-3" />{{ e.start }}-{{ e.end }}</span
                            >
                            <span v-if="e.location" class="inline-flex items-center gap-1"
                              ><MapPin class="size-3" />{{ e.location }}</span
                            >
                          </div>
                          <div v-if="e.attendees?.length" class="flex items-center -space-x-1.5 pt-0.5">
                            <Avatar
                              v-for="(a, i) in e.attendees.slice(0, 4)"
                              :key="i"
                              class="border-background size-5 border-2"
                            >
                              <AvatarFallback class="bg-primary/10 text-primary text-[8px]">{{
                                initials(a)
                              }}</AvatarFallback>
                            </Avatar>
                            <span v-if="e.attendees.length > 4" class="text-muted-foreground pl-2 text-[10px]"
                              >+{{ e.attendees.length - 4 }}</span
                            >
                          </div>
                        </div>
                      </div>
                    </ContextMenuTrigger>
                    <ContextMenuContent class="w-48">
                      <ContextMenuLabel class="text-muted-foreground truncate text-[11px]">{{ e.title }}</ContextMenuLabel>
                      <ContextMenuSeparator />
                      <ContextMenuItem class="gap-2" @select="openEvent(e)"
                        ><Eye class="size-3.5" /> View details</ContextMenuItem
                      >
                      <ContextMenuItem class="gap-2" @select="emit('event-edit', e)"
                        ><Pencil class="size-3.5" /> Edit</ContextMenuItem
                      >
                      <ContextMenuItem class="gap-2" @select="emit('event-duplicate', e)"
                        ><CopyPlus class="size-3.5" /> Duplicate</ContextMenuItem
                      >
                      <ContextMenuSeparator />
                      <ContextMenuItem
                        class="text-destructive focus:text-destructive gap-2"
                        @select="emit('event-delete', e)"
                        ><Trash2 class="size-3.5" /> Cancel event</ContextMenuItem
                      >
                    </ContextMenuContent>
                  </ContextMenu>
                </template>
              </div>
            </div>
    
            <div v-else class="bg-card/40 overflow-hidden rounded-xl border">
              <div class="from-primary/10 border-b bg-gradient-to-r to-transparent px-4 py-3">
                <div class="flex items-center justify-between gap-3">
                  <div class="min-w-0">
                    <p class="text-muted-foreground text-[10px] tracking-wider uppercase">
                      Range, {{ rangeDayCount }} days
                    </p>
                    <p class="mt-0.5 truncate text-sm font-semibold">
                      {{ fmtDayShort(rangeBounds.lo) }} -> {{ fmtDayShort(rangeBounds.hi) }}
                    </p>
                  </div>
                  <Button variant="ghost" size="icon" class="size-7" @click="clearRange"><X class="size-3.5" /></Button>
                </div>
                <div class="mt-3 grid gap-2" :class="`grid-cols-${Math.min(typeKeys.length, 4)}`">
                  <div v-for="key in typeKeys" :key="key" class="bg-background/40 rounded-md border px-2 py-1.5">
                    <div class="text-muted-foreground flex items-center gap-1 text-[9px] tracking-wider uppercase">
                      <span :class="['size-1.5 rounded-full', eventTypes[key]!.dot]" />
                      {{ eventTypes[key]!.label }}
                    </div>
                    <p class="mt-0.5 text-sm font-semibold tabular-nums">{{ rangeTypeCounts[key] ?? 0 }}</p>
                  </div>
                </div>
              </div>
              <div class="max-h-[420px] overflow-y-auto">
                <template v-if="rangeEvents.length === 0">
                  <p class="text-muted-foreground px-4 py-8 text-center text-xs">No events in range.</p>
                </template>
                <template v-else>
                  <div
                    v-for="e in rangeEvents"
                    :key="e.id"
                    class="hover:bg-accent/40 flex cursor-pointer items-start gap-3 border-b px-3 py-2.5 transition-colors last:border-b-0"
                    @click="openEvent(e)"
                  >
                    <div
                      class="bg-background/60 flex w-10 shrink-0 flex-col items-center rounded-md border px-1 py-1 text-center"
                    >
                      <span class="text-muted-foreground text-[8px] uppercase">{{
                        new Date(e.date).toLocaleDateString('en-US', { month: 'short' })
                      }}</span>
                      <span class="text-sm leading-none font-semibold tabular-nums">{{ new Date(e.date).getDate() }}</span>
                    </div>
                    <div :class="['w-0.5 shrink-0 self-stretch rounded-full', metaFor(e.type).bar]" />
                    <div class="min-w-0 flex-1">
                      <p class="truncate text-xs font-medium">{{ e.title }}</p>
                      <p class="text-muted-foreground mt-0.5 text-[11px] tabular-nums">
                        {{ e.start }}-{{ e.end }}<span v-if="e.location"> {{ ' ' }} {{ e.location }}</span>
                      </p>
                    </div>
                    <span
                      :class="[
                        'shrink-0 rounded px-1.5 py-0.5 text-[9px] tracking-wider uppercase ring-1 ring-inset',
                        metaFor(e.type).chip,
                      ]"
                    >
                      {{ metaFor(e.type).label }}
                    </span>
                  </div>
                </template>
              </div>
            </div>
    
            <div v-if="showUpcoming" class="bg-card/40 overflow-hidden rounded-xl border">
              <div class="bg-muted/30 border-b px-4 py-2.5">
                <p class="text-sm font-semibold">Up next</p>
                <p class="text-muted-foreground text-[11px]">After today</p>
              </div>
              <div class="divide-y">
                <template v-if="loading">
                  <div v-for="i in 3" :key="i" class="flex items-center gap-3 p-3">
                    <Skeleton class="size-9 rounded-md" />
                    <div class="flex-1 space-y-1">
                      <Skeleton class="h-3 w-32" />
                      <Skeleton class="h-2 w-20" />
                    </div>
                  </div>
                </template>
                <template v-else-if="upcoming.length === 0">
                  <p class="text-muted-foreground px-4 py-6 text-center text-xs">Nothing on the horizon.</p>
                </template>
                <template v-else>
                  <button
                    v-for="e in upcoming"
                    :key="e.id"
                    type="button"
                    class="group hover:bg-accent/40 flex w-full items-center gap-3 p-3 text-left transition-colors"
                    @click="openEvent(e)"
                  >
                    <div
                      class="bg-background flex size-10 shrink-0 flex-col items-center justify-center rounded-md border text-center"
                    >
                      <span class="text-muted-foreground text-[9px] uppercase">{{
                        new Date(e.date).toLocaleDateString('en-US', { month: 'short' })
                      }}</span>
                      <span class="text-sm leading-none font-semibold tabular-nums">{{ new Date(e.date).getDate() }}</span>
                    </div>
                    <div class="min-w-0 flex-1">
                      <div class="flex items-center gap-1.5">
                        <span :class="['size-1.5 rounded-full', metaFor(e.type).dot]" />
                        <p class="truncate text-xs font-medium">{{ e.title }}</p>
                      </div>
                      <p class="text-muted-foreground mt-0.5 text-[11px] tabular-nums">
                        {{ e.start }}-{{ e.end }}<span v-if="e.location"> {{ ' ' }} {{ e.location }}</span>
                      </p>
                    </div>
                  </button>
                </template>
              </div>
            </div>
          </aside>
        </div>
    
        <!-- Event Detail Dialog -->
        <Dialog v-model:open="eventOpen">
          <DialogContent v-if="selectedEvent" class="sm:max-w-md">
            <slot name="event-dialog" :event="selectedEvent">
              <DialogHeader>
                <div class="flex items-center gap-2">
                  <div
                    :class="[
                      'flex size-8 items-center justify-center rounded-md ring-1 ring-inset',
                      metaFor(selectedEvent.type).chip,
                    ]"
                  >
                    <component :is="metaFor(selectedEvent.type).icon" class="size-4" />
                  </div>
                  <div>
                    <DialogTitle class="text-base">{{ selectedEvent.title }}</DialogTitle>
                    <DialogDescription class="text-xs">
                      {{ metaFor(selectedEvent.type).label
                      }}<span v-if="selectedEvent.status"> {{ ' ' }} {{ selectedEvent.status }}</span>
                    </DialogDescription>
                  </div>
                </div>
              </DialogHeader>
              <div class="space-y-3 py-2">
                <p v-if="selectedEvent.description" class="text-muted-foreground text-sm">
                  {{ selectedEvent.description }}
                </p>
                <Separator />
                <div class="grid gap-2 text-sm">
                  <div class="flex items-center gap-2">
                    <Clock class="text-muted-foreground size-4" />
                    <span>{{ selectedEvent.date }} {{ ' ' }} {{ selectedEvent.start }} - {{ selectedEvent.end }}</span>
                  </div>
                  <div v-if="selectedEvent.location" class="flex items-center gap-2">
                    <MapPin class="text-muted-foreground size-4" />
                    <span>{{ selectedEvent.location }}</span>
                  </div>
                  <div v-if="selectedEvent.attendees?.length" class="flex items-start gap-2">
                    <Users class="text-muted-foreground mt-0.5 size-4" />
                    <div class="flex flex-wrap items-center gap-1.5">
                      <div
                        v-for="a in selectedEvent.attendees"
                        :key="a"
                        class="bg-muted flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs"
                      >
                        <Avatar class="size-4">
                          <AvatarFallback class="bg-primary/10 text-primary text-[8px]">{{ initials(a) }}</AvatarFallback>
                        </Avatar>
                        <span>{{ a }}</span>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
              <DialogFooter>
                <Button
                  variant="outline"
                  size="sm"
                  @click="
                    () => {
                      emit('event-edit', selectedEvent)
                      eventOpen = false
                    }
                  "
                  >Edit</Button
                >
                <Button
                  size="sm"
                  variant="destructive"
                  @click="
                    () => {
                      emit('event-delete', selectedEvent)
                      eventOpen = false
                    }
                  "
                  >Cancel event</Button
                >
              </DialogFooter>
            </slot>
          </DialogContent>
        </Dialog>
      </div>
    </template>
  • app/components/blocks/event-calendar/defaults.ts 1.5 kB
    import { Video, CheckCircle2, AlertCircle, Plane } from 'lucide-vue-next'
    import type { EventTypeMeta } from './types'
    
    /**
     * Default event-type theme. Mirrors the boilerplate dashboard but routes
     * every color through OKLCH tokens (chart-1..4, success, warning) so it
     * follows light/dark mode and any consumer theme override.
     *
     * Override per-type or replace the whole map via `<EventCalendar :event-types="..." />`.
     */
    export const defaultEventTypes: Record<string, EventTypeMeta> = {
      meeting: {
        label: 'Meeting',
        icon: Video,
        chip: 'bg-chart-1/10 text-chart-1 ring-chart-1/20',
        bar: 'bg-chart-1',
        dot: 'bg-chart-1',
        glow: 'bg-chart-1/10',
        text: 'text-chart-1',
        ring: 'hover:ring-chart-1/30',
      },
      task: {
        label: 'Task',
        icon: CheckCircle2,
        chip: 'bg-success/10 text-success ring-success/20',
        bar: 'bg-success',
        dot: 'bg-success',
        glow: 'bg-success/10',
        text: 'text-success',
        ring: 'hover:ring-success/30',
      },
      reminder: {
        label: 'Reminder',
        icon: AlertCircle,
        chip: 'bg-warning/10 text-warning ring-warning/20',
        bar: 'bg-warning',
        dot: 'bg-warning',
        glow: 'bg-warning/10',
        text: 'text-warning',
        ring: 'hover:ring-warning/30',
      },
      travel: {
        label: 'Travel',
        icon: Plane,
        chip: 'bg-chart-3/10 text-chart-3 ring-chart-3/20',
        bar: 'bg-chart-3',
        dot: 'bg-chart-3',
        glow: 'bg-chart-3/10',
        text: 'text-chart-3',
        ring: 'hover:ring-chart-3/30',
      },
    }
  • app/components/blocks/event-calendar/types.ts 1.5 kB
    import type { Component } from 'vue'
    
    /**
     * Single event on the calendar. `type` is a free-form string (e.g. `'meeting'`,
     * `'task'`, anything) -- the matching key must exist in the `eventTypes` map
     * passed to <EventCalendar>. Unknown types fall through to a neutral style.
     */
    export interface CalendarEvent {
      id: string
      title: string
      /** YYYY-MM-DD */
      date: string
      /** HH:mm */
      start: string
      /** HH:mm */
      end: string
      type: string
      description?: string
      location?: string
      attendees?: string[]
      /** Free-form status tag rendered next to the type label in the dialog. */
      status?: string
    }
    
    /**
     * Visual + semantic config per event type. The default theme uses OKLCH
     * tokens (chart-1..4, success, warning) so consumers get sensible colors
     * out of the box, but you can override per-type to wire any palette.
     */
    export interface EventTypeMeta {
      label: string
      /** Lucide icon component for the stat tile + chip + dialog header. */
      icon: Component
      /** Tailwind classes for the colored chip on event rows + dialog header.
       *  Shape: `bg-X/10 text-X ring-X/20` is what the default theme uses. */
      chip: string
      /** Tailwind class for the vertical bar to the left of side-rail rows. */
      bar: string
      /** Tailwind class for the legend / stat-tile dot. */
      dot: string
      /** Tailwind class for the soft watermark glow behind stat tiles. */
      glow: string
      /** Tailwind class for the small text accent on "Next: ..." date labels. */
      text: string
      /** Tailwind class for the stat-tile hover ring. */
      ring: string
    }
  • app/components/blocks/event-calendar/index.ts 0.2 kB
    export { default as EventCalendar } from './EventCalendar.vue'
    export type { CalendarEvent, EventTypeMeta } from './types'
    export { defaultEventTypes } from './defaults'

Raw manifest: https://uipkge.dev/r/vue/event-calendar.json