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'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:
| Key | Timed event in a week or day view | Other events |
|---|---|---|
| ↑ / ↓ | earlier / later by one snap | a week earlier / later |
| ← / → | a day earlier / later | a day earlier / later |
| Shift + ↑ / ↓ | the end, one snap | the end, one day (all-day events) |
| Shift + Alt + ↑ / ↓ | the start, one snap | the start, one day |
| PageUp / PageDown | an hour earlier / later | a week earlier / later |
| Enter (or Space) | drop | drop |
| Escape | cancel | cancel |
| Tab | cancel, and move on | cancel, 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) => booleanper overlapped event (movingis 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) andproposal.via. Returntrue,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') andvia('pointer','touch','keyboard','menu');event(before the change) andnext(the newstart,end,startIso,endIso,allDayandresourceIds; instants with each end's own offset);delta:ms(elapsed time between the starts) anddays(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);nullotherwise;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;nullwhen 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, aDatestays aDate), andapplyRelated(): the same forrelated.
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
'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.