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:
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.