Thank you for being patient! We're working hard on resolving the issue
Gestures handle pointer interactions inside a parent plugin's grid. The system has two layers that solve different problems:
| Layer | Owns the pointer | Use 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 parent | Drag-to-create on empty space, marquee selection, anything that spans multiple items or empty cells |
Picking the right layer:
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.
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:
Callbacks per span.gestureManager.asEmptyCellDelegate(GestureInfo) which dispatches
registered CalendarGesture instances.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.
The renderer's content component (in our tutorial,
LonaCalendarTaskCard) participates in the chain in two ways:
<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).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.
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.
Span shapeimport { 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.
We'll build a task renderer where each task is a span with drag-to-reschedule and click-to-open behavior.
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 };
}
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>;
}
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.
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.
callbacks from makeSpansimport { 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.
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.
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.shouldCapturePointer. Return true from
onPointerMove once the user is dragging so the cell holds the
pointer through scroll.dismissOtherSystems + registerState apply equally to
item-level and column-level menus.CalendarGesture managerColumn-level gestures are still useful for:
pointerdown; the gesture creates one.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.
CalendarGesture interfacetype 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.
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;
}
}
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.
guard(info) — fast filterCalled 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 matchReturns 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 activeReturn { 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).
allowsConcurrentGestures)true): multiple gestures can run together. Click-to-view
and dismiss are typically weak.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.
| Phase | When | Carries |
|---|---|---|
pointerDown | Pointer pressed | info, position, handle ("top"/"bottom"/null), onEvent |
pointerMove | Pointer moved while down | info, position, delta |
pointerUpCell | Released on a cell with an item | info, pointerDownInfo, position |
pointerUpColumn | Released on empty column | info, pointerDownInfo, position |
pointerDownContextMenu | Right-click | info, offset |
position is a Duration.Time — the time offset within the column.
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:
ref: { rowId, itemId } end-to-end.EventMutationTarget, TaskMutationTarget, …) per source row,
wired to the producer's domain client.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.).
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.
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.
subcolumnProvider and choosing cells vs spansRenderPlugin
lifecycle and SheetDelegate positioning APIs