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.
| 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 typed events, the initial date and the registry from core/interactions.ts. React receives current options directly, and the preview is a value.
export function EditableCalendar({ dragScope }: { dragScope?: Calendar_DragScope }) {
const [date, setDate] = useState(initialDate);
const [events, setEvents] = useState(initialEvents);
const [selection, setSelection] = useState<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,
timeZone: 'UTC',
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 track | Descriptor |
|---|---|
| Time-grid timed column | timeGridSurface(column.key) |
| Time-grid all-day track | timeGridSurface() |
| Timeline resource lane | timelineSurface(row.key) |
| Day-grid row within a section | dayGridSurface(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({ column, bindings }: { column: Column; bindings: Bindings }) {
return (
<TimeGridViewColumn column={column} ref={bindings.surface(timeGridSurface(column.key))}>
{column.segments.map(segment => (
<TimeGridViewEvent
key={segment.key}
segment={segment}
ref={bindings.source(segment.key, {
eventId: segment.event.id,
resourceId: column.resource?.id,
})}
>
{segment.event.data.title}
<span
aria-hidden
className='resize-end'
ref={bindings.source(`${segment.key}:end`, {
eventId: segment.event.id,
resourceId: column.resource?.id,
edge: 'end',
})}
/>
</TimeGridViewEvent>
))}
<ColumnDraft bindings={bindings} surface={`column:${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({ bindings, surface }: { bindings: Bindings; surface: string }) {
const preview = bindings.preview;
if (preview?.status !== 'valid' || preview.kind !== 'timeGrid') return null;
return preview.geometry
.filter(item => item.surface === surface)
.map((item, index) => (
<span
key={index}
aria-hidden
className='editing-draft'
style={{
top: `${item.start * 100}%`,
height: `${item.span * 100}%`,
left: `${(item.inlineStart ?? 0) * 100}%`,
width: `${(item.inlineSize ?? 1) * 100}%`,
}}
/>
));
}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.