Something went wrong

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

Add a Layout - Lona Docs Log in

Add a Layout

A step-by-step recipe for building a new parent layout that hosts subcolumns and supports gestures. The calendar plugin is the canonical example; this guide describes what to build when you need a different layout (Gantt, swimlane, week-grid, custom timeline) that still participates in the gesture system.

Pairs with Parent Plugins (broader contract) and Add a Gesture (what your subcolumns will hand you).

What a layout owns

A parent layout is responsible for four things:

  1. Discovering child rows from the sheet tree.
  2. Asking each child for a subcolumn via subcolumnProvider.
  3. Laying out each subcolumn's content in its own coordinate system (time, pixels, lanes).
  4. Routing pointer events through wrapper cells so that:
    • Item-level callbacks supplied by renderers fire on the item.
    • Empty space goes through the parent's CalendarGesture.Manager.

Subcolumns do not change shape between layouts — the same tasks plugin can render in a daily timeline or a Gantt chart without modification, as long as both layouts honor the contract below.

Shape of the contract

parent layout (you)                                 subcolumn (existing)
─────────────────                                   ────────────────────
discovers children                  ─── asks ───→   subcolumnFromRow(row)
                                  ←── returns ───  CalendarSubcolumn<T>
positions content in a grid                        makeSpans / makeLayout
wraps each $e in CalendarColumnCell                 (or bindCell for cells)
calls bindCallbacks(span.callbacks                   each span carries
  ?? asEmptyCellDelegate(info))                      callbacks for item-level
owns CalendarGesture.Manager
  for column-level gestures

The wrapper cell is the seam. The layout does not care what the renderer's content looks like; the renderer does not care what the layout's coordinate system is.


Steps

Step 1 — choose your coordinate system

Decide what (x, y) means for a click in your layout. The choice determines which positioning handle you reach for from SheetDelegate:

Coordinate systemUseHandle
Column index + row local id (scroll-aware)Daily/weekly timeline, week gridhandle.popover, handle.fixedChild
Pure pixel offset within a rowMonthly grid, custom canvashandle.staticChild
Both, for tooltipsHover annotationshandle.tooltip

Most new timeline-style layouts want column + row. Monthly-style layouts (the entire range fits in one row) want pure pixels.

Step 2 — declare the layout plugin

Layouts are RenderPlugin implementations. The minimum surface looks like every other plugin (id, makeCell, bindCell, lifecycle hooks); the distinguishing piece is that bindCell does subcolumn discovery and calls into a renderer-facing helper that wraps each $e in a CalendarColumnCell.

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

export class SwimlaneLayoutPlugin
  implements RenderPlugin<SwimlaneCell, SwimlaneLegend>
{
  readonly id = "swimlane[column]";
  readonly icon = swimlaneIconTemplate;

  // Owned by the layout. Lives across rebinds.
  readonly gestureManager = new CalendarGesture.Manager<GestureInfo>();

  makeCell(): SwimlaneCell {
    return SwimlaneCell.make();
  }

  async bindCell(
    $cell: SwimlaneCell,
    context: BindCellContext,
  ): Promise<void> {
    const { row, column, handle, sheet } = context;

    const subcolumns = this.discoverSubcolumns(
      sheet,
      row,
      handle.pluginManager,
    );
    await this.renderSubcolumns($cell, row.id, subcolumns, context);
  }

  onResize(): void {}
  onBindRow(): void {}
  onViewportColumnsChanged(): void {}
  onRenderedColumnsWillChange(): void {}
  optionsInfo(): Option<string> { return null; }
}

The gesture manager is a field because column-level gestures register on it once at app-wiring time; it persists across cell rebinds.

Step 3 — discover child rows

