Primitives / Interaction

Pressing and dragging

Press turns pointer and keyboard activation of one element into one fact. Foldkit's declarative pointer attributes carry no button, pointer id, or click detail, so Press.events is a Mount that reports what the element saw (PointerDown, PointerUp, PointerCancelled, KeyDown, KeyUp, Clicked), and the Bundle's update decides: primary button only, one pointer at a time, pointerleave and pointercancel cancel, Enter and Space with a repeat ignored, a click with detail 0 (keyboard on a native control, or assistive technology) counts, and the ghost click that follows a touch is ignored inside a window of clickSuppressionMs that a Command on Effect's clock closes. Enter and Space are default-prevented on the element, so a native control does not also click and Space does not scroll. Activation is the OutMessage Pressed { pointerType, shiftKey }, and the placement must handle it:

ts
const Button = Bundle.declare(Press.bundle, 'saveButton')
const pressed = CardPage.assemble(
  CardPage.at(Button, {
    args: { clickSuppressionMs: 50 },
    onOut: () => model => ({ model, commands: [save(model)] }),
  }),
)
const Activate = Press.behavior(Button)(CardSlots)<CardModel, CardMessage>({
  target: 'save',
  disabled: model => model.saving,
})

The Behavior attaches the Mount to the target slot, writes data-pressed while the element is down for styling, and marks a disabled target aria-disabled, which the Mount reads at event time so nothing is reported and no remount is needed. The Model slice is { pressed, pointerId, key, suppressing, generation }; only pressed is meant for a view.

LongPress is holding for thresholdMs. It reads the same facts Press.events reports, so it needs no Mount of its own; the threshold is a Command on Effect's clock carrying a generation, and a release before it fires makes its Elapsed a no-op. LongPressed { pointerType } is the OutMessage, required at placement. The Behavior writes data-holding while down. Press and LongPress on one slot are refused by the resolver, since both would mount PressEvents; a slot takes one of them.

Move is pointer movement as facts, a Mount in foldkit-primitives/dom: a primary-button pointer down captures the pointer and reports MoveStarted, each move reports Moved { deltaX, deltaY } from where it went down, and up, cancel, or lost capture reports MoveEnded { completed }. A second pointer and a secondary button are ignored; capture is released with the Mount. Move.behavior(Slots)<Input, Message>({ handle, toMessage }) attaches it to a Draggable slot and maps each fact into the view's Messages; a drag's meaning (a threshold, a snap, a reorder) is the parent's update.

Targets is which marked descendant of a container the pointer is over, and which one was pressed, as one Mount on the container rather than one per item. A descendant is marked by an attribute holding its id (a canvas's data-composition-node, a table's data-row), and the nearest marked ancestor of the event's target, inside the container, is the one reported: TargetHovered { id } once per change, null when there is none or the pointer leaves, and TargetPressed { id, shiftKey, altKey, ctrlKey, metaKey } on a click. preventDefault: true stops a press's default, such as a link navigating inside an editor's canvas. The Mount is Targets({ attribute, preventDefault }) in foldkit-primitives/dom; Targets.behavior(Slots)<Input, Message>({ container, attribute, preventDefault?, toMessage }) attaches it. targetOf(container, from, attribute) is the pure lookup.

PointerDrag is dragging one marked descendant onto another, marked the way Targets marks them. A primary press on one that moves more than DRAG_THRESHOLD (4px) starts a drag (DragStarted { id }); then, once per change, DraggedOver { over } says which other marked descendant the pointer is over and in which third of its box, { id, zone: 'before' | 'inside' | 'after' }, or null. Releasing is DragDropped { id, over }, and Escape, a cancelled pointer, or a button found released mid-drag is DragCancelled { id }. An Escape that cancels is heard on the way down and goes no further, so the focused element's own Escape (a canvas's deselect) does not also run. It follows the pointer that pressed and ignores a second one; the element under it is found by position, so a touch or a pen, which the browser captures to where it went down, drags too, once the marked elements have touch-action: none so a finger drags rather than scrolls. The click a drop ends with is swallowed, so a Targets on the same container does not also press. A press in contenteditable text selects text and starts no drag. It writes no roles, tabindex, or keys, so it sits beside a tree's or a listbox's own; the keyboard's way to do what a drag does is yours to give. Boxes are measured as the pointer moves and never kept; an element drawn as display: contents is measured by its first child (boxOf). The Mount is PointerDrag({ attribute }) in foldkit-primitives/dom; PointerDrag.behavior(Slots)<Input, Message>({ container, attribute, toMessage }) attaches it, and zoneOf(box, y) is the pure split.

A drag may also land somewhere other than among its own: with targets: { attribute: 'data-composition-node', within: '#page' }, what is dragged is still one of the container's marked descendants (a palette's tiles), and over is an element marked by that attribute inside the one within selects (the page's nodes). Its own tiles, and a matching element outside that one, are then over nothing. within is looked for nearest first, under the container's closest ancestor that holds a match, so two editors on one page each drop onto their own page. Such a drag's DraggedOver and DragDropped also carry region: whether the pointer is inside that element, so over: null with region: true is its empty space (an empty page, the space below its last node), and with false, elsewhere.

A place can be a named part of what it is over: with part: 'data-composition-region', an element marked by both attribute (or targets.attribute) and part is reported as { id, zone: 'inside', part }, whatever third of it the pointer is in. An editor draws a node's empty Region that way, so a drop can name the Region it goes into.