Skip to content
ilinxa/pro-ui

Event Calendar

alphav0.5.0

Editable event calendar with month, week, day, and agenda views — multi-day spans, drag and resize editing, clipboard support, and keyboard navigation.

Category: Data DisplayUpdated: 2026-08-17Created: 2026-06-22Author: ilinxa

Context

Event Calendar is the fifth surface onto the canonical TaskItem the rest of the task family renders: task-card (list), task-tree (outline), kanban-board (board), gantt-timeline (continuous timeline), and now event-calendar (date grid). A product's 'Calendar' tab is literally the same task data its other tabs show, with no adapter. v0.2 adds the editing layer additively over v0.1's display surface, reusing gantt's controlled-echo vocabulary + the shared TaskPermissions matrix; editable defaults off so consumers opt in. Because every task surface speaks the same TaskItem, copy/paste rides a shared 'ilinxa/task' clipboard envelope — a task copied in the calendar pastes into gantt/kanban/tree and back. Compound structure: EventCalendarRoot (headless provider) + flat parts (Toolbar / MonthView / WeekView / DayView / AgendaView / MiniNav / Inspector / QuickComposer / ContextMenu / edit overlays) + Tier-C primitives (CalendarEventChip / CalendarEventBar / CalendarTimeBlock / MonthDayCell / TimeGrid / TimeGutter / NowIndicator / AgendaRow / EventTooltip / EventEditorPanel / CalendarSkeleton) + the EventCalendar assembly. Each view is its own module so a month-only consumer never pulls the week/day time-grid code; the full-card tooltip + detail editor lazy-load task-card, so the default lightweight tooltip keeps it out of the bundle.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/event-calendar

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/event-calendar-fixtures
Optional feature slicesInstall on top of the base for opt-in heavier capability.
pnpm dlx shadcn@latest add @ilinxa/event-calendar-editing

Drag/resize editing, quick-compose, clipboard, keyboard mutations, and permission-gated actions.

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Switch views · M/W/D/A · ←/→ navigate · click an event
August 2026
Mon
Tue
Wed
Thu
Fri
Sat
Sun
27
28
29
30
31
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
1
2
3
4
5
6

Demo source

