Thank you for being patient! We're working hard on resolving the issue
The full gesture lifecycle — from pointer events, through gesture handlers, to showing menus that coordinate across layout plugins.
User clicks a cell
→ Layout plugin's gesture manager dispatches pointer events
→ Matching gesture handler activates
→ Handler creates content ($details, $menu, $form)
→ Handler shows content via sheet positioning handle
→ Handler registers state with gesture coordinator
→ Other systems dismiss their menus
Each layout plugin owns a gesture manager that tracks pointer state across all cells in its grid:
| Layout | Gesture Manager | Info type |
|---|---|---|
| Calendar | CalendarGesture.Manager<GestureInfo> | GestureInfo(rowId, item, column, windowed) |
| CalendarMonth | CalendarMonthGesture.Manager<Info> | { date: NaiveDate } |
| Timeline | TimelineGesture.Manager<AllDayEventInfo> | AllDayEventInfo(rowId, event, tz, column, rowIdx) |
The manager dispatches pointer events to registered gesture handlers and resolves conflicts between competing gestures.
Gestures for the daily/weekly grid implement CalendarGesture:
type CalendarGesture<Info> = {
identifier: string;
guard: (info: Info) => boolean;
canHandle: (event: CalendarGesture.Event<Info>) => boolean;
allowsConcurrentGestures: () => boolean;
onFinished: (cancelledBy: Option<CalendarGesture<Info>>) => void;
onPointerDown: (e: Event.PointerDown<Info>) => { finished: boolean };
onPointerUpCell: (e: Event.PointerUpCell<Info>) => { finished: boolean };
onPointerUpColumn: (e: Event.PointerUpColumn<Info>) => { finished: boolean };
onPointerMove: (e: Event.PointerMove<Info>) => { shouldCapturePointer: boolean };
onPointerDownContextMenu: (e: Event.PointerDownContextMenu<Info>) => { finished: boolean };
};
Extend CalendarGesture.Default and override only the methods you need.
| Event | When | Key fields |
|---|---|---|
pointerDown | Pointer pressed | info, handle (resize edge), position, onEvent |
pointerUpCell | Released on a cell | info, pointerDownInfo, position |
pointerUpColumn | Released on empty column | info, pointerDownInfo, position |
pointerMove | Moved while pressed | info, position, delta |
pointerDownContextMenu | Right-click | info, offset (cursor position) |
position is a Duration.Time — time offset within the column.
info carries rowId, column, windowed, and the content provider's
gestureItem.
Called before canHandle. Returns false to skip this gesture for cells
matching the info. Use instanceof on info.item to filter by content
type:
guard(info: GestureInfo): boolean {
return info.item instanceof TaskGestureItem;
}
Called with the current event. Return true to activate. Use matchOpt
to check which pointer phase:
canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
return e.matchOpt({
pointerUpCell: () => true,
pointerDownContextMenu: () => true,
}) ?? false;
}
Return { finished: true } to remove from the active set.
Return { finished: false } to stay active for subsequent events.
allowsConcurrentGestures() controls exclusivity:
true): Multiple gestures active simultaneously.false): Takes over — other active gestures cancelled.Common pattern — start weak, become strong on movement:
allowsConcurrentGestures(): boolean {
return this.state == null;
}
Called when removed from active set. cancelledBy is the gesture that
displaced this one, or null if it finished naturally. Clean up state
and dismiss menus here.
When a gesture handler shows a menu, it follows a 4-step protocol:
onPointerUpCell({ info }: CalendarGesture.Event.PointerUpCell<GestureInfo>) {
const task = (info.item as TaskGestureItem).task;
// 1. Create your content (child decides what to show)
const $details = TaskDetails.make(task);
// 2. Dismiss other systems
this.coordinator.globalState.dismissOtherSystems(this);
// 3. Show via the sheet's positioning handle (layout decides where)
this.handle.popover.reveal("task-details", $details, {
x: info.column,
y: info.rowId,
}, {
offsetY: computeOffsetY(task, info.windowed),
context: "task-details",
});
// 4. Register state so others can dismiss you
this.coordinator.globalState.registerState({
source: this,
id: "task-details",
dismiss: () => {
this.handle.popover.dismiss("task-details");
this.state = null;
},
});
return { finished: false };
}
Steps 2 and 4 are critical. Without step 2, your menu opens alongside another system's menu. Without step 4, no one can dismiss yours.
hideDetails(): void {
this.coordinator.globalState.unregisterState("task-details");
this.handle.popover.dismiss("task-details");
this.state = null;
}
| Handle | Use for | Scroll-aware | Coordinate system |
|---|---|---|---|
popover | Details panels, forms | Yes | Column + RowLocalId |
fixedChild | Context menus, controls | Yes | Column + RowLocalId + pixel inset |
staticChild | Monthly view content | No | RowLocalId + pixel offset |
tooltip | Hover tooltips | Yes | Column + RowLocalId |
Any plugin that shows menus implements GestureSystem and registers
with the coordinator:
interface GestureSystem {
closeAllMenus(): void;
}
// On bind
this.coordinator.register(this.gestureHandler);
Tracks active states across all systems:
class GlobalGestureStateManager {
registerState(state: GestureSystemState): void;
unregisterState(id: string): void;
hasState(id: string): boolean;
hasStateInOtherSystem(source: GestureSystem): boolean;
dismissOtherSystems(source: GestureSystem): boolean;
}
interface GestureSystemState {
readonly source: GestureSystem;
readonly id: string; // e.g., "task-pending-new"
readonly blocking?: boolean;
dismiss(): void; // cleanup callback
}
Use descriptive, namespaced state IDs:
"task-pending-new" // good
"task-existing-details" // good
"pending" // bad — collides
Use blocking: true for state that should consume the dismissing interaction:
this.coordinator.globalState.registerState({
source: this,
id: "task-pending-new",
blocking: true,
dismiss: () => {
this.handle.popover.dismiss("new-task");
this.state = null;
},
});
When creating new items, the first click after another system's menu is open should dismiss that menu — not start creation:
class TaskCreateGesture {
private guard = new CrossSystemDismissalGuard(this, coordinator.globalState);
canHandle(event: GestureEvent): boolean {
if (
this.guard.shouldBlock(event.isPointerDown, {
blockOnAnyDismissal: true,
})
) return false;
return event.isEmptyCell;
}
}
class ClickToOpenTask extends CalendarGesture.Default<GestureInfo> {
readonly identifier = "click-to-open-task";
constructor(
private handle: SheetDelegate,
private coordinator: GestureCoordinatorLike,
) {
super();
}
guard(info: GestureInfo): boolean {
return info.item instanceof TaskGestureItem;
}
canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
return e.matchOpt({ pointerUpCell: () => true }) ?? false;
}
onPointerUpCell({ info }: CalendarGesture.Event.PointerUpCell<GestureInfo>) {
const task = (info.item as TaskGestureItem).task;
const $details = TaskDetails.make(task);
this.coordinator.globalState.dismissOtherSystems(this);
this.handle.popover.reveal("task-details", $details, {
x: info.column, y: info.rowId,
}, {
offsetY: computeOffsetY(task, info.windowed),
context: "task-details",
});
this.coordinator.globalState.registerState({
source: this,
id: "task-details",
dismiss: () => this.handle.popover.dismiss("task-details"),
});
return { finished: false };
}
allowsConcurrentGestures(): boolean {
return true;
}
}
class TaskContextMenuGesture extends CalendarGesture.Default<GestureInfo> {
readonly identifier = "task-context-menu";
constructor(
private handle: SheetDelegate,
private coordinator: GestureCoordinatorLike,
private taskStore: TaskStore,
) {
super();
}
guard(info: GestureInfo): boolean {
return info.item instanceof TaskGestureItem;
}
canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
return e.matchOpt({ pointerDownContextMenu: () => true }) ?? false;
}
onPointerDownContextMenu({
info, offset,
}: CalendarGesture.Event.PointerDownContextMenu<GestureInfo>) {
const task = (info.item as TaskGestureItem).task;
const $menu = TaskContextMenu.create(task, this.taskStore);
this.coordinator.globalState.dismissOtherSystems(this);
this.handle.fixedChild.reveal("task-ctx-menu", $menu, {
x: info.column, y: info.rowId,
}, {
inset: { left: 4 + offset.x, top: 2 + offset.y },
widthBehavior: 240,
});
this.coordinator.notifyMenuOpened(this);
this.coordinator.globalState.registerState({
source: this,
id: "task-context-menu",
dismiss: () => this.handle.fixedChild.dismiss("task-ctx-menu"),
});
return { finished: false };
}
onPointerUpCell() {
this.coordinator.globalState.unregisterState("task-context-menu");
this.handle.fixedChild.dismiss("task-ctx-menu");
return { finished: true };
}
onFinished() {
this.coordinator.globalState.unregisterState("task-context-menu");
this.handle.fixedChild.dismiss("task-ctx-menu");
}
}
class DragToCreateTask extends CalendarGesture.Default<GestureInfo> {
readonly identifier = "drag-to-create-task";
private state: Option<{ startTime: DateTime<Utc>; endTime: DateTime<Utc> }>;
constructor(
private handle: SheetDelegate,
private coordinator: GestureCoordinatorLike,
) {
super();
}
guard(info: GestureInfo): boolean {
return info.item == null;
}
canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
return e.matchOpt({ pointerDown: (ev) => !ev.onEvent }) ?? false;
}
onPointerDown({ info, position }: CalendarGesture.Event.PointerDown<GestureInfo>) {
const startTime = info.windowed.fragment.start.withTime(
position.floor(Duration.Time.MIN15).toTimeOfDayWrapping(),
);
this.state = {
startTime: startTime.toUtc(),
endTime: startTime.add(Duration.Time.hrs(1)).toUtc(),
};
return { finished: false };
}
onPointerMove({ position, info }: CalendarGesture.Event.PointerMove<GestureInfo>) {
if (!this.state) return { shouldCapturePointer: false };
const endTime = info.windowed.fragment.start.withTime(
position.floor(Duration.Time.MIN15).toTimeOfDayWrapping(),
);
this.state.endTime = endTime.toUtc();
return { shouldCapturePointer: true };
}
onPointerUpColumn({ info }: CalendarGesture.Event.PointerUpColumn<GestureInfo>) {
if (!this.state) return { finished: true };
const $form = TaskCreateForm.make(this.state.startTime, this.state.endTime);
this.handle.popover.reveal("new-task", $form, {
x: info.column, y: info.rowId,
});
this.coordinator.globalState.registerState({
source: this,
id: "task-pending-new",
blocking: true,
dismiss: () => {
this.handle.popover.dismiss("new-task");
this.state = null;
},
});
return { finished: false };
}
allowsConcurrentGestures(): boolean {
return this.state == null;
}
onFinished() {
this.state = null;
}
}
Register on the layout plugin's gesture manager at wiring time:
function registerTaskGestures(
manager: CalendarGesture.Manager<GestureInfo>,
handle: SheetDelegate,
coordinator: GestureCoordinatorLike,
taskStore: TaskStore,
): void {
manager
.addGesture(new ClickToOpenTask(handle, coordinator))
.addGesture(new TaskContextMenuGesture(handle, coordinator, taskStore))
.addGesture(new DragToCreateTask(handle, coordinator))
.addGesture(new DismissTask(handle));
}
Order matters. The manager checks gestures in registration order. Place dismiss gestures after creation gestures.
RenderPlugin interface