Something went wrong

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

Overview - Lona Docs Log in

Gestures

Gestures handle pointer interactions inside a parent plugin's grid. The system has two layers that solve different problems:

LayerOwns the pointerUse for
Item-level (Span pattern)Renderer's own DOM element (Span.$e)Drag, resize, click, hover on a specific rendered item
Column-level (CalendarGesture manager)Shared gesture manager wired by the parentDrag-to-create on empty space, marquee selection, anything that spans multiple items or empty cells

Picking the right layer:

  • The interaction is on a specific item the user can see (an event card, a task chip, a sleep span) → item-level. The renderer wires its own pointer events.
  • The interaction is on empty space or needs cross-item coordination (e.g., dismiss everyone else's open menu) → column-level via CalendarGesture.Manager.

Most new gestures are item-level. Column-level is reserved for cases where the renderer-owned approach genuinely cannot work.

For a step-by-step walkthrough of the full chain (DOM event → component → cell → handler → mutation target → producer client → invalidation → rebind), see Gesture Lifecycle.

Overview: where pointer events go

The host wraps every renderer element in a CalendarColumnCell and calls its bindCallbacks(callbacks, context) method. The cell does the pointer work — edge detection, position translation, capture decision — and forwards a structured Callbacks event:

// tento-ui-js/components/calendar/calendar-column-cell.ts
type Callbacks = {
  onPointerDown: (event: {
    position: PointerPosition;
    handle: Option<"top" | "bottom">;
  }) => void;
  onPointerUpCell: (event: { position: PointerPosition }) => void;
  onPointerMove: (event: { position: PointerPosition })
    => { shouldCapturePointer: boolean };
  onContextMenu: (event: { position: PointerPosition }) => void;
};

handle is set when the user pressed within 4px of the top or bottom edge — the cell's built-in resize-handle convention. The renderer never re-implements this.

user clicks a rendered item
  ├── pointerdown fires on the wrapper cell
  ├── cell figures out handle: "top" | "bottom" | null
  └── cell calls callbacks.onPointerDown({ position, handle })
      └── renderer-supplied callback runs (drag, resize, click, …)

user clicks empty space inside the column
  ├── pointerdown fires on the wrapper cell of an empty span
  └── cell calls callbacks.onPointerDown({ position, handle: null })
      └── host-supplied asEmptyCellDelegate routes to
          CalendarGesture.Manager

The two layers differ in which callbacks the host wires onto the wrapper cell:

  • Item-level: the renderer supplies Callbacks per span.
  • Column-level: the host supplies gestureManager.asEmptyCellDelegate(GestureInfo) which dispatches registered CalendarGesture instances.

Event propagation

A component (the renderer's content card) sits inside a wrapper cell. The wrapper is a CalendarColumnCell — a LonaWebComponent with pointer-events enabled. Pointer events from the DOM flow through this fixed chain of actors:

DOM pointerdown / pointermove / pointerup / contextmenu
  │
  ▼
CalendarColumnCell (wrapper, exposes bindCallbacks)
  ├── GESTURE_MANAGER.addPointerEvent dispatches the native event
  ├── classifies offset.y → handle: "top" | "bottom" | null
  ├── builds a PointerPosition (offset + offsetFromColumnStart)
  └── invokes the registered Callbacks
        │
        ▼
   ┌─────────────────────────────────────────────────────────┐
   │ Span.callbacks present?                                  │
   └─────────────────────────────────────────────────────────┘
        │ yes                              │ no
        ▼                                  ▼
  Item-level handler                 GESTURE_MANAGER.asEmptyCellDelegate
   (renderer-supplied)                 (column-level fallback)
        │                                  │
        ▼                                  ▼
  EventMutationTarget                 CalendarGesture.Manager
   .setTime(record.ref, range)         dispatches to registered
        │                              CalendarGesture instances
        ▼                                  │
  Producer-row event client                ▼
   .setTime(itemId, range)            SubcolumnCreateHandler
        │                              .onDragCreate(range, ctx)
        ▼                                  │
  Row write + invalidation                 ▼
        │                              Plugin's create flow
        ▼                              (popover form, etc.)
  Sheet rebinds affected rows
        │
        ▼
  makeSpans called again with fresh records

The component itself never reads MouseEvent or PointerEvent. The wrapper cell's bindCallbacks is the ingress point; the renderer's handler is the egress point. Components stay focused on visuals.

Where the component fits

The renderer's content component (in our tutorial, LonaCalendarTaskCard) participates in the chain in two ways:

  1. As the visible target. It sits in the wrapper cell's <slot name="content">. Its DOM is what the user clicks on. Its shadow root may visualize state (e.g. a dragging attribute toggled by the handler).
  2. As a state surface, not a state machine. The handler holds dragStart, the proposed range, and the ghost. It mutates the card via toggleAttribute / style.setProperty to reflect that state. The card never owns the gesture state itself.

This split keeps the component pure: same card, regardless of whether the row is read-only, editable, source-bound, or aggregated.


Item-level gestures: the Span pattern

A renderer that needs item-level interactions returns a CalendarSubcolumn.SpanType and constructs each span's $e itself. The renderer attaches its own pointer event handlers natively. Sleep, scheduling availability, debug-charts, and stickers all use this pattern today.

The Span shape

import { Span } from "@tento-ui/components/span/span";

type Span = {
  id: string;                           // stable id for diffing
  $e: HTMLElement;                      // renderer-owned content element
  start: DateTime<Utc>;                 // for the lane layout to position
  end: Option<DateTime<Utc>>;
  pointerEvents: Option<string>;        // CSS pointer-events on the wrapper
  callbacks?: CalendarColumnCell.Callbacks;  // optional per-span callbacks
};

The host wraps $e in a CalendarColumnCell.makeWith({content: $e}), sets --default-interactable from pointerEvents, and calls bindCallbacks(callbacks ?? asEmptyCellDelegate(...), "span"). When the renderer supplies its own callbacks, item-level interactions flow through them; otherwise column-level dispatch handles the cell.

Tutorial: a draggable task span

We'll build a task renderer where each task is a span with drag-to-reschedule and click-to-open behavior.

Step 1 — model the record

The record carries enough provenance for the renderer to write back to the right source row:

interface TaskRecord {
  readonly id: string;
  readonly title: string;
  readonly time: DateTime.Range<Utc>;
  // Producer reference — survives aliasing.
  readonly ref: { readonly rowId: RowLocalId; readonly itemId: string };
}

Step 2 — define the mutation target

The renderer writes via an interface, not a concrete client. The plugin layer injects the right implementation when the subcolumn is constructed.

interface TaskMutationTarget {
  setTime(ref: TaskRecord["ref"], range: DateTime.Range<Utc>): Promise<void>;
  remove(ref: TaskRecord["ref"]): Promise<void>;
}

Step 3 — build the content web component

The content element is a standard LonaWebComponent that paints the visual. It does not wire pointer events — those go through bindCallbacks on the wrapper cell in step 4.

Follow the project conventions: @component decorator with a namespaced tag, static $html = html\…`template usingid-keyed elements and slots, static $styles = [css`…`] using design tokens (--bg-surface-light, --text-sec-on-solid, --font-size: var(--text-Npx), --margin-topbaseline-capheight units). Never write rawfont-size:ormargin:` on typographic elements — see the Web Components and Typography guides.

import { LonaWebComponent, html } from "@tento-ui/component";
import { component } from "@tento-ui/component-decorators";
import { css } from "@tento-ui/component-styles";
import { Typography } from "@tento-ui/ui/typography";

@component({ name: "lona-calendar-task-card" })
export class LonaCalendarTaskCard extends LonaWebComponent {
  private $title = this.$("title");
  private $time = this.$("time");

  bind(record: TaskRecord, use24Hour: boolean): void {
    this.$title.textContent = record.title;
    this.$time.textContent = formatRange(record.time, use24Hour);
    this.toggleAttribute("editable", true);  // surfaces the cell's resize handles
  }

  static $styles = [
    Typography.$maxLines,
    css`
      :host {
        display: block;
        height: 100%;
        padding-block: 8px;
        padding-inline: 6px;
        background: var(--bg-task);
        color: var(--text-on-solid);
        border-radius: 12px;
        cursor: grab;
        user-select: none;
      }

      :host([dragging]) {
        cursor: grabbing;
      }

      #title {
        --font-size: var(--text-8px);
      }

      #time {
        --margin-top: 4px;
        --font-size: var(--text-6px);
        color: var(--text-sec-on-solid);
      }
    `,
  ];

  static $html = html`
    <std-col id="root">
      <p id="title" class="max-lines-2"></p>
      <p id="time"></p>
    </std-col>
  `;
}

Note: the cell wrapper already provides resize-handle DOM (top/bottom 4px stripes with cursor: ns-resize) when you set editable on it. The card itself never reimplements that geometry.

Step 4 — write a per-record interaction handler

The handler is a small object that turns CalendarColumnCell.Callbacks events into target writes. It owns the drag/resize state machine and the ghost preview. The card stays purely visual; the handler holds mutation logic.

import { CalendarColumnCell } from "@tento-ui/components/calendar/calendar-column-cell";

class TaskCardHandler {
  private dragStart: Option<{
    handle: Option<"top" | "bottom">;
    range: DateTime.Range<Utc>;
    startTime: DurationTime;
  }> = null;

  constructor(
    private readonly record: TaskRecord,
    private readonly target: TaskMutationTarget,
    private readonly windowed: DateFragment.Windowed,
    private readonly pxPerHr: number,
    private readonly $card: LonaCalendarTaskCard,
  ) {}

  callbacks(): CalendarColumnCell.Callbacks {
    return {
      onPointerDown: ({ position, handle }) => {
        this.dragStart = {
          handle,
          range: this.record.time,
          startTime: position.resolve(this.windowed, this.pxPerHr),
        };
        this.$card.toggleAttribute("dragging", true);
      },
      onPointerMove: ({ position }) => {
        if (!this.dragStart) return { shouldCapturePointer: false };
        const t = position.resolve(this.windowed, this.pxPerHr);
        const proposed = this.derive(t);
        this.updateGhost(proposed);
        return { shouldCapturePointer: true };
      },
      onPointerUpCell: async ({ position }) => {
        if (!this.dragStart) return;
        const t = position.resolve(this.windowed, this.pxPerHr);
        const proposed = this.derive(t);
        this.dragStart = null;
        this.$card.toggleAttribute("dragging", false);
        this.removeGhost();
        await this.target.setTime(this.record.ref, proposed);
      },
      onContextMenu: ({ position }) => {
        // Renderer's own context menu, or delegate to coordinator.
      },
    };
  }

  private derive(t: DurationTime): DateTime.Range<Utc> {
    // dragStart!.handle === "top"    → move start, keep end
    // dragStart!.handle === "bottom" → keep start, move end
    // dragStart!.handle === null     → translate both by (t - startTime)
    // ...
    return /* computed range */;
  }

  private updateGhost(range: DateTime.Range<Utc>): void { /* sibling DOM */ }
  private removeGhost(): void { /* remove sibling DOM */ }
}

The handler reuses CalendarColumnCell's built-in PointerPosition.resolve(windowed, pxPerHr) to convert offset to DurationTime — no manual pixel math.

Step 5 — return spans with callbacks from makeSpans

import { CalendarSubcolumn } from "@tento-lona/sheets-ui";
import { SpanColumn } from "@tento-ui/components/span/components/span-column";

function makeTaskSubcolumn(
  row: SheetRow.Joined.Client,
  target: TaskMutationTarget,
  use24Hour: boolean,
  fetchTasks: (range: DateTime.Range<Utc>) => Promise<TaskRecord[]>,
): CalendarSubcolumn<TaskRecord> {
  return {
    rowId: RowId.fromLocalId(row.id),
    renderer: "spans",
    makeLayout: () => {
      const $c = SpanColumn.make();
      $c.bind([24]);
      return $c;
    },
    makeSpans: async (windowed, _tzr) => {
      const records = await fetchTasks(windowed.fragment.inner.toUtc());
      return records.map((record) => {
        const $card = LonaCalendarTaskCard.make();
        $card.bind(record, use24Hour);

        const handler = new TaskCardHandler(
          record, target, windowed, /* pxPerHr */ 48, $card,
        );

        return {
          id: record.id,
          $e: $card,
          start: record.time.start,
          end: record.time.end,
          pointerEvents: "all",
          callbacks: handler.callbacks(),  // ← per-span callbacks
        };
      });
    },
  };
}

The factory is the only place target is constructed. Read-only sources omit callbacks (or supply CalendarColumnCell.Callbacks.DEFAULT) and set pointerEvents: "none" so the wrapper cell doesn't even receive events.

Step 6 — register from the plugin

subcolumnProvider.subcolumnFromRow is called by the parent plugin when it discovers your row in the tree (see Subcolumn Plugins). Inject the target there:

class TaskListPlugin implements RenderPlugin<HTMLElement, HTMLElement> {
  readonly id = "task-list";

  subcolumnProvider: CalendarSubcolumn.Provider<TaskRecord> = {
    subcolumnFromRow: (sheet, row, parentRow) => {
      const target = this.taskTargetFor(row);  // wraps producer-row task client
      const fetch = (range: DateTime.Range<Utc>) =>
        this.taskProvider.tasksInRange(row, range);
      return makeTaskSubcolumn(row, target, this.use24Hour, fetch);
    },
  };
}

That's the whole item-level gesture surface. No CalendarGesture implementation, no manager registration, no gestureItem factory.

What item-level handlers should do

  • Use the cell's Callbacks interface. It already gives you handle: Option<"top" | "bottom">, the resolved position, and a capture decision via onPointerMove's return value. Do not wire onpointerdown listeners directly.
  • Honor shouldCapturePointer. Return true from onPointerMove once the user is dragging so the cell holds the pointer through scroll.
  • Write through the injected target. Never look up a row index or call a global event/task store. The target carries producer- aware routing.
  • Render their own preview. Append a sibling DOM node into the lane container and reposition it during drag. Remove it on pointerup. There is no shared ghost signal in the new path.
  • Coordinate with other systems when showing menus. See Gesture CoordinationdismissOtherSystems + registerState apply equally to item-level and column-level menus.

Column-level gestures: the CalendarGesture manager

Column-level gestures are still useful for:

  • Drag-to-create on empty space. No specific item under the pointer at pointerdown; the gesture creates one.
  • Marquee or multi-select. Spans multiple cells.
  • Cross-system dismissal. A click on empty space should close everyone's open menus.
  • Long-press context menus on empty space.

These run through GESTURE_MANAGER.asEmptyCellDelegate(GestureInfo) that the host wires onto every cell wrapper. They never see pointer events that an item's $e captured first.

The CalendarGesture interface

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

Info is GestureInfo: { rowId, item, column, windowed, createHandler }. For column-level gestures info.item is null (no specific item under the pointer); info.createHandler gives access to the subcolumn's create policy.

Tutorial: drag-to-create a task

import { CalendarGesture } from "@tento-ui/components/calendar/calendar-gesture";
import { GestureInfo } from "@tento-lona/sheets-ui";

class DragToCreateTask extends CalendarGesture.Default<GestureInfo> {
  readonly identifier = "drag-to-create-task";
  private state: Option<{
    startTime: DateTime<Utc>;
    endTime: DateTime<Utc>;
  }> = null;

  constructor(
    private handle: SheetDelegate,
    private coordinator: GestureCoordinatorLike,
  ) {
    super();
  }

  // Only on cells with no item — column-level only.
  guard(info: GestureInfo): boolean {
    return info.item == null && info.createHandler != null;
  }

  canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
    return e.matchOpt({ pointerDown: () => true }) ?? 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();
    info.createHandler?.updateGhost(
      new DateTime.Range(this.state.startTime, this.state.endTime),
    );
    return { shouldCapturePointer: true };
  }

  onPointerUpColumn({ info }: CalendarGesture.Event.PointerUpColumn<GestureInfo>) {
    if (!this.state) return { finished: true };

    const range = new DateTime.Range(this.state.startTime, this.state.endTime);
    info.createHandler?.onDragCreate(range, { rowId: info.rowId, column: info.column });
    info.createHandler?.clearGhost();

    return { finished: true };
  }

  // Strong gesture once dragging starts — preempts weak gestures
  // (e.g. dismiss-on-empty-click).
  allowsConcurrentGestures(): boolean {
    return this.state == null;
  }

  onFinished() {
    this.state = null;
  }
}

Registering column-level gestures

The parent plugin owns the CalendarGesture.Manager. App-layer wiring adds gestures to it:

function registerTaskGestures(
  manager: CalendarGesture.Manager<GestureInfo>,
  handle: SheetDelegate,
  coordinator: GestureCoordinatorLike,
): void {
  manager
    .addGesture(new DragToCreateTask(handle, coordinator))
    .addGesture(new DismissOnEmptyClick(handle));
}

Order matters — the manager checks gestures in registration order. Place dismiss gestures after creation gestures so dismissal only fires when no creation gesture handles the event.

Lifecycle

guard(info) — fast filter

Called before canHandle. Returns false to skip this gesture for cells matching the info. Use it to filter by row context:

guard(info: GestureInfo): boolean {
  return info.item == null && info.createHandler != null;
}

canHandle(event) — phase match

Returns true if the gesture handles the current event phase. Events are a tagged union — use matchOpt:

canHandle(e: CalendarGesture.Event<GestureInfo>): boolean {
  return e.matchOpt({
    pointerUpColumn: () => true,
    pointerDownContextMenu: () => true,
  }) ?? false;
}

finished vs staying active

Return { finished: true } to remove the gesture from the active set. Return { finished: false } to stay active for subsequent events (e.g. holding open a popover until cancelled).

Weak vs strong (allowsConcurrentGestures)

  • Weak (true): multiple gestures can run together. Click-to-view and dismiss are typically weak.
  • Strong (false): preempts other active gestures via onFinished(cancelledBy). Drag-to-create becomes strong once it has captured movement.

Common pattern: weak until a movement threshold is crossed.

onFinished(cancelledBy)

Called when the gesture is removed from the active set. Use it to clean up state, dismiss menus, or commit pending changes. cancelledBy is the gesture that displaced this one, or null if it finished naturally.

Event phases

PhaseWhenCarries
pointerDownPointer pressedinfo, position, handle ("top"/"bottom"/null), onEvent
pointerMovePointer moved while downinfo, position, delta
pointerUpCellReleased on a cell with an iteminfo, pointerDownInfo, position
pointerUpColumnReleased on empty columninfo, pointerDownInfo, position
pointerDownContextMenuRight-clickinfo, offset

position is a Duration.Time — the time offset within the column.


Mutation routing

For both layers, write paths must go through producer rows. Calendar aliases and aggregates make the visible row diverge from the row that owns the item. Look-up patterns like mutationEventsIndex(row) are unsafe under aliases.

The pattern is:

  1. The item record carries ref: { rowId, itemId } end-to-end.
  2. The plugin layer constructs a domain-specific mutation target (EventMutationTarget, TaskMutationTarget, …) per source row, wired to the producer's domain client.
  3. The renderer (item-level) or the create handler (column-level) calls target.setTime(ref, range) directly. The target translates ref.rowId into the right producer client.

The repo design doc (__design_docs/lona-rows/gestures/) covers the per-domain target shape and the attribute-driven dispatch policy ({ existing: "producer-item" } vs { existing: { target: "row", … } } etc.).


Monthly view

The monthly view uses a separate CalendarMonthGesture interface with a simpler info type ({ date: NaiveDate }). The dual-layer model still applies: month spans (sleep, all-day) own native pointer events on their $e; cross-cell gestures (range select, shift-click) go through the month gesture manager. Positioning uses StaticChildHandle instead of PopoverHandle.


Migration note: events today

Calendar events still use the older CellType + EventGestureItem + CalendarGesture.Manager route for item-level interactions. The design above describes the target. Migration is in progress — see the source-bound gestures design doc in the repo for the per-domain plan. New renderers should use the Span pattern from day one.


See also

  • Gesture Lifecycle — reference walkthrough of every phase from DOM event through commit, invalidation, and rebind
  • Subcolumn Plugins — declaring a subcolumnProvider and choosing cells vs spans
  • Parent Plugins — hosting subcolumns and owning the gesture manager
  • Gesture Coordination — cross-system menu management; applies to both layers
  • Plugins — the RenderPlugin lifecycle and SheetDelegate positioning APIs