Walk the row's children and ask each child's plugin for a subcolumn. The exact walk depends on your layout — most layouts iterate row.children() directly; some flatten through a layer of implementation rows (see the calendar's timeline[column] example in the source-bound-gestures design docs).

private discoverSubcolumns(
  sheet: SheetViewModel.Readable,
  row: SheetRow.Joined.Client,
  pluginManager: SheetDelegate.PluginManager,
): CalendarSubcolumn<unknown>[] {
  const subcolumns: CalendarSubcolumn<unknown>[] = [];
  for (const child of row.children?.() ?? []) {
    const plugin = pluginManager.getPlugin(child.type);
    const sub = plugin?.subcolumnProvider?.subcolumnFromRow(
      sheet,
      child,
      row,
    );
    if (sub) subcolumns.push(sub);
  }
  return subcolumns;
}

If your layout interposes implementation rows between the visible row and provider rows (an alias hydration pattern), be explicit about which row anchors popovers (the visible row) versus which row hosts discovery (the layout row). Mixing them up is the most common bug at this layer — see the Source-Bound Gestures design notes for the pattern (anchorRowId vs layoutRowId).

Step 4 — render each subcolumn into your grid

For renderer: "spans" subcolumns, request spans, lay them out in your coordinate system, then wrap each $e in a CalendarColumnCell and wire callbacks. This is what makes the layout gesture-capable.

private async renderSubcolumns(
  $cell: SwimlaneCell,
  rowId: RowLocalId,
  subcolumns: CalendarSubcolumn<unknown>[],
  context: BindCellContext,
): Promise<void> {
  for (const subcolumn of subcolumns) {
    if (subcolumn.renderer !== "spans") continue;

    const $lane = subcolumn.makeLayout();
    $cell.appendLane($lane);

    const spans = await subcolumn.makeSpans(
      context.windowed,
      context.dr.tz,
    );

    for (const span of spans) {
      const $wrapper = CalendarColumnCell.makeWith({
        id: span.id,
        time: this.spanTimeRange(span), // your layout's time computation
        content: span.$e,
      });
      $wrapper.style.setProperty(
        "--default-interactable",
        span.pointerEvents ?? "",
      );
      $wrapper.style.pointerEvents = "var(--default-interactable)";

      const fallback = this.gestureManager.asEmptyCellDelegate(
        new GestureInfo(
          rowId,
          /* item */ null,
          context.column.withSubindex(this.subindexFor($lane)),
          context.windowed,
          subcolumn.createHandler ?? null,
        ),
      );

      $wrapper.bindCallbacks(span.callbacks ?? fallback, "swimlane");
      $lane.appendChild($wrapper);
    }
  }
}

The two key calls:

  • CalendarColumnCell.makeWith({ content: span.$e }) — wraps the renderer's element so the cell can do edge classification, position resolution, and pointer capture.
  • bindCallbacks(span.callbacks ?? asEmptyCellDelegate(info)) — picks the right routing for this cell. Item-level callbacks fire the renderer's handler; the empty-cell delegate routes through your layout's gestureManager for column-level gestures (drag-to-create, marquee, dismiss).

renderer: "cells" subcolumns follow the same pattern except you call subcolumn.bindCell($wrapper, item, ctx) after wrapping.

Step 5 — register column-level gestures (once, at app wiring)

The layout owns the manager but does not register gestures itself. The app layer composes the right gestures for the deployment:

// In your app's wiring code (not the layout plugin):
swimlaneLayout.gestureManager
  .addGesture(new DragToCreateBlock(handle, coordinator))
  .addGesture(new MarqueeSelect(handle, coordinator))
  .addGesture(new DismissOnEmptyClick(handle));

Order matters — register dismiss gestures after creation gestures. See Add a Gesture Step 7 for the CalendarGesture shape.

Step 6 — expose positioning APIs to renderers

Renderers and column-level gestures need to open popovers, context menus, and tooltips. You do not implement those handles — the SheetDelegate already gives you handle.popover, handle.fixedChild, handle.staticChild, and handle.tooltip. Your job is to:

  • Anchor by the visible row, not an implementation row. When your layout has implementation rows between the visible row and the provider, popovers must use the visible row's id, or they appear in the wrong place vertically.
  • Pass the right column coordinate. Use context.column.withSubindex(...) if your layout has lane-level subindices; otherwise pass context.column directly.
// From within a renderer's onContextMenu or a column-level gesture's
// onPointerUp:
handle.popover.reveal(
  "swimlane-block-details",
  $details,
  { x: column, y: visibleRowId },
  { offsetY: pixelOffsetForTime, context: "block-details" },
);

See Coordination for the full protocol — dismissOtherSystems, registerState, and mode transfer.

Step 7 — full-day aggregation (optional)

If your layout has a full-day row (an aggregate strip that sits above the time grid), implement fullDayEventsFromRow so child plugins can contribute items:

subcolumnProvider: CalendarSubcolumn.Provider<unknown, FullDayItem> = {
  subcolumnFromRow: (...) => { /* primary subcolumn */ },

  fullDayEventsFromRow: (row, range) => {
    const items: FullDayItem[] = [];
    for (const child of row.children?.() ?? []) {
      const plugin = this.pluginManager.getPlugin(child.type);
      const childItems =
        plugin?.subcolumnProvider?.fullDayEventsFromRow?.(child, range);
      if (childItems) items.push(...childItems);
    }
    return items;
  },
};

The aggregate row uses the same Span pattern, just with a different lane layout — date-spanning bars instead of time-of-day blocks.


Common pitfalls

  • Forgetting bindCallbacks. A wrapper cell with no callbacks silently swallows pointer events. Always pass either span.callbacks or asEmptyCellDelegate(info). Use the ?? pattern in Step 4 to make the fallback automatic.
  • Anchoring popovers to an implementation row. If your layout hides a layer of rows from the user (calendar aliases hydrate this way), popover.reveal({ y: rowId }) must use the visible row's id, not the implementation row's. The user will see popovers jumping to the wrong vertical position otherwise.
  • Re-implementing edge geometry. Top/bottom 4px resize-handle detection lives in CalendarColumnCell.bindCallbacks. Do not duplicate it in your layout. Set editable on the wrapper to surface the visual handles.
  • Reading MouseEvent directly. The wrapper cell is the only thing that should attach native pointer listeners. Layouts and renderers consume Callbacks.
  • Sharing one GESTURE_MANAGER-wide gesture state. Each layout owns its own CalendarGesture.Manager. Do not try to share state between two parent layouts via the manager — coordinate through the GestureCoordinator instead.
  • Rebinding handlers stale. Build a new handler per record per makeSpans call; do not cache them keyed by record id. The pointer flow is short-lived and the next bind discards the old handler.

Verification checklist

  • [ ] Subcolumn discovery. Render a row whose children include both a cells- and a spans-renderer plugin; both render in your layout's grid.
  • [ ] Item-level gestures fire. Drag a span; the renderer's handler runs and calls its mutation target.
  • [ ] Column-level gestures fire on empty space. Drag on empty space; your registered gestures (e.g. drag-to-create) run via asEmptyCellDelegate.
  • [ ] Popovers anchor correctly. With an alias-hydrated tree, a popover opened from a span anchors to the visible row.
  • [ ] Read-only sources are inert. A subcolumn that returned spans with pointerEvents: "none" does not respond to drag.
  • [ ] No double dispatch. A single pointerdown does not fire both an item-level callback and a column-level gesture.
  • [ ] ./run ts check passes; UX-test on a real sheet via the ux-test skill.

See also

  • Parent Plugins — broader contract for parent plugins (also covers child discovery and aggregation patterns)
  • Subcolumn Plugins — what your child plugins implement
  • Add a Gesture — what the renderer-side handler looks like that you'll invoke from your wrapper cells
  • Overview — dual-layer model, Span pattern, mutation routing
  • Lifecycle — full bind → pointer → commit → invalidation → rebind walk
  • Coordination — cross-system menu coordination via GestureCoordinator
  • PluginsRenderPlugin basics and SheetDelegate positioning handles