demo.tsxtsx
"use client"; import { useMemo, useRef, useState } from "react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import {  EventCalendar,  EventCalendarRoot,  CalendarAgendaView,  CalendarFullCardTooltip,  CalendarMonthView,  CalendarSkeleton,  CalendarToolbar,  useCalendar,} from "./";import type { CalendarHandle, TaskItem } from "./";import { calendarEditing } from "./features/editing";import {  buildCalendarDummyData,  CALENDAR_LABEL_OPTIONS,  CALENDAR_PRIORITY_OPTIONS,  CALENDAR_STATUS_OPTIONS,} from "./dummy-data"; /** Distinct accent per status → the event color tracks the status (not time). */const STATUS_COLORS: Record<string, string> = {  todo: "oklch(0.62 0.13 255)", // blue — not started  "in-progress": "var(--primary)", // signal-lime — active  blocked: "var(--destructive)", // red — blocked  done: "var(--muted-foreground)", // grey — done};const isHighPriority = (item: TaskItem) => item.priority === "high"; /** Inline view switcher for the hand-assembled "lighter" subset. */function ComposedBody() {  const { view } = useCalendar();  return view === "agenda" ? <CalendarAgendaView /> : <CalendarMonthView />;} export default function EventCalendarDemo() {  const handle = useRef<CalendarHandle>(null);  const [clicked, setClicked] = useState<TaskItem | null>(null);  const [today] = useState(() => new Date());  const data = useMemo(() => buildCalendarDummyData(today), [today]);  // Editable tab is controlled: edits echo back into local state.  const [editData, setEditData] = useState<TaskItem[]>(() =>    buildCalendarDummyData(today),  );  const [lastEdit, setLastEdit] = useState<string>("");   return (    <Tabs defaultValue="calendar" className="w-full gap-4">      <SwipeTabsList>        <TabsTrigger value="calendar">Calendar</TabsTrigger>        <TabsTrigger value="editable">Editable</TabsTrigger>        <TabsTrigger value="composed">Lighter (composed)</TabsTrigger>        <TabsTrigger value="fullcard">Full-card tooltip</TabsTrigger>        <TabsTrigger value="states">States</TabsTrigger>      </SwipeTabsList>       {/* 1 — full assembly + imperative handle + mini-nav */}      <TabsContent value="calendar" className="space-y-3">        <div className="flex flex-wrap items-center gap-2">          <Button            size="sm"            variant="outline"            onClick={() => handle.current?.goToToday()}          >            Today          </Button>          <Button            size="sm"            variant="outline"            onClick={() => handle.current?.setView("week")}          >            Week          </Button>          <Button            size="sm"            variant="outline"            onClick={() => handle.current?.setView("day")}          >            Day          </Button>          <span className="ml-auto text-xs text-muted-foreground">            {clicked              ? `Clicked: ${clicked.name}`              : "Switch views · M/W/D/A · ←/→ navigate · click an event"}          </span>        </div>        <EventCalendar          ref={handle}          data={data}          statusOptions={CALENDAR_STATUS_OPTIONS}          priorityOptions={CALENDAR_PRIORITY_OPTIONS}          statusColors={STATUS_COLORS}          flagPriority={isHighPriority}          defaultView="month"          now={today}          showMiniNav          onTaskClick={setClicked}        />      </TabsContent>       {/* 1b — EDITABLE (v0.2.0): controlled data echoed via onChange */}      <TabsContent value="editable" className="space-y-3">        <div className="flex flex-wrap items-center gap-2">          <span className="text-xs text-muted-foreground">            <strong>Drag</strong> to reschedule · <strong>drag an edge</strong> to            resize · <strong>double-click</strong> (or drag) empty space →            quick-create · <strong>right-click</strong> for actions (incl.            Copy/Cut) · select → <strong>Edit</strong>. <strong>Keyboard:</strong>{" "}            focus an event → arrows move, Shift+arrows resize, Enter edit, F2            rename, Del delete. <strong>Copy/paste</strong> (⌘/Ctrl+C·X·V) carries            tasks across task tools. Switch to <strong>Week/Day</strong> for time            editing.          </span>          <span className="ml-auto text-xs text-muted-foreground">            {lastEdit || `${editData.length} root items`}          </span>        </div>        <EventCalendar          editable          editing={calendarEditing}          data={editData}          onChange={setEditData}          statusOptions={CALENDAR_STATUS_OPTIONS}          priorityOptions={CALENDAR_PRIORITY_OPTIONS}          labelOptions={CALENDAR_LABEL_OPTIONS}          statusColors={STATUS_COLORS}          flagPriority={isHighPriority}          defaultView="month"          now={today}          showInspector          onItemAdded={(e) => setLastEdit(`Added: ${e.item.name}`)}          onItemRemoved={(e) => setLastEdit(`Removed: ${e.removed.name}`)}          onFieldEdited={(e) => setLastEdit(`Edited ${e.key} on ${e.itemId}`)}        />      </TabsContent>       {/* 2 — hand-assembled subset (month + agenda; week/day time-grid never pulled) */}      <TabsContent value="composed" className="space-y-2">        <p className="text-xs text-muted-foreground">          Hand-assembled <code>EventCalendarRoot</code> + <code>CalendarToolbar</code>{" "}          + month/agenda only — the week/day time-grid code never enters this          bundle.        </p>        <EventCalendarRoot          data={data}          statusOptions={CALENDAR_STATUS_OPTIONS}          priorityOptions={CALENDAR_PRIORITY_OPTIONS}          statusColors={STATUS_COLORS}          flagPriority={isHighPriority}          now={today}          defaultView="month"          views={["month", "agenda"]}        >          <CalendarToolbar />          <ComposedBody />        </EventCalendarRoot>      </TabsContent>       {/* 3 — rich full-card hover tooltip (lazy task-card) */}      <TabsContent value="fullcard" className="space-y-2">        <p className="text-xs text-muted-foreground">          Hover an event → the full <code>TaskCard</code> (lazy-loaded; the          default tooltip is a native title).        </p>        <EventCalendar          data={data}          statusOptions={CALENDAR_STATUS_OPTIONS}          priorityOptions={CALENDAR_PRIORITY_OPTIONS}          statusColors={STATUS_COLORS}          flagPriority={isHighPriority}          now={today}          defaultView="month"          renderTooltip={(item) => (            <CalendarFullCardTooltip              item={item}              statusOptions={CALENDAR_STATUS_OPTIONS}            />          )}        />      </TabsContent>       {/* 4 — empty + loading */}      <TabsContent value="states" className="grid gap-4 sm:grid-cols-2">        <div className="space-y-1">          <p className="text-xs font-medium text-muted-foreground">Empty</p>          <EventCalendar            data={[]}            statusOptions={CALENDAR_STATUS_OPTIONS}            now={today}            defaultView="agenda"          />        </div>        <div className="space-y-1">          <p className="text-xs font-medium text-muted-foreground">Loading</p>          <div className="rounded-lg border border-border bg-card">            <CalendarSkeleton />          </div>        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for EventCalendar when you already hold the canonical TaskItem[] (the data behind task-card, task-tree, kanban-board, and gantt-timeline) and want a date-grid surface — month, week, day, or agenda — with no adapter. It is the read-only display sibling of the gantt; editing lands in v0.2.

Basic example

import { EventCalendar } from "@/components/event-calendar"

