calendar-tsx

Writing features

Add state, options, actions and model fields to the views that use them.

Features add behavior beyond navigation, the event pipeline and the clock. Call each factory with its default configuration, collect the definitions with calendarFeatures, and bind the registry to named views with calendarViews. View factories supply their layouts. For built-in operations and browser bindings, see selection and event editing.

Registering features

Per-view features entries override factory defaults by registry key:

const features = calendarFeatures({ highlight: highlightFeature({ only: false }) });
const views = calendarViews({
  features: features,
  views: {
    week: timeGridView({ days: 7 }),
    day: timeGridView({ days: 1, features: { highlight: { only: true } } }),
    plain: timeGridView({ days: 1 }), // inherits the registered feature defaults
  },
});

export const HighlightedCalendar = () => {
  const calendar = useCalendar({
    timeZone,
    views,
    features,
    view: 'week',
    date: Temporal.Now.plainDateISO(),
    events: bookings,
    onNavigate: () => {},
  });

  return (
    <CalendarProvider value={calendar}>
      <button onClick={() => calendar.api.highlight('standup')}>Highlight the standup</button>
      <Highlighted />
    </CalendarProvider>
  );
};

type State = Calendar_State<unknown, unknown, typeof views, typeof features>;

const Highlighted = () => {
  const highlighted = useCalendarSelector((state: State) => state.highlighted);

  return <p>Highlighted: {highlighted ?? 'none'}</p>;
};

Every view inherits every registered feature. A view's features object overrides configuration by registry alias; an override on one view doesn't affect another.

Pass the same registry to calendarViews and the calendar. The calendar rejects unknown override keys, invalid configuration types, a feature identity registered twice, and a registry key equal to a view's layout kind. Definitions and factory defaults are fixed at creation: keep them at module scope or memoize them, because passing different definitions through ~update throws. To change an override, pass a new view definition in a new views map.

Settings

Each factory defines its configuration shape, and overrides use the same shape: backgroundFeature(layers) takes an array, recurrenceFeature() takes nothing. Hooks read the resolved value from context.config.

Omitted overrides inherit the defaults. Objects merge shallowly, so nested objects are replaced. Arrays replace the default; spread it to extend. An empty background array clears that view's layers and keeps the feature registered.

The runtime caches resolved configuration per view definition and includes it in stage dependencies. No generic switch removes a registered feature from one view; interaction operations and input drivers have their own enabled: false.

State

A feature declares state slices with getInitialState. They appear on calendar.store.state, and each lives in one of three places:

OwnershipHowsetState
UncontrolledDefault; the calendar keeps it.Updates the slice.
ControlledPass the value in state: { slice }.Ignored; the slice changes only with state.
ExternalPass a store in atoms: { slice }, read once.Updates the store.

An atom is any { state, setState(updater) }, so a TanStack Store fits. A controlled slice reports nothing by itself; a feature that supports control calls its own on…Change callback. State and actions belong to the instance and are shared across its views. Factories and view assembly don't create state.

Writing a feature

export const highlightFeature = (config: HighlightSettings = {}) =>
  ({
    '~id': 'highlightFeature',
    '~config': config,
    getInitialState: () => ({ highlighted: null }),
    constructApi: context => ({
      highlight: (id: string | null) => context.setState('highlighted', () => id),
    }),
    events: {
      deps: context => [context.getState('highlighted'), context.config.only],
      run: (events, context) => {
        const highlighted = context.getState<string | null>('highlighted');
        const { only } = context.config;

        return only && highlighted ? events.filter(event => event.id === highlighted) : events;
      },
    },
    decorate: {
      deps: context => [context.getState('highlighted')],
      run: (layout, context) => {
        if (layout.kind !== 'timeGrid') return layout;

        const highlighted = context.getState<string | null>('highlighted');
        const grid = layout as Calendar_TimeGridLayout<unknown, unknown>;
        const is = (event: Calendar_Event<unknown>) => event.id === highlighted;

        return {
          ...grid,
          columns: grid.columns.map(column => ({
            ...column,
            segments: column.segments.map(segment => ({ ...segment, isHighlighted: is(segment.event) })),
          })),
        };
      },
    },
  }) satisfies Calendar_ConfiguredFeature<'highlightFeature', HighlightSettings>;

