Something went wrong

Thank you for being patient! We're working hard on resolving the issue

Gestures - Lona Docs Log in

Gestures

The full gesture lifecycle — from pointer events, through gesture handlers, to showing menus that coordinate across layout plugins.

Overview

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

The Gesture Manager

Each layout plugin owns a gesture manager that tracks pointer state across all cells in its grid:

LayoutGesture ManagerInfo type
CalendarCalendarGesture.Manager<GestureInfo>GestureInfo(rowId, item, column, windowed)
CalendarMonthCalendarMonthGesture.Manager<Info>{ date: NaiveDate }
TimelineTimelineGesture.Manager<AllDayEventInfo>AllDayEventInfo(rowId, event, tz, column, rowIdx)

The manager dispatches pointer events to registered gesture handlers and resolves conflicts between competing gestures.

CalendarGesture Interface

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 Types

EventWhenKey fields
pointerDownPointer pressedinfo, handle (resize edge), position, onEvent
pointerUpCellReleased on a cellinfo, pointerDownInfo, position
pointerUpColumnReleased on empty columninfo, pointerDownInfo, position
pointerMoveMoved while pressedinfo, position, delta
pointerDownContextMenuRight-clickinfo, offset (cursor position)

position is a Duration.Time — time offset within the column. info carries rowId, column, windowed, and the content provider's gestureItem.

Gesture Lifecycle

guard(info)

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;
}

canHandle(event)

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;
}

finished

Return { finished: true } to remove from the active set. Return { finished: false } to stay active for subsequent events.

Weak vs strong

allowsConcurrentGestures() controls exclusivity:

  • Weak (true): Multiple gestures active simultaneously.
  • Strong (false): Takes over — other active gestures cancelled.

Common pattern — start weak, become strong on movement:

allowsConcurrentGestures(): boolean {
  return this.state == null;
}

onFinished(cancelledBy)

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.

Showing Menus

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.

Dismissing your own menu

hideDetails(): void {
  this.coordinator.globalState.unregisterState("task-details");
  this.handle.popover.dismiss("task-details");
  this.state = null;
}

Positioning Handles

HandleUse forScroll-awareCoordinate system
popoverDetails panels, formsYesColumn + RowLocalId
fixedChildContext menus, controlsYesColumn + RowLocalId + pixel inset
staticChildMonthly view contentNoRowLocalId + pixel offset
tooltipHover tooltipsYesColumn + RowLocalId

Cross-System Coordination

GestureSystem

Any plugin that shows menus implements GestureSystem and registers with the coordinator:

interface GestureSystem {
  closeAllMenus(): void;
}

// On bind
this.coordinator.register(this.gestureHandler);

GlobalGestureStateManager

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;
  },
});

Cross-System Dismissal Guard

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;
  }
}

Full Examples

Click to Show Details

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;
  }
}

Context Menu

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");
  }
}

Drag to Create

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;
  }
}

Registering Gestures

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.

See also