Hapio Calendar

Moving and resizing events

Drag, long-press or grab events from the keyboard, judge each place with rules, and save changes with an optimistic overlay that reverts on failure.

Editing is an opt-in item. calendar-editing brings selection, the actions menu (Event actions) and the scope dialog of recurring events with it:

npx shadcn@latest add @hapio/calendar-editing
components/editable-bookings.tsx
'use client'

import type {
  BackgroundEventInput,
  CalendarEventInputBase,
  EventChange,
  EventRules,
} from '@hapio/calendar-react'

import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { rescheduleBooking, type Booking } from '@/lib/bookings'

function toCalendarEvent(booking: Booking): CalendarEventInputBase {
  return {
    id: booking.id,
    title: booking.title,
    start: booking.startsAt,
    end: booking.endsAt,
    resourceId: booking.staffId,
    editable: booking.status !== 'canceled',
  }
}

const RULES: { readonly event: EventRules<Booking> } = {
  event: {
    // Stay inside the open time (the background events of group 'open').
    constraint: { group: 'open' },
    // Overlap only canceled bookings.
    overlap: (other) => other.data.status === 'canceled',
    // A string is the reason screen readers hear and the drop reverts with.
    allow: ({ next }) => (next.allDay ? 'Bookings need a start and an end time' : true),
  },
}

export function EditableBookings({
  bookings,
  openTime,
  onSaved,
}: {
  bookings: readonly Booking[]
  openTime: readonly BackgroundEventInput[]
  onSaved: (booking: Booking) => void
}) {
  // The event already shows at its new place (pending) while this runs.
  const onEventChange = async (change: EventChange<Booking>) => {
    const saved = await rescheduleBooking(
      change.event.data.id,
      {
        startsAt: change.next.startIso,
        endsAt: change.next.endIso,
        staffId: change.next.resourceIds[0] ?? null,
      },
      change.signal,
    )
    onSaved(saved) // your data has the new times: the change settles on this echo
  }

  return (
    <CalendarEditing>
      <HapioCalendar
        eventItems={bookings}
        eventAccessor={toCalendarEvent}
        backgroundEvents={openTime}
        timeZone="Europe/Stockholm"
        editable
        snapDuration="PT15M"
        rules={RULES}
        onEventChange={onEventChange}
        className="h-[max(36rem,calc(100svh-17rem))]"
      />
    </CalendarEditing>
  )
}

Inside <CalendarEditing>, events whose editable allows it can be moved and resized; the calendar also selects (with selectable). Try it in the editing example, whose fake API saves, rejects, fails or never echoes on request.

How users edit

  • Mouse and pen: drag an event's body past 5 px to move it; drag its edge (the resize handle) to resize it. A mirror shows where it would land, the drag preview follows the pointer, and near the scroller's edges the view scrolls. Escape cancels before the release.
  • Touch: a long-press (400 ms, longPressMs) on an event starts its drag; moving more than 8 px before that scrolls instead. A long-press also selects the event, so the next touch on it drags at once.
  • Keyboard: Space grabs the focused event (the end of its description says so: "Press Space to move"), then:
KeyTimed event in a week or day viewOther events
↑ / ↓earlier / later by one snapa week earlier / later
← / →a day earlier / latera day earlier / later
Shift + ↑ / ↓the end, one snapthe end, one day (all-day events)
Shift + Alt + ↑ / ↓the start, one snapthe start, one day
PageUp / PageDownan hour earlier / latera week earlier / later
Enter (or Space)dropdrop
Escapecancelcancel
Tabcancel, and move oncancel, and move on

← and → swap in right-to-left layouts. Each step is announced ("Moving to Thursday, October 22, 2026, 09:30 to 10:30", with the reason when that place is refused), the event keeps focus, and after the drop focus follows it to its new place. Keyboard steps are elapsed time, so they reach both passes of the hour that happens twice on a DST day.

  • Without dragging: every event that can change has a "More actions" menu with "Move to…", "Change time…" and steps, so a single pointer or the keyboard can do everything a drag does. See Event actions.