A factory returns a Calendar_ConfiguredFeature<'yourFeatureIdentity', YourConfig>. ~id is the stable identity that declaration maps and event sources use; ~config holds the resolved defaults, and undefined means the feature takes no overrides. An optional ~validateConfig checks values at runtime, including merged view overrides.

MemberDescription
getInitialStateReturns the initial value of each state slice the feature owns.
constructApiReturns actions, merged onto calendar.api.
requiresCanonical feature identities required before APIs install.
interactionOptional operation descriptor, settings key, validators and dependencies.
resolveEventsResolves definitions carrying the feature's stable source identity before normalization.
eventsA stage over events after the core sort, before layout.
resourcesA stage over the full depth-first resource-node list, before layout.
decorateA stage over the built layout; adds the feature's model fields.

A stage is { deps?, run } and reruns when its input, resolved configuration or a deps value changes, so list every other value run reads. Stages run in registry order. requires is checked by identity when the calendar is created, and cycles are rejected.

Sources carry a ~source identity matched to the registering feature's ~id, so aliases keep both types and runtime lookup. Resolution is calendar-wide, uses the registered implementation, and reuses its result while the input array reference, input range and display zone stay the same.

Hooks receive a context:

MemberDescription
configResolved factory defaults and active-view overrides.
optionsThe calendar's current core options.
viewThe active view, with its settings.
apiThe calendar's API.
featuresResolved definitions keyed by canonical feature identity.
stateCurrent calendar state when a command runs.
layoutActive layout model, input, settings and capabilities.
onOptionsChange(callback)Runs after every options sync, even when layout inputs are equal.
onMount(callback)Acquires resources and returns cleanup for the mounted lifetime.
settings(key, defaults)Reads interaction defaults, live options and active-view overrides.
getState(key)A state slice, from wherever it lives.
setState(key, updater)Writes a state slice; see state.

Register onOptionsChange and onMount inside constructApi. Mount callbacks run once per mounted lifetime, and their cleanups run in reverse order on the last unmount, so a remount acquires them again.

Typing a feature

Contributions use declaration merging into feature maps keyed by the stable ~id, so registering highlight: highlightFeature() still selects the highlightFeature entries. Configuration types travel on the factory result.

declare module '@calendar-tsx/core' {
  interface Calendar_State_FeatureMap {
    highlightFeature: { highlighted: string | null };
  }
  interface Calendar_Api_FeatureMap {
    highlightFeature: { highlight: (id: string | null) => void };
  }
  interface Calendar_TimeGridSegment_FeatureMap {
    highlightFeature: { isHighlighted: boolean };
  }
}
MapAdds
Calendar_EventInput_FeatureMapEvent definitions resolved by the feature.
Calendar_Options_FeatureMapOptions, such as live callbacks and ownership.
Calendar_State_FeatureMapState slices, also accepted by state and atoms.
Calendar_Api_FeatureMapActions on api.
Calendar_DayGridDay_FeatureMapFields on day-grid days.
Calendar_BarSegment_FeatureMapFields on bar segments.
Calendar_TimeGridColumn_FeatureMapFields on time-grid columns.
Calendar_TimeGridSegment_FeatureMapFields on time-grid segments.
Calendar_ListDay_FeatureMapFields on list days.
Calendar_TimelineRow_FeatureMapFields on timeline rows.
Calendar_TimelineSegment_FeatureMapFields on timeline segments.

Calendar_Options_FeatureMap<TData, TResourceData> and Calendar_Api_FeatureMap<TData, TResourceData> receive the calendar's event and resource payload types; declarations without type parameters stay valid. createCalendar and the adapters infer them. An explicit annotation is Calendar_Api<TViews, TFeatures, TData, TResourceData>, where omitted payload types default to unknown.

Every registered feature contributes to every view model it supports. Layout maps use canonical identities, so aliases don't change model composition. Use Calendar_StateOf<typeof calendar> for typed selectors. Providers accept calendars whichever feature-state maps you import.

Operation lifecycle

interactionFeature() builds one transient runtime from the registered descriptors. dateSelectionFeature(), eventMoveFeature() and eventResizeFeature() register independently and install their APIs through constructApi on core and adapter instances. Pointer listeners come from useCalendarInteractionBindings or the core DOM host.

