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:
| Ownership | How | setState |
|---|---|---|
| Uncontrolled | Default; the calendar keeps it. | Updates the slice. |
| Controlled | Pass the value in state: { slice }. | Ignored; the slice changes only with state. |
| External | Pass 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.
| Member | Description |
|---|---|
getInitialState | Returns the initial value of each state slice the feature owns. |
constructApi | Returns actions, merged onto calendar.api. |
requires | Canonical feature identities required before APIs install. |
interaction | Optional operation descriptor, settings key, validators and dependencies. |
resolveEvents | Resolves definitions carrying the feature's stable source identity before normalization. |
events | A stage over events after the core sort, before layout. |
resources | A stage over the full depth-first resource-node list, before layout. |
decorate | A 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:
| Member | Description |
|---|---|
config | Resolved factory defaults and active-view overrides. |
options | The calendar's current core options. |
view | The active view, with its settings. |
api | The calendar's API. |
features | Resolved definitions keyed by canonical feature identity. |
state | Current calendar state when a command runs. |
layout | Active 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 };
}
}| Map | Adds |
|---|---|
Calendar_EventInput_FeatureMap | Event definitions resolved by the feature. |
Calendar_Options_FeatureMap | Options, such as live callbacks and ownership. |
Calendar_State_FeatureMap | State slices, also accepted by state and atoms. |
Calendar_Api_FeatureMap | Actions on api. |
Calendar_DayGridDay_FeatureMap | Fields on day-grid days. |
Calendar_BarSegment_FeatureMap | Fields on bar segments. |
Calendar_TimeGridColumn_FeatureMap | Fields on time-grid columns. |
Calendar_TimeGridSegment_FeatureMap | Fields on time-grid segments. |
Calendar_ListDay_FeatureMap | Fields on list days. |
Calendar_TimelineRow_FeatureMap | Fields on timeline rows. |
Calendar_TimelineSegment_FeatureMap | Fields 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:
| Member | Description |
|---|---|
kind | The 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. |
locate | Optional: a surface position → a semantic target. |
project | Optional: 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:
| Layout | Point |
|---|---|
| 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.