calendar-tsx

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.

FactoryInstallsRequires
interactionFeature()Shared session controls and preview storeNone
dateSelectionFeature()api.selection, selectedRange and onSelectionChangeInteraction
eventMoveFeature()api.move and onEventMoveInteraction
eventResizeFeature()api.resize and onEventResizeInteraction
pointerFeature()Browser input settingsInteraction
touchFeature()Touch hold activationPointer
autoScrollFeature()Edge scrollingPointer
constraintsFeature()Validation of scheduling proposalsInteraction
externalDropFeature()api.externalDrop and onExternalDropInteraction
eventTransferFeature()api.transfer and onEventTransferInteraction

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.

core/interactions.ts
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.

core/interaction-host.ts
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 through read().
  • { mode: 'external', atom } uses an atom with state, setState and optional subscribe.

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.