A third-party operation declares its intent, target and proposal types in Calendar_OperationMap<TData>, builds a descriptor with createInteractionOperation, and contributes interaction: { operation, validate?, deps? }. A settings key on that contribution exposes the resolved factory configuration on the matching operation input field. The feature also extends the options and API maps and declares requires: ['interactionFeature'] as const. The descriptor owns activation, source identity, equality, evaluation, API binding and completion. Targets can be non-date values, and callbacks and results keep consumer data. deps compare by reference: depend on primitive setting fields or stable references. Shared controls have no unrestricted begin/evaluate. See the public extension fixture.

An operation's API returns Calendar_InteractionStart<'operationKind', TData>. An active result holds a typed session whose update takes that operation's target and whose commit returns the consumer's proposal type. To take part in built-in constraints and previews, implement scheduling(proposal) returning { kind: 'range', range } or { kind: 'event', event, excludeEventId? }. Non-date operations omit it and rely on custom layout projection.

For browser input, bind createCalendarDomOperation({ acceptsTarget, begin, target? }) through host.source; the optional target maps a layout hit to the operation's target. createCalendarDomHost takes only pointer settings and interaction controls, and needs no events or time zone. createCalendarInteractionHost adds the built-in event and selection bindings on the same gesture engine.

Drafts live in the readonly store at api.interactions.store and don't rebuild the base layout. Selection writes its accepted range through feature state; event changes stay proposals for you to apply. Options updates refresh current inputs, and the last unmount cancels sessions. APIs and subscriptions survive remounts. Core callers drive ~mount, its cleanup and ~update; adapter hooks do it for you.

Layout features

A layout feature supplies a view kind:

MemberDescription
kindThe layout's kind.
period(date, settings, { firstDayOfWeek }) → the nominal period.
range(period, settings, { firstDayOfWeek }) → the visible days.
step(date, settings, direction) → the date one period away.
build(input, settings) → the layout model.
locateOptional: a surface position → a semantic target.
projectOptional: a proposal → preview geometry.

build receives range, period, inputRange, foreground and background events, resources, timeZone and now. resources is the complete ordered resource list; resourceNodes holds prepared hierarchy metadata, including collapsed descendants. settings is the view definition. A new kind needs an entry in Calendar_Layout_FeatureMap keyed by the layout feature's name. Its view supplies the layout; it isn't a registry entry.

Locating and projecting

Day grids, time grids and timelines implement both capabilities. With interactionFeature registered, api.interactions.locate(point) and api.interactions.project(proposal) dispatch to the active layout, including replacements and third-party layouts. project previews a proposal without starting a session or applying it.

Points use fractions of a measured surface, not DOM objects:

LayoutPoint
Time grid{ kind: 'timeGrid', surface: { kind: 'column', key } | { kind: 'allDay' }, fraction }
Timeline{ kind: 'timeline', surface: { kind: 'lane', key }, fraction }
Day grid{ kind: 'dayGrid', surface: { kind: 'row', section, row }, fraction }

Points also take optional dateKind, offset and edge; dateKind: 'allDay' selects dates in timeline lanes. Fractions follow the visible dates and clock window. Fraction 1 is the end of the last visible window for timed targets and the last displayed date for all-day targets. Timed positions snap to the view's slotMinutes, by default 30 in time grids and 60 in timelines, on the fixed clock axis. A valid hit keeps its local clock and its distinct instant candidates.

A failed lookup returns { status: 'unavailable', reason }, with different-layout for a point of another kind, locate-unsupported or project-unsupported when the layout lacks the capability. Semantic operations still work without them. The derived preview is null for an invalid or absent candidate.

Third-party layouts add a Calendar_LayoutInteractionMap entry, keyed by layout kind, with point, target and geometry types of any shape, and annotate the descriptor with Calendar_LayoutFeature<'myKind', MySettings, MyModel>. Both capabilities receive the decorated model, the actual layout input and the active view settings; the runtime has no built-in kind switch. Custom DOM surfaces return their own Calendar_LayoutPoint variant. Controlled and external owners store semantic snapshots only, and preview geometry derives from the draft and base model. See the public layout fixture.