Something went wrong

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

Add a Gesture - Lona Docs Log in

Add a Gesture

A copy-pasteable recipe for wiring a new gesture into a sheet renderer. Pairs with Overview (concepts) and Lifecycle (reference).

Pick the layer first

If your gesture is…UseImplement
On a specific rendered item (drag the card, resize the chip, click the span)Item-levelA renderer-owned handler that supplies Span.callbacks
On empty space (drag-to-create, marquee select, dismiss other menus)Column-levelA CalendarGesture registered on the parent's CalendarGesture.Manager

Most new gestures are item-level. Reach for column-level only when there is no specific item under the pointer at pointerdown, or when the gesture must coordinate across renderers.

If your renderer needs both (e.g. tasks support drag-to-reschedule on existing items and drag-to-create on empty space), do both — the two paths run independently. Steps 1–6 cover item-level; step 7 covers column-level.


Item-level gesture (steps 1–6)

The pattern is: record carries provenance → handler is the state machine → mutation target is the write seam → renderer returns spans with callbacks. The wrapper cell handles geometry; you never read MouseEvent or PointerEvent yourself.

Step 1 — model the record with ref

Every record carries the producer row + producer item id so writes survive aliasing and aggregation.

import type { RowLocalId } from "@tento-lona/sheets";
import type { DateTime, Utc } from "@tento-chrono";

interface FocusBlockRecord {
  readonly id: string;
  readonly title: string;
  readonly time: DateTime.Range<Utc>;
  // Producer reference — stays stable across alias / aggregate rows.
  readonly ref: { readonly rowId: RowLocalId; readonly itemId: string };
}

The provider that loads records (your subcolumnProvider or an upstream plugin) must populate ref. Never derive it from the visible row at gesture time.

Step 2 — define a mutation target

Renderers write through an interface, not a concrete client. The plugin layer (Step 6) injects the implementation.

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

Add only the methods this renderer needs. Targets are domain-specific — do not generalize across event/task/scheduling unless the write contract truly is identical.

Step 3 — build the visual web component

Pure visual. No pointer events. Reflects state via attributes the handler toggles in Step 4.

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-focus-block-card" })
export class LonaFocusBlockCard extends LonaWebComponent {
  private $title = this.$("title");
  private $time = this.$("time");

