calendar-tsx

Selection and event editing

Opt into selection, movement and resizing with typed proposals and layout previews.

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 typed events, the initial date and the registry from core/interactions.ts. Solid reads reactive options inside an accessor, and the preview is an accessor too.

solid/interactions.tsx
export function EditableCalendar({ dragScope }: { dragScope?: Calendar_DragScope }) {
  const [date, setDate] = createSignal(initialDate);
  const [events, setEvents] = createSignal(initialEvents);
  const [selection, setSelection] = createSignal<Calendar_Selection | null>(null);
  const replaceEvent = (after: Calendar_EventInput<Booking>) =>
    setEvents(current => current.map(event => (event.id === after.id ? after : event)));

  const calendar = useCalendar(() => ({
    features,
    views,
    eventRelocationPolicy,
    view: 'day',
    date: date(),
    timeZone: 'UTC',
    events: events(),
    onNavigate: next => setDate(next.date),
    onSelectionChange: setSelection,
    onEventMove: ({ after }) => replaceEvent(after),
    onEventResize: ({ after }) => replaceEvent(after),
  }));
  const bindings = useCalendarInteractionBindings(calendar, { dragScope });

  return (
    <CalendarProvider value={calendar}>
      <div ref={bindings.root}>
        <EditableDay bindings={bindings} />
      </div>
      <p role='status'>
        {selection() ? `Selected ${selection()!.start.toString()} to ${selection()!.end.toString()}` : 'No selection'}
      </p>
    </CalendarProvider>
  );
}

Bind the rendered tracks and events

Import the view parts and useCalendarInteractionBindings from your framework package, and the surface descriptors from @calendar-tsx/core. Attach bindings.root to the calendar container. Register each date/time track as a surface, without axis labels or gutters. Attach a source ref to each event and a distinct source key to each resize handle, with resourceId when the event renders in a resource track.

Rendered trackDescriptor
Time-grid timed columntimeGridSurface(column.key)
Time-grid all-day tracktimeGridSurface()
Timeline resource lanetimelineSurface(row.key)
Day-grid row within a sectiondayGridSurface(section.key, row.key)

Gestures on blank space select, when selection is registered. Gestures on an event move it, when movement is registered. A source with edge: 'start' or 'end' resizes and keeps the opposite edge fixed. Pointer positions are fractions of the surface's bounding box, mirrored for right-to-left surfaces, so a partly scrolled track still maps correctly.

EditableDay maps the time-grid columns inside TimeGridViewBody, and each column renders its sources:

function EditableColumn(props: { column: Column; bindings: Bindings }) {
  return (
    <TimeGridViewColumn column={props.column} ref={props.bindings.surface(timeGridSurface(props.column.key))}>
      <For each={props.column.segments}>
        {segment => (
          <TimeGridViewEvent
            segment={segment}
            ref={props.bindings.source(segment.key, {
              eventId: segment.event.id,
              resourceId: props.column.resource?.id,
            })}
          >
            {segment.event.data.title}
            <span
              aria-hidden
              class='resize-end'
              ref={props.bindings.source(`${segment.key}:end`, {
                eventId: segment.event.id,
                resourceId: props.column.resource?.id,
                edge: 'end',
              })}
            />
          </TimeGridViewEvent>
        )}
      </For>
      <ColumnDraft bindings={props.bindings} surface={`column:${props.column.key}`} />
    </TimeGridViewColumn>
  );
}

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.

function ColumnDraft(props: { bindings: Bindings; surface: string }) {
  const geometry = () => {
    const preview = props.bindings.preview();
    return preview?.status === 'valid' && preview.kind === 'timeGrid'
      ? preview.geometry.filter(item => item.surface === props.surface)
      : [];
  };
  return (
    <For each={geometry()}>
      {item => (
        <span
          aria-hidden
          class='editing-draft'
          style={{
            top: `${item.start * 100}%`,
            height: `${item.span * 100}%`,
            left: `${(item.inlineStart ?? 0) * 100}%`,
            width: `${(item.inlineSize ?? 1) * 100}%`,
          }}
        />
      )}
    </For>
  );
}

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.