Thank you for being patient! We're working hard on resolving the issue
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).
A parent layout is responsible for four things:
subcolumnProvider.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.
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.
Decide what (x, y) means for a click in your layout. The choice
determines which positioning handle you reach for from
SheetDelegate:
| Coordinate system | Use | Handle |
|---|---|---|
| Column index + row local id (scroll-aware) | Daily/weekly timeline, week grid | handle.popover, handle.fixedChild |
| Pure pixel offset within a row | Monthly grid, custom canvas | handle.staticChild |
| Both, for tooltips | Hover annotations | handle.tooltip |
Most new timeline-style layouts want column + row. Monthly-style layouts (the entire range fits in one row) want pure pixels.
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.
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).
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.
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.
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:
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.
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.
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.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.CalendarColumnCell.bindCallbacks. Do not
duplicate it in your layout. Set editable on the wrapper to
surface the visual handles.MouseEvent directly. The wrapper cell is the only
thing that should attach native pointer listeners. Layouts and
renderers consume Callbacks.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.makeSpans call; do not cache them keyed by record id. The
pointer flow is short-lived and the next bind discards the old
handler.cells- and a spans-renderer plugin; both render in
your layout's grid.asEmptyCellDelegate.pointerEvents: "none" does not respond to drag.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.GestureCoordinatorRenderPlugin
basics and SheetDelegate positioning handles