Selection and event editing
Register independent operations, mount browser bindings and apply typed event proposals.
Selection, movement and resizing are separate opt-in features. Your application owns event data, applies committed proposals and renders handles, previews and feedback. Try them in the playground.
| Factory | Installs | Requires |
|---|---|---|
interactionFeature() | Shared session controls and preview store | None |
dateSelectionFeature() | api.selection, selectedRange and onSelectionChange | Interaction |
eventMoveFeature() | api.move and onEventMove | Interaction |
eventResizeFeature() | api.resize and onEventResize | Interaction |
pointerFeature() | Browser input settings | Interaction |
touchFeature() | Touch hold activation | Pointer |
autoScrollFeature() | Edge scrolling | Pointer |
constraintsFeature() | Validation of scheduling proposals | Interaction |
externalDropFeature() | api.externalDrop and onExternalDrop | Interaction |
eventTransferFeature() | api.transfer and onEventTransfer | Interaction |
Prerequisites match factory identities, so you pick the registry aliases. Pointer resizing alone needs interaction, pointer and resize. An operation you don't register has no API or callbacks on the calendar type.
Register features and views
Create definitions once, at module scope or per calendar. The registry is fixed for an instance's lifetime: set
enabled: false in an operation's settings to pause it, and create a new instance to change the registry.
export const features = calendarFeatures({
interactions: interactionFeature(),
pointer: pointerFeature(),
selection: dateSelectionFeature(),
movement: eventMoveFeature(),
resizing: eventResizeFeature(),
});
export const views = calendarViews({
features,
views: { day: timeGridView({ days: 1, allDay: false, slotMinutes: 30 }) },
});
export const eventRelocationPolicy = createEventRelocationPolicy({
allowConversion: true,
conversionDuration: 'reset',
resourceMode: 'replace-all',
});The example renders timed columns only. Register the all-day track as its own surface when you show the all-day row.
Own the data
Type the event input as Calendar_EventInput<Booking>[] and let the calendar infer its generics. A committed move or
resize emits { before, after }, and resize adds edge. The example replaces the matching input event; validate with
your server or persist in the same callback.
Selection emits a range within one resource assignment. Timed ranges use Temporal.ZonedDateTime; all-day ranges use
inclusive Temporal.PlainDate start and end dates.
The example imports the registry and typed events from core/interactions.ts, and applies event and navigation
proposals by syncing new options. Call destroy when removing the calendar.
export function mountEditableCalendar(root: HTMLElement) {
let events = initialEvents;
const replaceEvent = (after: Calendar_EventInput<Booking>): void => {
events = events.map(event => (event.id === after.id ? after : event));
calendar['~update']({ ...calendar.options, events });
};
const calendar = createCalendar({
features,
views,
eventRelocationPolicy,
view: 'day',
date: initialDate,
timeZone: 'UTC',
events,
onNavigate: (next): void =>
calendar['~update']({
...calendar.options,
view: next.view,
date: next.date,
}),
onEventMove: ({ after }) => replaceEvent(after),
onEventResize: ({ after }) => replaceEvent(after),
});
const releaseCalendar = calendar['~mount']();
const bindings = createCalendarInteractionBindings(calendar);
bindings.root(root);
const releaseBindings = bindings.mount();
return {
calendar,
bindings,
destroy() {
releaseBindings();
bindings.root(null);
releaseCalendar();
},
};
}Attach browser elements
You render the DOM yourself. Register each track with a surface ref, without axis labels or gutters, and attach source refs to events and resize handles. The helper shows one column and one event; a renderer registers each column once and binds each of its events. Run the cleanup when replacing those elements.
export function attachTimedEvent(
bindings: ReturnType<typeof createCalendarInteractionBindings>,
columnElement: HTMLElement,
columnKey: string,
eventElement: HTMLElement,
handleElement: HTMLElement,
segmentKey: string,
eventId: string,
resourceId?: string,
) {
const surface = bindings.surface(timeGridSurface(columnKey));
const source = bindings.source(segmentKey, { eventId, resourceId });
const handle = bindings.source(`${segmentKey}:end`, { eventId, resourceId, edge: 'end' });
surface(columnElement);
source(eventElement);
handle(handleElement);
return () => {
handle(null);
source(null);
surface(null);
};
}Other tracks use timeGridSurface() (all-day), timelineSurface(row.key) and dayGridSurface(section.key, row.key).
Render the draft
The interaction store derives preview geometry from the candidate and the active layout. Drafts don't rebuild the base
layout or repack neighboring events. A rejected candidate has no preview; read its reason from
calendar.api.interactions.store.state.candidate. Event previews reuse the layout math, including minimum lengths,
clipping, resources and inclusive all-day ends. Selection previews carry no event or payload.
In time-grid columns, start/span are vertical fractions and inlineStart/inlineSize horizontal ones. All-day,
timeline and day-grid surfaces use start/span horizontally. Filter geometry by its surface key before rendering,
and keep overlays aria-hidden with pointer-events: none.
Subscribe to calendar.api.interactions.store and redraw overlays from state.preview. The
React and
Solid examples render the same geometry.
This CSS supplies an end handle and the draft overlay for the column example, on top of a theme or its positioning base:
.resize-end {
position: absolute;
bottom: 0;
left: 25%;
width: 50%;
height: 8px;
cursor: ns-resize;
border-bottom: 2px solid currentColor;
}
.editing-draft {
position: absolute;
z-index: 2;
box-sizing: border-box;
border: 2px dashed Highlight;
background: color-mix(in srgb, Highlight 18%, transparent);
pointer-events: none;
}Handles are pointer targets only. Provide labelled keyboard controls through the operation APIs below, and announce completion or rejection in a live region. The playground has both.
Programmatic edits
Each operation API has begin and evaluate. evaluate returns a valid proposal, or a rejection or unavailability
reason, without opening a session. begin returns { status: 'active', session }, or busy while another session is
active.
The session has update(target), commit(), cancel(reason?), unavailable(reason) and isActive(), and keeps its
operation's target and event-data types. Each command returns a discriminated result; valid updates and commits carry
the typed proposal. commit revalidates against current inputs. isActive() checks the session's lease, which stays
held while a controlled owner has not acknowledged a write. This helper uses the default internal owner and releases
rejected sessions:
export function moveEvent(
calendar: { api: { move: Calendar_MoveApi<Booking> } },
eventId: string,
target: Calendar_DateTarget,
) {
const session = calendar.api.move?.begin(eventId);
if (session?.status !== 'active') return session?.status ?? 'unavailable';
session.session.update(target);
const result = session.session.commit();
if (result.status !== 'committed') session.session.cancel();
return result;
}Begin selection with selection.begin(anchor) and resizing with resize.begin(eventId, edge). Targets are
{ kind: 'timed', at: zonedDateTime, resourceId? } or { kind: 'allDay', at: plainDate, resourceId? }.
State and ownership
Committed selection lives at calendar.store.state.selectedRange. The calendar owns it by default; control it with
state: { selectedRange } plus onSelectionChange, or pass atoms: { selectedRange }.
Sessions live separately in api.interactions.store, whose state holds session, candidate, outcome and the
derived preview. interactionOwnership defaults to { mode: 'internal' }. Other owners:
{ mode: 'controlled', read, onChange }accepts snapshots and returns them throughread().{ mode: 'external', atom }uses an atom withstate,setStateand optionalsubscribe.
Both store Calendar_InteractionSnapshot, which has no preview geometry. Mode and atom are fixed at creation. A
controlled owner acknowledges the exact snapshot object it received; until then commands return awaiting-owner.
Browser drivers handle late acknowledgment, including a release before acceptance. A mounted external atom with
subscribe refreshes the runtime on change; otherwise call api.interactions.refresh() after writing it. Options
updates refresh sessions, callbacks and settings. Changing sources or navigating cancels the session, and the last
unmount cancels pending leases.
Browser lifetime and cancellation
Registering features adds no listeners. useCalendarInteractionBindings mounts the shared DOM bindings, cleans them up
on unmount, and returns refs, cancel and the preview. To watch programmatic drafts without pointer input, select from
api.interactions.store.
Without a framework, createCalendarInteractionBindings(calendar) returns the same refs and a mount() that returns
cleanup; also call calendar['~mount'](). The lower-level createCalendarInteractionHost(calendar) takes elements
directly: host.surface(element, descriptor) and host.source(element, { eventId, edge?, resourceId? }) register
them, and host.mount(root) returns cleanup.
Escape, pointer cancellation, window blur, unmount and removal of a required surface cancel a gesture. Changing view, date or time zone cancels the session. Changed events and constraints are checked again at commit.
Day grids, time grids and timelines support hit testing. Lists support the programmatic APIs only. Third-party layouts
can implement locate and project; see writing features.
Continue with editing policies for conversion, constraints, touch and scrolling, and external drops and transfers for adding events and moving them across calendars.