Thank you for being patient! We're working hard on resolving the issue
A copy-pasteable recipe for wiring a new gesture into a sheet renderer. Pairs with Overview (concepts) and Lifecycle (reference).
| If your gesture is… | Use | Implement |
|---|---|---|
| On a specific rendered item (drag the card, resize the chip, click the span) | Item-level | A renderer-owned handler that supplies Span.callbacks |
| On empty space (drag-to-create, marquee select, dismiss other menus) | Column-level | A 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.
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.
refEvery 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.
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.
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.
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:
position.resolve(windowed, pxPerHr) for pixel→time. Never
do manual arithmetic.shouldCapturePointer. Return true from onPointerMove
every move once dragging starts. Returning false releases capture.dragStart === null in onPointerUpCell —
the user may release without a prior move (a click).makeSpans rebind, so closures over a stale record never
commit.callbacks from makeSpansimport { 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.
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.
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.
Before merging:
ref. Add a fixture (or quick log) that shows
record.ref.rowId matches the producer row, not the visible alias.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.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.subcolumnProvider and choosing cells vs spans