Events listed in the "+N more" popover are moved through their actions menu, not dragged. Timed events in a month move by whole days and have no resize handles there.

What can change

editable sets what events allow: true is { move: true, resizeEnd: true, resizeStart: false, resource: true }, or give the flags yourself. Each event, event source and resource can set its own editable, which wins in that order: event, source, resource, calendar. Resizing from the start needs resizeStart: true.

A move keeps a timed event's elapsed length and an all-day event's day count; a move between the all-day row and the times takes the default length of its new kind (defaultTimedDuration, one hour, or defaultAllDayDuration, one day). Events with the same groupId move together by the same amount.

Rules

rules.event judges every place a change could land, as the user moves, so the mirror and the announcement say whether a drop would be accepted:

  • constraint: 'businessHours', { group } (inside that group's background events), an object (dates, weekdays, a daily window, resources), a function of the span, or a list of them.
  • overlap: false, or (other, moving) => boolean per overlapped event (moving is the event before the change; background events never count).
  • allow(proposal): proposal.kind ('move', 'resize-start', 'resize-end', or 'external' for drops), proposal.event, proposal.next (the new timing and resources) and proposal.via. Return true, false, or a string, the reason users hear.

validRange and the editable flags are checked first. Each event, source and resource can carry its own rules, over the calendar's. A drop the rules refuse goes back at once, and screen readers hear why ("Can't move it to …: overlaps another event").

Saving: onEventChange

When a change is dropped, the calendar draws the event at its new place, marked pending (data-pending, aria-busy), and calls onEventChange(change):

  • kind ('move', 'resize-start', 'resize-end') and via ('pointer', 'touch', 'keyboard', 'menu');
  • event (before the change) and next (the new start, end, startIso, endIso, allDay and resourceIds; instants with each end's own offset);
  • delta: ms (elapsed time between the starts) and days (wall days in the calendar's zone);
  • related: the events of the same group, which moved too;
  • recurrence: for an occurrence of a recurring series, its scope and plan (see Recurrence); null otherwise;
  • signal: aborted when a newer change to the same event (or its group) supersedes this one;
  • nativeEvent: the DOM event that ended the gesture or chose the action: a drag's release (pointerup), the Enter or Space that dropped a grabbed event, the key, click or tap that placed it with "Move to…", a menu step's click, or the reschedule form's Save. For an occurrence of a recurring series it is the gesture's event, never the scope dialog's click; null when there is none;
  • apply(): a copy of the event's input with the new timing, written in the input's own date format (an ISO string with an offset stays one, a Date stays a Date), and applyRelated(): the same for related.

Return nothing, true, or a promise of either to accept the change; return false, throw, or reject to revert it. A reverted change goes back with a short animation (data-reverting), screen readers hear "Change reverted", and onEventChangeError(error, change) is called. An accepted change stays drawn until your data has it; see saved changes settle on your data. Without onEventChange every change reverts, and development builds say why.

While a change waits, its event cannot be changed again; Space on it does nothing.

For demos: useLocalEvents

components/demo-calendar.tsx
'use client'

import { useLocalEvents, type CalendarEventInput } from '@hapio/calendar-react'

import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'

export function DemoCalendar({ initial }: { initial: readonly CalendarEventInput[] }) {
  // Local state: each change is applied with `change.apply()` (and its group's).
  const [events, onEventChange] = useLocalEvents(initial)
  return (
    <CalendarEditing>
      <HapioCalendar
        events={events}
        onEventChange={onEventChange}
        editable
        timeZone="Europe/Stockholm"
        className="h-[36rem]"
      />
    </CalendarEditing>
  )
}

useLocalEvents keeps events in local state and applies each change (apply() and applyRelated()), matching events by id. A server-expanded occurrence of a recurring series (an event with recurrence) shares its series' id, so it is matched by its series and its original start instead: changing one occurrence leaves the others as they are. Series given to the calendar as series are not local state here: save change.recurrence.plan as Recurrence shows.

Focus

A keyboard drop, a revert or a save that moves the event keeps focus on it: focus follows the event to where it is drawn now (its first segment, else the "+N more" that hides it, else the events' tab stop), never to the page body.