Interaction
The interaction engines and hooks (@hapio/calendar-react/interaction), and the core's ./dom, ./layout and ./series entries.
Interactions are injected, so a read-only calendar bundles none of their code. The registry's
wrappers pass the engines for you; markup of your own passes them as interaction and renders
the hooks below. @hapio/calendar-react/interaction re-exports @hapio/calendar-core/interaction.
Engines
Create each engine once, at module scope of a client module, and pass one or a list as
interaction ([selection(), editing(), externalDrops()]).
function selection(): InteractionEngine;The selection engine (M4): keyboard and click selection, the slot composite, date clicks in
time grids, rules.select. Pass it as interaction (the registry's <CalendarSelection>
does it for HapioCalendar); create it once, at module scope of a client module (a handle a
Server Component passes as a prop arrives as a copy without its code):
import { selection } from '@hapio/calendar-react/interaction'
const interaction = selection()
useCalendar({ events, selectable: true, interaction, onSelect })Each call returns a new handle; the handle is stateless and can be shared by any number of calendars.
function editing(): InteractionEngine;The editing engine (M5): moves and resizes of events by pointer, touch and keyboard, judged by
rules.event, validRange and the editable flags, committed through the pending overlay to
onEventChange; from M6 also the events' actions menu (useEventActions: "Move to…", "Change
time…", Earlier/Later, Previous/Next day, Longer/Shorter, "Change resource…"), the reschedule
form and placing, the single-pointer and keyboard alternatives to dragging (WCAG 2.5.7).
Combine it with the selection engine (which also gives the grids their slot composite and
selection drags) and pass the list as interaction (the registry's <CalendarEditing> does it
for HapioCalendar); create both once, at module scope of a client module:
import { editing, selection } from '@hapio/calendar-react/interaction'
const interaction = [selection(), editing()]
useCalendar({ events, editable: true, interaction, onEventChange })Each call returns a new handle; the handle is stateless and can be shared by any number of calendars.
function externalDrops(): InteractionEngine;The external-drop engine (M6): a droppable calendar takes items dragged with a pointer or a
touch long-press from elements of useExternalDraggable (and native drags with nativeDrop),
or picked up from them (Space, Enter, a click or a tap) and placed with the slot cursor or a
click or tap on a slot or day; acceptExternal decides which items, validRange and
rules.event where they may land, and onExternalDrop commits them through the pending
overlay. It also answers store.hitTest for other drag-and-drop libraries. Combine it with
the selection engine (whose slot cursor places picked-up items) and, for editable calendars,
the editing engine; create them once, at module scope of a client module:
import { externalDrops, selection } from '@hapio/calendar-react/interaction'
const interaction = [selection(), externalDrops()]
useCalendar({ events, droppable: true, acceptExternal: isService, interaction, onExternalDrop })Each call returns a new handle; the handle is stateless and can be shared by any number of calendars.
An interaction engine: what makes dates, slots and
(from M5) events interactive, injected through CalendarOptions.interaction like recurrence,
so a read-only calendar bundles none of it. Get one from a factory of the
@hapio/calendar-core/interaction entry (re-exported by @hapio/calendar-react/interaction):
selection() (M4), editing() (M5) and externalDrops() (M6), combined as a list
(interaction: [selection(), editing(), externalDrops()]). An engine is stateless (each store keeps its own state), so create it once
at module scope and share it between calendars; a new engine identity ends any interaction in
progress. Create it in a client module: the code behind a handle is registered for the object
the factory returned, so a copy (a handle a Server Component passes as a prop, or one made by a
second copy of the core) does nothing, and development builds say so.
The interface is a handle: apps pass what a factory returns and never implement it. It is
frozen; later milestones extend it additively only: features gains members, factories gain
options or siblings (M5 editing(), M6 externalDrops(), combined by passing a list to
interaction), and the internal port behind the handle gains methods.
Prop
Type
Selection and placing: the slot cursor
function useSlotCursor(o?: UseSlotCursorOptions): UseSlotCursorResult;The time grid's slot cursor (see UseSlotCursorResult): the one focusable element of the
slot composite, rendered in the lane (or all-day cell) that holds the composite's tab stop.
With o.laneKey it subscribes per lane (a selector over live.tabStops.slots,
live.interaction and live.cursorVerdict that is unchanged while the cursor is elsewhere),
so a key press re-renders only the lane the cursor leaves and the one it enters. Its result
does not depend on the time grid's other state, and useTimeGrid's result does not depend on
the cursor, so lanes stay memoized. Inactive in read-only time grids and other views.
Without o.laneKey it follows the cursor into any lane, and into either surface unless
o.surface names one (with o.laneKey, surface defaults to 'time'). An inactive scoped
instance does not subscribe to where the cursor is: its laneKey and selecting are read
from the store when accessed.
Options of useSlotCursor: scope it to one lane (recommended), so each lane's cursor element
subscribes on its own and moving the cursor re-renders only the two lanes involved.
Prop
Type
useSlotCursor while the cursor is in this lane and surface (any lane without laneKey) and
the grid has a slot composite (interactions.slots; M6: or placing is in progress).
Prop
Type
Mirrors and the drag preview
function useInteractionMirror(key: string, o?: UseInteractionMirrorOptions): UseInteractionMirrorResult;What an interaction draws in one lane, all-day cell or day cell (see
UseInteractionMirrorResult): the selection's piece there (the one in progress, else the
committed one) and the mirror segments of an edit in progress. key is a time-grid lane's key
(with o.surface 'time' or 'all-day') or a day-grid day's key. It subscribes per key: a
selection step re-renders only the lanes whose piece changed (pieces are shared by key), an
edit's snapped move only the lanes and cells whose mirror segments changed. Its segments are
unknown-typed: createInteractionKit<TData>() gives the hook typed by the event data.
Options of useInteractionMirror.
Prop
Type
What useInteractionMirror returns (exported by
@hapio/calendar-react/interaction): what an interaction draws in one lane, all-day cell or
day cell. It subscribes per key, so a selection step or an edit's snapped move re-renders only
the lanes whose piece or mirror changed. Its segments are unknown-typed by default;
createInteractionKit<TData>() gives the typed hook (M5).
Prop
Type
function useDragPreview(): UseDragPreviewResult;The element that follows the pointer while an event is moved by a pointer or touch drag (see
UseDragPreviewResult). Subscribes to live.interaction through a selector that changes only
when a pointer or touch move starts or ends or its verdict changes, so a drag renders it
twice plus once per verdict change; the pointer engine finds the element inside the root by
its data-slot and writes its transform every frame. Inactive for resizes, keyboard moves,
selections and while idle.
useDragPreview while a pointer or touch move is in progress.
Prop
Type
Event actions
function useEventActions(segment: EventSegment, o?: UseEventActionsOptions): UseEventActionsResult;An event's actions menu and reschedule form (see UseEventActionsResult): the single-pointer
and keyboard alternatives to dragging it. Selects per segment whether its actions are open
(live.actions), through one store subscription per calendar (the event hub), so opening one
re-renders only its own part; while open, the items and the form come from live.actions,
which the editing engine writes.
Options of useEventActions.
Prop
Type
What useEventActions(segment, o?) returns: an
event's actions menu, the single-pointer and keyboard alternatives to dragging it (WCAG
2.5.7), and its reschedule form. Render the trigger beside the event element (both inside the
anchor), the menu while open, the form while form.open:
const actions = useEventActions(segment)
if (!actions.available) return eventElement
return (
<span {...actions.getAnchorProps()}>
{eventElement}
<DropdownMenu open={actions.open} onOpenChange={actions.onOpenChange}>
<DropdownMenuTrigger {...actions.getTriggerProps()} />
<DropdownMenuContent>
{actions.actions.map((a) => <DropdownMenuItem key={a.id} {...actions.getItemProps(a)}>{a.label}</DropdownMenuItem>)}
</DropdownMenuContent>
</DropdownMenu>
</span>
)One subscription per segment (whether its actions are open); the menu's items and the form are computed only while open.
Prop
Type
An item of the actions menu as useEventActions gives it: the engine's item
(EventActionItem) with its label (strings.eventAction, English by default).
Prop
Type
The reschedule form ("Change time…"): a dialog the registry renders
(calendar-reschedule-form) while open, with the event's dates, times, the all-day box, the
offset choice of a repeated wall time and the resource. Its value lives in the store
(live.actions.reschedule): every change goes to the editing engine (store.interact({ type: 'reschedule', value, save: false })), which checks it with the rules; Save commits a valid
value (via 'menu'), closes the form and moves focus to the event at its new place.
<form {...form.getFormProps()}>
<h2 {...form.getTitleProps()}>{form.title}</h2>
{form.fields.map(({ field, label, message }) => (
<div key={field}>
<label {...form.getLabelProps(field)}>{label}</label>
{field === 'resource' ? <select {...form.getSelectProps()}>…</select> : <input {...form.getInputProps(field)} />}
{message && <p {...form.getMessageProps(field)}>{message}</p>}
</div>
))}
<button {...form.getSaveProps()}>{form.saveLabel}</button>
</form>Prop
Type
External items
function useExternalDraggable(o: UseExternalDraggableOptions): UseExternalDraggableResult;An element that drags into, or is picked up and placed in, droppable calendars (see
UseExternalDraggableResult). Re-renders only when its own drag or pick-up starts or ends.
Options of useExternalDraggable: what the element carries
into a calendar.
Prop
Type
What useExternalDraggable(o) returns: an element
that drags into droppable calendars (pointer; a touch long-press) or is picked up for placing
(Space, Enter, a click, a tap, a screen reader's activation), registered with its document's
drag manager through the getter's ref.
const drag = useExternalDraggable({ data: { serviceId }, label: service.name, duration: { minutes: 45 } })
return (
<>
<button {...drag.getDraggableProps()}>{service.name}</button>
<span {...drag.getHintProps()}>{drag.hint}</span>
</>
)Prop
Type
The recurrence scope prompt
function useRecurrenceScopePrompt(): UseRecurrenceScopePromptResult;The recurrence scope dialog of the calendar in context (see UseRecurrenceScopePromptResult):
open while a change to a recurring occurrence waits for its scope
(recurrenceScope: 'prompt', the default). The registry's <CalendarEditing> renders it as
calendar-recurrence-scope-dialog; render your own with this hook when you pass interaction
yourself.
What useRecurrenceScopePrompt() returns: the
dialog that asks which occurrences a change to a recurring occurrence applies to
(live.scopePrompt, written by the editing engine when recurrenceScope is 'prompt', the
default). Render one per calendar (the registry's <CalendarEditing> renders
calendar-recurrence-scope-dialog), a modal dialog while open, named by its title and
described by its description, with one button per scope and Cancel:
const prompt = useRecurrenceScopePrompt()
return (
<Dialog open={prompt.open} onOpenChange={prompt.onOpenChange}>
<DialogContent aria-labelledby={prompt.getTitleProps().id} aria-describedby={prompt.getDescriptionProps().id}>
<h2 {...prompt.getTitleProps()}>{prompt.title}</h2>
<p {...prompt.getDescriptionProps()}>{prompt.description}</p>
{prompt.scopes.map((o) => <button key={o.scope} {...prompt.getScopeProps(o.scope)}>{o.label}</button>)}
<button {...prompt.getCancelProps()}>{prompt.cancelLabel}</button>
</DialogContent>
</Dialog>
)Focus is the hook's (the registry turns the primitive's own focus handling off): when the
dialog opens it moves to the first scope's button ("This event"); a chosen scope or Cancel
closes it and the editing engine moves focus to the occurrence (at its new place, or back where
it was), once the view renders. One subscription per calendar (live.scopePrompt); the texts
are computed only while open.
Prop
Type
A scope the recurrence scope dialog offers (UseRecurrenceScopePromptResult.scopes): the scope
and its label (strings.recurrenceScope(scope): "This event", "This and following events",
"All events"; English by default).
Prop
Type
Typed hooks: createInteractionKit
function createInteractionKit<TData = undefined>(): InteractionKit<TData>;The interaction entry's hooks typed by the event data, for a calendar whose events carry
TData (mirror segments and the drag preview's segment are EventSegment<TData>). Call at
module scope.
const interactions = createInteractionKit<Booking>()
function Mirror({ laneKey }: { laneKey: string }) {
const mirror = interactions.useInteractionMirror(laneKey)
return mirror.segments.map((segment) => …segment.event.data…)
}The interaction entry's hooks typed by the event data (M5; createInteractionKit<TData>()),
since createCalendarKit has no member for them (a member would bundle them into every app).
Each member is the standalone hook.
Prop
Type
Grid cursors
What the slot cursor and the day cells stand on, for bindings of your own.
function gridCursorAt(model: CalendarModel<any, any, any>, key: string | null): GridCursor | null;The cursor of the active grid at key; when key is null or not in the view (another
period, view or zone), the default cursor:
- time grids: today's lane (else the first) at the slot containing
scroll.timeMs(thescrollTimethe grid scrolled to), clamped intoslotMin–slotMax; lanes without slots (a skipped day) are passed over, and a grid without any slot falls back to the all-day cell; - day grids: today's cell, else the period's first day, else the first cell (M3's dates composite stop).
The slots of a lane are its snap-aligned wall times in [slotMin, slotMax) as instants, in
time order: a wall time in a DST gap has no slot, a repeated wall time has two (both
occurrences, earlier first), so ↑/↓ step in elapsed time (Stockholm 25 October: 02:30
(UTC+2) → 02:00 (UTC+1)). null while the model is pending, for list and unknown views, and
for a model resolve did not make. Cursors are cached per committed range and formatter, so
calling this in render is cheap and gives the same object for the same key.
function gridCursorAtPoint(model: CalendarModel<any, any, any>, point: GridPoint): GridCursor | null;The cursor under a pointer (click-to-select;
Escape from an event to the slot at its start): the lane's slot at the wall time at
fraction, snapped to the snap grid (a repeated wall time gives its first occurrence, as
'compatible' does for pointer drops; a wall time in a gap gives the first slot after
the gap), the lane's all-day cell, or the day cell. null when the lane or day is not in the
active grid, or the lane has no slots.
function gridKeyAction(model: CalendarModel<any, any, any>, key: string, input: GridKeyInput, o: GridKeyOptions): GridKeyAction;What input does on the cursor key of the active grid (the key tables of
https://calendar.hapio.io/docs/accessibility; the slot cursor carries it out, and the dates
composite's useDayCell, which lives in
the react barrel and so never imports this entry, implements the same table for day cells),
selectable, onDateClick and the nav links taken from the model
(model.interactions, the cells' navLink), directions flipped in right-to-left calendars
(model.dir). A key not in the view acts on the default cursor (gridCursorAt).
- Moves (no selection in progress): ←/→ ∓1 day (day grids, across rows) or the same wall
time on the previous/next visible day (time grids: a missing wall time gives the next slot,
a repeated one its first occurrence, a lane without slots is passed over; the all-day row:
the previous/next all-day cell); ↑/↓ ∓1 week (day grids) or the previous/next slot in time
order (↑ from the first slot to the lane's all-day cell, ↓ from the all-day cell to the
first slot); Home/End the row's (day grids, the all-day row) or lane's (time grids)
first/last; Ctrl+Home/End the grid's first/last cell or slot (the all-day row: its first or
last cell). A move off the grid's edge stays:
'move'to the cursor itself, so the key's default (scrolling) is still prevented. - Selecting: the same keys, with or without Shift, give
'extend'to their target, which never leaves the anchor's surface (slots, all-day cells or day cells): ↑ from the first slot and ↓ from the all-day row stay. - Shift+move keys with no selection in progress:
'extend'in selectable views, else'move'. - Space: selecting →
'confirm'; selectable →'start'; else as Enter. - Enter: selecting →
'confirm'; else'activate'when the cell has a nav link or the calendar hasonDateClick; else'start'in selectable views; else'none'. - PageUp/PageDown:
'page'(Shift+PageUp/PageDown:'none'). - Escape: selecting →
'cancel'; a committed selection in a selectable view →'clear'; else'none'. - Any other key, a key with Alt or Meta, or one with Ctrl other than Home/End:
'none'.
Series support
A calendar shows series only with an engine that carries the store-side series support;
rrule() does. An engine of your own adds series: seriesSupport.
const seriesSupport: SeriesSupport;The store-side series support (RecurrenceEngine.series): how a calendar store turns the
occurrences of series into events over its range, prefetches the adjacent ranges on idle and
previews 'following' and 'all' plans. rrule() carries it; an engine of your own adds it:
import { seriesSupport } from '@hapio/calendar-core/series'
export const myEngine: RecurrenceEngine = { id: 'mine', compile, expand, allowedScopes, plan, series: seriesSupport }A recurrence engine: compiles series, expands them over numeric windows, and plans edits
by scope. @hapio/calendar-recurrence provides rrule(); the interface lets an app swap in
another implementation. Engines are pure from the store's point of view: no timers, no clock
reads, no global zone state (a cache of what the zone database implies is allowed).
Prop
Type
./dom
The browser half the engines use: the pointer engine on a calendar's root and the per-document drag manager. Apps rarely need it directly.
function pointerEngineFor(root: HTMLElement): PointerEngine;The pointer engine of root, created on first use and shared by every consumer registered
with it (PointerEngine.register): the selection controller (surface clicks, selection drags)
and the edit controller (event drags and resizes) of one store. Its listeners go when the last
consumer unregisters. See PointerGesture for the thresholds and the capture, and
PressTarget for what a press can land on.
function dragManagerFor(doc: Document): DragManager;The drag manager of doc, created on first use and shared by every source and target of that
document (see DragManager, ExternalSource, ExternalDropTarget).
function nativeDropFor(root: HTMLElement, consumer: NativeDropConsumer): () => void;Starts hearing native drags on root for consumer; returns the removal of the listeners.
function createGeometryCache(root: HTMLElement, o?: GeometryCacheOptions): GeometryCache;The geometry of root's grid (see GeometryCache): capture() reads the rects once, at a
drag's start; hitTest is arithmetic over them and the scroll offsets since.
./layout
The pure layout algorithms the view models are built with.
function buildTimeAxis(o: TimeAxisOptions): TimeAxis;Builds a wall-clock time axis over o.days:
slotCount = ceil((slotMax − slotMin) / slotMs)slots,wallStart[s] = slotMin + s · slotMs;SLOT_MAJOReverylabelIntervalMsfromslotMinon every lane;SLOT_GAPandSLOT_REPEATEDper lane from the zone's transitions around the lanes (zone.transitionsInfrom the day before the first lane to the day after the last): a gap ofafter − beforewall ms just after the transition's wall time, or a repeat ofbefore − afterjust before it, intersected with each lane's wall day (a range crossing midnight flags both days); a day withstartOfDay(day) === startOfDay(day + 1)(Apia 2011-12-30) isSLOT_GAPthroughout;project/unproject/snapas documented onTimeAxis(no allocation per call;unprojectrounds to whole wall ms, sounproject(day, project(day, wall))iswallon the axis).
Throws a RangeError for mode: 'continuous' (1.2), a non-positive slotMs or
labelIntervalMs, or unless 0 ≤ slotMin < slotMax ≤ 86_400_000. Allocates the typed arrays
once; no Temporal.
function packTimeGrid<S extends TimeGridPackItem>(segs: readonly S[], o: PackTimeGridOptions<S>): TimeGridPacking;Packs one lane's segments, exactly:
- sort with
o.compare(default start ascending, longer first; ties keep input order); - sweep into overlap clusters using a visual end of
max(end, start + minVisualMs)(at least 1 ms past the start, so a zero-length segment still occupies its column); - first free column; with
orderStrict, only columns greater than every column used by an earlier-sorted segment it overlaps; - expand right:
span= 1 + consecutive free columns to the right over its range; - geometry with
ncolumns per cluster:'columns'→insetStart = c / n,width = span / n,z = 0;'stack'→insetStart = c / n,width = min(1 − insetStart, 2 · span / n),z = c(at most about half of an earlier event is covered); maxStack: when a cluster needs more thanmaxStackcolumns, its segments in columns ≥maxStack − 1arehiddenand merged into maximal ranges of overlapping visual ranges (more, in sweep order;endis the latest visual end), each drawn in columnmaxStack − 1, where it is what the visible segments expand against;nis thenmaxStack. Hidden placements keep their column and take the group column's geometry;isShortwhen the wall length is undero.shortMs(defaultSHORT_EVENT_MS). No container size queries.
Deterministic: equal input gives equal output. placements[i] belongs to segs[i].
function packDayRows<S extends DayRowPackItem>(segs: readonly S[], o: PackDayRowsOptions<S>): DayRowPacking;Packs one row, exactly:
- sort by
startCol, then span (endCol − startCol) descending, theno.compare, then input order (columns are clamped to the row, at least one wide); - levels first-fit with per-level occupancy bitmasks (one
Uint32word per level whencolumns ≤ 32, one word per 32 columns beyond); withorderStrict, a segment takes the level above every level used by an earlier-sorted segment it overlaps, so gaps below are never reused (aspackTimeGrid's columns); visibleLevels(col)=capacitywhen every segment covering the column is visible, elsecapacity − 1(no capacity:count); a segment isvisibleonly iflevel < visibleLevels(col)for every column it spans. Segments on levels ≥capacityare hidden, which lowers their columns; that can hide segments on levelcapacity − 1spanning into them, and so on (see the module comment). This is "capacitywhen the column'scount ≤ capacity" except for a multi-day segment hidden by a neighbouring column or put above a first-fit gap, whose columns showcapacity − 1as well. Hidden segments are listed per column incolumns[col].hidden, in event order (o.compare, then input order), and the column's "+N more" fits on levelvisibleLevels(col) < capacity.
Deterministic: equal input gives equal output. placements[i] belongs to segs[i].
function projectBackground(layers: readonly BackgroundLayer[], lanes: readonly LayoutLane[], axis: TimeAxis, zone: LayoutZone): readonly BackgroundPiece[];Projects background layers onto time lanes:
- a layer draws in a lane when it has no resources, when the lane has none
(
resourceId: null), or when itsresourceIdsinclude the lane's resource; - each lane's window is
[zone.startOfDay(lane.day), zone.startOfDay(lane.day + 1)); - a layer's intervals are clipped to the window; an
inverselayer is first complemented within it (a complement piece starts or ends where an interval does:isStartunless it begins at the window's start,isEndunless it runs to the window's end); - each piece's wall markers are projected with
axis.project, so pieces outsideslotMin–slotMaxare dropped and partial ones clipped; empty pieces are dropped. A piece containing a fall-back transition covers the hull of its wall times (both occurrences of the repeated wall times it holds).
Output order: by laneIndex, then layer order, then start. O(lanes · layers · log k) with a
binary search per layer and window; no Temporal.