Something went wrong

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

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: Position): 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;
}

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 TaskCreateGesture {
  private guard = new CrossSystemDismissalGuard(this, this.coordinator.globalState);

  canHandle(event: GestureEvent): boolean {
    // If another system was dismissed this interaction, block
    if (this.guard.shouldBlock(event.isPointerDown)) return false;
    return event.isEmptyCell;
  }
}

The guard tracks pointer interactions. On the first pointer-down after a dismissal, it blocks. On the next pointer-down, it allows.

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"

Mode Transfer

If your content supports switching modes (e.g., timed ↔ all-day), register a transfer target:

coordinator.registerTransferTarget("task-allday", {
  receive(payload: TransferPayload): void {
    // Another system handed off a creation to us
    this.showCreateForm({
      title: payload.preservedTitle,
      dateRange: payload.dateRange,
      tz: payload.tz,
    });
  },
});

// To initiate a transfer from your system:
coordinator.transfer("task-allday", {
  preservedTitle: currentForm.title,
  dateRange,
  tz,
});

See also