export function Example({ tasks }) {
  return (
    <EventCalendar
      data={tasks}                 // TaskItem[]
      statusOptions={statusOptions}
      defaultView="month"          // "month" | "week" | "day" | "agenda"
      now={serverNow}              // SSR-stable "now"
      showMiniNav
      onTaskClick={(item) => openDetail(item)}
      onRangeChange={({ start, end }) => fetchWindow(start, end)}
    />
  )
}

Editing (opt-in feature)

Editing (drag/resize/create, clipboard, keyboard mutations, permission-gated actions) ships as a separate feature slice — installing the base package alone keeps it out of your bundle entirely. Install @ilinxa/event-calendar-editing, import calendarEditing, and pass it alongside editable.

import { EventCalendar } from "@/components/event-calendar"
import { calendarEditing } from "@/components/event-calendar/features/editing"

<EventCalendar
  editable
  editing={calendarEditing}   // wires drag/resize/create/clipboard/keyboard
  data={tasks}
  onChange={setTasks}         // controlled — echo the mutated forest back
  statusOptions={statusOptions}
  permissions={permissions}   // optional — the shared TaskPermissions matrix
/>

editable without editing wired stays fully read-only (byte-identical to a base-only install) — the calendar logs one console.warn in development and never throws.

Lighter (hand-assembled subset)

import {
  EventCalendarRoot, CalendarToolbar, CalendarMonthView,
} from "@/components/event-calendar"

// month-only — the week/day time-grid code never enters your bundle
<EventCalendarRoot data={tasks} statusOptions={statusOptions} views={["month"]}>
  <CalendarToolbar />
  <CalendarMonthView />
</EventCalendarRoot>

All-day vs timed

  • A classifyEvent(item) predicate wins when it returns a kind.
  • Otherwise a date-only string ("2026-06-22", no T) is an all-day event (parsed floating-local — no timezone off-by-one); a full timestamp is timed.
  • With no end and a full timestamp, the item is a milestone (a marker / dot).

Notes

  • Fully controlled — no internal data state. Pass now for an SSR-stable first paint.
  • Keyboard: M/W/D/A switch views, ←/→ + PageUp/PageDown step the period, T jumps to today.
  • The default hover tooltip is a native title; pass renderTooltip (e.g. CalendarFullCardTooltip) for a rich card.

Features

  • Four views: Month (date cells with multi-day spanning bars + chips + '+N more' overflow), Week + Day (hour time-grids: all-day band + lane-packed timed blocks + now-line), Agenda (day-grouped chronological list)
  • Consumes the canonical TaskItem[] directly — same data as task-card / task-tree / kanban-board / gantt-timeline; no adapter
  • Opt-in editing (editable, default off → read-only): drag-to-reschedule + edge-resize + draw/double-click create + quick-composer + right-click menu (Edit/Rename/Status/Priority/Copy/Cut/Delete) + selected-event inspector + modal detail editor + inline rename; controlled-echo events + onChange, no internal data state, gated by the shared TaskPermissions matrix
  • Full keyboard editing: M/W/D/A switch views, ←/→ step the period, T today; focus an event → ←/→ move, Shift+←/→ (+↑/↓ in the time grid) resize, Enter edit, F2 rename, Delete remove; focus a day → Enter quick-create
  • Cross-surface copy / cut / paste: events copy as a portable TaskItem envelope through the OS clipboard (⌘/Ctrl+C·X·V), so a task copied here pastes into gantt / kanban / tree — paste-target decides all-day vs timed
  • All-day / timed / milestone derived via a three-layer rule: consumer classifyEvent predicate → date-only strings (parsed as floating-local, no TZ off-by-one) → span heuristic; all-day⇄timed conversion via paste-target or drag onto the all-day band
  • Status-driven event color (statusColors + colorBy, default 'status'; colorBy='urgency' restores the v0.1 deadline ramp) imported from task-card; per-item borderColor override; high-priority Flag; overdue + inactive treatments
  • Cursor (view + focus date) controlled OR uncontrolled; period nav, view switch, optional jump-to-date mini-nav (shadcn calendar), onRangeChange for lazy windowed fetch; height-responsive month overflow (maxEventsPerCell overrides)
  • SSR-safe first paint (now prop seeds; client interval refreshes); finite-date guards (unparseable dates render label-only, never throw); all-day floating-local round-trip (no off-by-one)
  • Compound: EventCalendarRoot + flat view parts + edit overlays + Tier-C primitives + the EventCalendar assembly; each view its own module (tree-shakeable); a month-only subset drops the time-grid code; the detail editor lazy-loads task-card
  • v0.5 barrel completeness: `CalendarBaseContextValue` — the return type of the exported `useCalendar()` hook — is now importable from the package root. Type-only, additive.

Tags

event-calendarcalendarmonthweekdayagendascheduletodo

Dependencies

shadcn primitives: avatar, badge, button, calendar, context-menu, input, popover, skeleton
npm peer deps: date-fns@^4.1.0, lucide-react@^1.11.0, @dnd-kit/core@^6.3.1, @dnd-kit/utilities@^3.2.2
internal: task-card