Hapio Calendar

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 (the scrollTime the grid scrolled to), clamped into slotMin–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 has onDateClick; 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_MAJOR every labelIntervalMs from slotMin on every lane; SLOT_GAP and SLOT_REPEATED per lane from the zone's transitions around the lanes (zone.transitionsIn from the day before the first lane to the day after the last): a gap of after − before wall ms just after the transition's wall time, or a repeat of before − after just before it, intersected with each lane's wall day (a range crossing midnight flags both days); a day with startOfDay(day) === startOfDay(day + 1) (Apia 2011-12-30) is SLOT_GAP throughout;
  • project/unproject/snap as documented on TimeAxis (no allocation per call; unproject rounds to whole wall ms, so unproject(day, project(day, wall)) is wall on 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:

  1. sort with o.compare (default start ascending, longer first; ties keep input order);
  2. 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);
  3. first free column; with orderStrict, only columns greater than every column used by an earlier-sorted segment it overlaps;
  4. expand right: span = 1 + consecutive free columns to the right over its range;
  5. geometry with n columns 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);
  6. maxStack: when a cluster needs more than maxStack columns, its segments in columns ≥ maxStack − 1 are hidden and merged into maximal ranges of overlapping visual ranges (more, in sweep order; end is the latest visual end), each drawn in column maxStack − 1, where it is what the visible segments expand against; n is then maxStack. Hidden placements keep their column and take the group column's geometry;
  7. isShort when the wall length is under o.shortMs (default SHORT_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, then o.compare, then input order (columns are clamped to the row, at least one wide);
  • levels first-fit with per-level occupancy bitmasks (one Uint32 word per level when columns ≤ 32, one word per 32 columns beyond); with orderStrict, a segment takes the level above every level used by an earlier-sorted segment it overlaps, so gaps below are never reused (as packTimeGrid's columns);
  • visibleLevels(col) = capacity when every segment covering the column is visible, else capacity − 1 (no capacity: count); a segment is visible only if level < visibleLevels(col) for every column it spans. Segments on levels ≥ capacity are hidden, which lowers their columns; that can hide segments on level capacity − 1 spanning into them, and so on (see the module comment). This is "capacity when the column's count ≤ capacity" except for a multi-day segment hidden by a neighbouring column or put above a first-fit gap, whose columns show capacity − 1 as well. Hidden segments are listed per column in columns[col].hidden, in event order (o.compare, then input order), and the column's "+N more" fits on level visibleLevels(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 its resourceIds include 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 inverse layer is first complemented within it (a complement piece starts or ends where an interval does: isStart unless it begins at the window's start, isEnd unless it runs to the window's end);
  • each piece's wall markers are projected with axis.project, so pieces outside slotMin–slotMax are 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.