Something went wrong

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

Gesture Coordination - Lona Docs Log in

Gesture Coordination

When multiple plugins show menus (popovers, context menus, forms), the gesture coordination system ensures only one is visible at a time and dismisses others cleanly.

Overview

The coordination system has three parts:

  1. GestureSystem — each plugin that shows menus implements this
  2. GlobalGestureStateManager — tracks active menu states across systems
  3. GestureCoordinatorLike — the hub that connects everything
User clicks event in daily view
  → Daily system opens event details
  → coordinator.globalState.dismissOtherSystems(self)
    → All-day system: dismisses its open form
    → Monthly system: dismisses its open popover
  → Daily system registers its state

Implementing GestureSystem

Any plugin that shows menus should implement GestureSystem:

import type { GestureSystem } from "@tento-lona/sheets-ui";

class TaskGestureHandler implements GestureSystem {
  closeAllMenus(): void {
    this.dismissTaskDetails();
    this.dismissContextMenu();
    this.dismissCreateForm();
  }
}

Register with the coordinator when your plugin binds to a row:

onBindRow(handle: SheetDelegate, row: SheetRow.Joined.Client): void {
  this.coordinator.register(this.gestureHandler);
}

Showing a Menu

When your gesture handler shows a menu, follow this protocol:

showTaskDetails(
  task: TaskItem,
  position: { x: ColumnIndex.Key.Resolved.WithSubindex; y: RowLocalId },
): void {
  // 1. Create your content (you decide what to show)
  const $details = TaskDetails.make(task);

  // 2. Dismiss other systems
  this.coordinator.globalState.dismissOtherSystems(this);

  // 3. Show via the sheet's positioning API
  handle.popover.reveal("task-details", $details, position, {
    offsetY: this.computeOffset(task),
    context: "task-details",
  });

  // 4. Register your state so others can dismiss you
  this.coordinator.globalState.registerState({
    source: this,
    id: "task-details",
    dismiss: () => {
      handle.popover.dismiss("task-details");
      this.state = null;
    },
  });
}

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 a Menu

When hiding your own menu, unregister the state:

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

Blocking States

Use blocking: true for unfinished creation or edit flows that should consume the dismissing interaction.

this.coordinator.globalState.registerState({
  source: this,
  id: "task-pending-new",
  blocking: true,
  dismiss: () => {
    handle.popover.dismiss("new-task");
    this.pendingDraft = null;
  },
});

Typical rule:

  • details popovers and context menus are usually non-blocking
  • pending create or edit forms are usually blocking

Cross-System Dismissal Guard

When creating new items (drag-to-create), the first click after another system's menu is open should dismiss that menu — not start a new creation. Use CrossSystemDismissalGuard for this:

import { CrossSystemDismissalGuard } from "@tento-lona/sheets-ui";

class ClickToCreateTask {
  private guard = new CrossSystemDismissalGuard(this, this.coordinator.globalState);

  canHandle(event: GestureEvent): boolean {
    // Empty-space creation should consume the click if any other menu was dismissed.
    if (
      this.guard.shouldBlock(event.isPointerDown, {
        blockOnAnyDismissal: true,
      })
    ) return false;
    return event.isEmptyCell;
  }
}

The guard tracks pointer interactions. Use the default behavior for gestures that may continue after dismissing non-blocking state, and blockOnAnyDismissal: true for empty-space creation where the dismissal itself should consume the click.

Positioning APIs

The sheet provides four positioning handles. Choose based on your needs:

PopoverHandle — scroll-aware anchored popover

For details panels, create forms. Tracks scroll position.

handle.popover.reveal(
  "task-details",
  $content,
  { x: column, y: rowId },  // Point<WithSubindex, RowLocalId>
  { offsetY: 48, context: "task-details" },
);
  • x: Column coordinate — ColumnIndex.Key.Resolved.WithSubindex
  • y: Row coordinate — RowLocalId
  • offsetY: Pixel offset from row top (e.g., based on time position)

FixedChildHandle — scroll-aware overlay

For context menus, inline controls. Positioned relative to a column anchor with a pixel inset.

handle.fixedChild.reveal(
  "task-context-menu",
  $menu,
  { x: column, y: rowId },
  { inset: { left: offset.x, top: offset.y }, widthBehavior: 240 },
);

StaticChildHandle — raw pixel offset

For content within a single row that doesn't need column tracking. Used by the monthly calendar view.

handle.staticChild.reveal(
  "month-popover",
  $content,
  rowId,
  { offset: { x: pixelX, y: pixelY } },
);

TooltipHandle — hover tooltips

handle.tooltip.showTooltip(
  "task-tooltip",
  $tooltip,
  { x: column, y: rowId },
  "bottom",
);

State IDs

Use descriptive, namespaced IDs for your states:

// Good
"task-pending-new"
"task-existing-details"
"task-context-menu"

// Bad — collides with other systems
"pending"
"details"
"menu"

See also