Drag, resize, select

Add the Interaction plugin and set editable and selectable. Drag an event to move it, drag its edge to resize, and drag across empty cells to select a range. Click an event to open a popover.

Interaction.tsx
Interaction.tsx
import {
  Calendar,
  DayGrid,
  Interaction,
  TimeGrid,
  useCalendar,
  type CalEvent,
  type EventInput,
  type SelectInfo,
} from 'z-cal/react'
import { useCallback, useState, type JSX } from 'react'
import { useScheme } from '../DemoFrame'
import { today, todayDate } from '../shared/dates'
import { EventCreateDialog } from '../shared/EventCreateDialog'
import { EventPopover, placeUnder, type EventPopoverTarget } from '../shared/EventPopover'

// The Interaction plugin enables dragging, resizing, and range selection.
const PLUGINS = [DayGrid, TimeGrid, Interaction]

const INITIAL_EVENTS: EventInput[] = [
  { id: 'sync', title: 'Team sync', start: today(0, 10), end: today(0, 11) },
  { id: 'workshop', title: 'Workshop', start: today(0, 13), end: today(0, 15) },
  { id: 'review', title: 'Design review', start: today(1, 16), end: today(1, 17) },
  { id: 'offsite', title: 'Offsite', start: todayDate(2), end: todayDate(4) },
]

let counter = 0

export function InteractionDemo(): JSX.Element {
  // The app owns the data. Drag and resize report a new range; the app persists it.
  const [events, setEvents] = useState(INITIAL_EVENTS)
  const [log, setLog] = useState<{ id: number; line: string }[]>([])
  const [selection, setSelection] = useState<SelectInfo | null>(null)
  const [popover, setPopover] = useState<EventPopoverTarget | null>(null)
  const { ref, calendar } = useCalendar()
  const closePopover = useCallback(() => setPopover(null), [])

  const record = (line: string): void =>
    setLog((prev) => [{ id: ++counter, line }, ...prev].slice(0, 5))

  const persist = (event: CalEvent): void =>
    setEvents((prev) =>
      prev.map((input) =>
        input.id === event.id
          ? { ...input, start: event.start, end: event.end, allDay: event.allDay }
          : input,
      ),
    )

  return (
    <>
      <div className="card">
        <Calendar
          ref={ref}
          plugins={PLUGINS}
          view="timeGridWeek"
          colorScheme={useScheme()}
          headerToolbar={{
            start: 'title',
            center: '',
            end: 'today prev,next dayGridMonth,timeGridWeek',
          }}
          events={events}
          editable
          selectable
          nowIndicator
          scrollTime="08:00"
          height="600px"
          eventDrop={(info) => {
            persist(info.event)
            record(`moved "${info.event.title}" to ${info.event.startStr}`)
          }}
          eventResize={(info) => {
            persist(info.event)
            record(`resized "${info.event.title}" to ${info.event.endStr}`)
          }}
          select={(info) => {
            record(`selected ${info.startStr} → ${info.endStr}`)
            setPopover(null)
            setSelection(info)
          }}
          eventClick={(info) => {
            const target = {
              event: info.event,
              anchor: info.el,
              placement: placeUnder(info.el.getBoundingClientRect()),
            }
            setPopover((prev) => (prev?.anchor === info.el ? null : target))
          }}
          datesSet={() => {
            setPopover(null)
            setSelection(null)
          }}
        />
      </div>
      {selection && (
        <EventCreateDialog
          selection={selection}
          timeZone={new Intl.DateTimeFormat().resolvedOptions().timeZone}
          onCancel={() => {
            setSelection(null)
            calendar?.unselect().focus()
          }}
          onCreate={(title) => {
            const input: EventInput = {
              id: `new-${++counter}`,
              title,
              start: selection.start,
              end: selection.end,
              allDay: selection.allDay,
            }
            setEvents((prev) => [...prev, input])
            record(`created "${title}"`)
            setSelection(null)
            calendar?.unselect().focus()
          }}
        />
      )}
      <EventPopover
        target={popover}
        locale="en-US"
        resourceTitle={(id) => id}
        onClose={closePopover}
        onDelete={(event) => {
          setEvents((prev) => prev.filter((input) => input.id !== event.id))
          record(`deleted "${event.title}"`)
          setPopover(null)
        }}
      />
      <ul className="log" aria-live="polite">
        {log.map((entry) => (
          <li key={entry.id}>{entry.line}</li>
        ))}
      </ul>
    </>
  )
}

What to look at

  • The app owns the event array. eventDrop and eventResize report the new range, and the demo writes it back into React state so the change survives a refetch. See Events and interaction.
  • select hands you the range and whether it is all-day. The demo opens a native <dialog> to name the event, then calls unselect() on the instance.
  • The popover is plain app state driven by eventClick. The library reports the clicked element; the app positions the popover under it.