  bind(record: FocusBlockRecord, 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-focus);
        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>
  `;
}

Conventions: --font-size not font-size, --margin-top not margin-top, design tokens for colors. See the Web Components and Typography guides.

Step 4 — write the per-record handler

The handler builds CalendarColumnCell.Callbacks, owns the drag state machine, paints its own ghost, and calls the target on commit.

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

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

  constructor(
    private readonly record: FocusBlockRecord,
    private readonly target: FocusBlockMutationTarget,
    private readonly windowed: DateFragment.Windowed,
    private readonly pxPerHr: number,
    private readonly $card: LonaFocusBlockCard,
    private readonly $lane: HTMLElement, // for ghost DOM (sibling)
  ) {}

  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 coordinate via the
        // GestureCoordinator (see Coordination doc).
      },
    };
  }

  private derive(t: Duration.Time): DateTime.Range<Utc> {
    const start = this.dragStart!;
    // handle === "top"    → move start, keep end
    // handle === "bottom" → keep start, move end
    // handle === null     → translate both by (t - startTime)
    // (Implement using DateTime arithmetic; this is the only place
    // pixel-derived time becomes a record range.)
    return /* ... */ start.range;
  }

  private updateGhost(range: DateTime.Range<Utc>): void {
    // Append/move a sibling DOM node inside this.$lane.
  }

  private removeGhost(): void {
    // Remove any ghost node from this.$lane.
  }
}

Rules of the road:

  • Use position.resolve(windowed, pxPerHr) for pixel→time. Never do manual arithmetic.
  • Honor shouldCapturePointer. Return true from onPointerMove every move once dragging starts. Returning false releases capture.
  • Be defensive about dragStart === null in onPointerUpCell — the user may release without a prior move (a click).
  • Clean up the ghost idempotently.
  • Discard the handler at end of gesture. A new one is built on every makeSpans rebind, so closures over a stale record never commit.

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";
import { RowId } from "@tento-lona/sheets";

function makeFocusBlockSubcolumn(
  row: SheetRow.Joined.Client,
  target: FocusBlockMutationTarget,
  use24Hour: boolean,
  fetchBlocks: (range: DateTime.Range<Utc>) => Promise<FocusBlockRecord[]>,
): CalendarSubcolumn<FocusBlockRecord> {
  let $lane: Option<HTMLElement> = null;

  return {
    rowId: RowId.fromLocalId(row.id),
    renderer: "spans",
    makeLayout: () => {
      const $c = SpanColumn.make();
      $c.bind([24]);
      $lane = $c;
      return $c;
    },
    makeSpans: async (windowed, _tzr) => {
      const records = await fetchBlocks(windowed.fragment.inner.toUtc());
      return records.map((record) => {
        const $card = LonaFocusBlockCard.make();
        $card.bind(record, use24Hour);

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

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

Read-only sources omit callbacks (or pass CalendarColumnCell.Callbacks.DEFAULT) and set pointerEvents: "none" so the wrapper cell does not even receive events.

Step 6 — register from the plugin

Inject the target at the only place it is constructed: subcolumnProvider.subcolumnFromRow. The provider gets called per discovered row by the parent layout (see Add a Layout).

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

export class FocusBlockPlugin
  implements RenderPlugin<HTMLElement, HTMLElement>
{
  readonly id = "focus-blocks";

  constructor(
    private readonly clients: ProducerClientRegistry, // your domain wiring
    private readonly use24Hour: boolean,
  ) {}

  subcolumnProvider: CalendarSubcolumn.Provider<FocusBlockRecord> = {
    subcolumnFromRow: (_sheet, row, _parentRow) => {
      const target = this.targetFor(row); // resolves producer client by row
      const fetch = (range: DateTime.Range<Utc>) =>
        this.fetchBlocks(row, range);
      return makeFocusBlockSubcolumn(row, target, this.use24Hour, fetch);
    },
  };

  // ...standard RenderPlugin methods (makeCell, bindCell, etc.)
}

For aggregate rows (one renderer over N source rows), targetFor(row) should return a dispatcher that routes by ref.rowId — not a single producer client. The dispatcher pattern is described in the Overview under "Mutation routing".

That is the whole item-level surface. No gestureItem factory, no CalendarGesture subclass, no manager registration.


Step 7 — column-level gesture (drag-to-create, marquee, dismiss)

If you also need a column-level gesture, write a CalendarGesture and register it on the parent's manager. Column-level gestures only fire on cells that did not have Span.callbacks (i.e. empty space, or spans that explicitly opted out of item-level handling).

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

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

  // Only fire on empty cells with a create handler.
  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 start = info.windowed.fragment.start.withTime(
      position.floor(Duration.Time.MIN15).toTimeOfDayWrapping(),
    );
    this.state = {
      startTime: start.toUtc(),
      endTime: start.add(Duration.Time.hrs(1)).toUtc(),
    };
    return { finished: false };
  }

  onPointerMove({
    info,
    position,
  }: CalendarGesture.Event.PointerMove<GestureInfo>) {
    if (!this.state) return { shouldCapturePointer: false };
    const end = info.windowed.fragment.start.withTime(
      position.floor(Duration.Time.MIN15).toTimeOfDayWrapping(),
    );
    this.state.endTime = end.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 once dragging — preempts weak gestures (e.g. dismiss).
  allowsConcurrentGestures(): boolean {
    return this.state == null;
  }

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

Wire your subcolumn's createHandler (Step 5/6) and register the gesture on the parent layout's manager:

manager
  .addGesture(new DragToCreateFocusBlock(handle, coordinator))
  .addGesture(new DismissOnEmptyClick(handle));

Order matters — register dismiss gestures after creation gestures so dismissal only fires when no creation gesture handled the event.


Verification checklist

Before merging:

  • [ ] Records carry ref. Add a fixture (or quick log) that shows record.ref.rowId matches the producer row, not the visible alias.
  • [ ] Drag commit hits the right row. With two source rows feeding one alias, drag an item from each — the producer row receives the write.
  • [ ] Read-only sources cannot mutate. Confirm pointerEvents: "none" (no handlers) or that the target throws with a useful message.
  • [ ] shouldCapturePointer returns true every move while dragging, not just the first move. Otherwise capture release will drop the gesture mid-drag.
  • [ ] Ghost cleanup is idempotent. Drag, release outside the cell, drag again — no orphan ghost DOM.
  • [ ] Column-level create still works on cells without callbacks (e.g. empty space between blocks).
  • [ ] ./run ts check passes; relevant ./run tests.lona-js passes; UX-test on a real sheet via the ux-test skill.

See also

  • Overview — the dual-layer model and full background
  • Lifecycle — every phase from DOM event to rebind, with debugging tips
  • Coordination — cross-system menu management; required if your gesture opens a popover
  • Add a Layout — build a parent layout that hosts subcolumns and routes gestures
  • Subcolumn Plugins — declaring subcolumnProvider and choosing cells vs spans