Primitives / Interaction
Focus rings, overlays, selection, announcements
FocusVisible is the one entry whose Bundle lives elsewhere:
InputModality in foldkit-primitives/events keeps { modality }.
FocusVisible.behavior(Declared)(Slots)<Model, Message>({ target }) writes
data-focus-visible on the target while the page is driven by keyboard, so a
stylesheet shows a ring with [data-focus-visible]:focus. CSS :focus-visible
does this with no Model at all; this is for a design system that must decide
in the Model, or show the same answer somewhere other than the focused
element.
DismissLayer closes overlays that are not native <dialog> or popover
elements: Escape closes the topmost open layer, and a pointer press closes the
layers it is outside of. One Bundle, placed once, owns the document
listeners; each layer's Behavior marks its container with
data-foldkit-plus-layer="<id>" and its trigger with the matching trigger
attribute. The stack is the DOM order of the marked elements at the moment of
the event, so a layer takes part exactly while it is rendered and an open
flag in the parent Model is its whole lifecycle; nothing registers. The rules,
each a test: a press inside a parent layer is outside its children, so the
parent stays and the children go; a press on a layer's trigger counts as
inside it, so a click on the trigger never dismisses and reopens; a layer
placed with outsidePress: false or escape: false opts out of that path.
Dismiss { ids } is the OutMessage, and the placement's onOut closes them:
const Stack = Bundle.declare(DismissLayer.bundle, 'layers')
const dismissable = MenuPage.assemble(
MenuPage.at(Stack, {
onOut:
({ ids }) =>
model => ({ model: { ...model, menuOpen: ids.includes('menu') ? false : model.menuOpen } }),
}),
)
const Dismissable = DismissLayer.behavior(Stack)(MenuSlots)<MenuModel, MenuMessage>({
layer: 'panel',
trigger: 'button',
id: () => 'menu',
})The Model slice is { layers }, the open layers as the last event saw them,
for DevTools and agents. toDismiss(layers, inside) is the pure rule.
Layers.scrollLock(Slots)({ container }) and
Layers.hideOutside(Slots)({ container }) attach the ScrollLock and
HideOutside Mounts from foldkit-primitives/dom: the first locks the
document's scroll while the container is mounted, refcounted so nested
overlays release together; the second marks everything outside the container
inert while it is mounted. A native <dialog> shown modally needs neither.
Overlay is one policy value instead of nine overlay implementations:
Overlay.modal (dismissed by Escape and outside press, focused, locked,
inert) and Overlay.nonModal (dismissed, focus restored, page usable), or
an explicit Policy. Overlay.behaviors(Slots)<Model, Message,
StackField>({ stack, layer, trigger?, id, policy }) turns the value into
the existing pieces — DismissLayer marking, FocusScope, and the scroll
and inert mounts the policy keeps — to spread into the view's pipe. Nothing
is reimplemented: the mounts are the shared implementation. Positioning
stays per-widget (Anchor.behavior binds a floating element to an external
id, a different shape), and presence stays CSS. The stack bundle is placed
once per app; its Dismiss outmessage closes what it names. See
examples/drawer for the whole shape:
const DrawerOverlay = Overlay.behaviors(DrawerSlots)<Model, Message, 'layers'>({
stack: Stack,
layer: 'panel',
trigger: 'trigger',
id: () => 'drawer',
policy: Overlay.modal,
})
const Drawer = SlotView.forMessages<Message>()
.define(DrawerSlots, (model, slots, h) => h.div(/* … */))
.pipe(...DrawerOverlay.map(Behavior.attach))Placing keeps a floating panel inside the viewport, and can put it
under the trigger that opened it. On insert, keepWithin shifts left past
the right edge and flips above its trigger when the bottom overflows and
the room overhead fits; on release every inline prop goes.
Placing.placeFor(rect, viewport) is that pure rule. Placing.placeAt(trigger,
origin, gap?) is the other: the popup's left and top under the trigger,
relative to the offset parent. Placing.placeAtPoint(point, origin) is that
offset for a viewport point. Placing.placeAtTrigger(Slots)({ panel, triggerId, gap?, at? })
mounts one of them: at returning a point wins, and none places under the trigger.
The behavior stays named PlaceAt. The mount is PlaceAtPoint when at is
some and PlaceAt when it is none. OnContextMenu carries no coordinates, so
a right-click records clientX and clientY on pointerdown (button 2) and
passes that point through at only while it belongs to the open target. A
keyboard open has no point, so it places under the trigger. The Mount writes
left and the --fk-placed-top custom property (Placing.placedTop); the
panel's stylesheet reads that property as top, so a flip, which sets the
top property, still wins. Key the popup by what the args depend on, because
a Mount reads them once. A gap sits outside the trigger's border box, so
pointerleave on the trigger fires before the pointer reaches the popup:
listen on a wrap that contains both, or overlap the border boxes.
The two mounts start together, so the fit may run before the write; the
write measures again. A point that would hang past the bottom shifts up
(Placing.shiftIntoViewport) instead of flipping above its offset parent.
A trigger still flips. keepWithin sets data-fk-placed="above"
(Placing.placedSide, Placing.placedAbove) when it does, so a hover
bridge can overlap that side instead of the one below the trigger. No
timers, observers, or reposition loops.
Anchor.behavior relocates nodes and tracks a moving anchor; this places
once and stays inert without layout. KeepInView in foldkit-primitives/dom
scrolls newly marked content into view: that one moves the page to the panel.
Behavior.attach(Placing.keepWithin(WidgetSlots)({ panel: 'popup' })),Selection is which items are selected, with mode 'single' (a click
replaces; allowEmpty says whether clicking the selected item deselects it),
'multiple' (a click toggles), or 'none', and the anchor a range extends
from. The Model slice is { selected, anchor }. A range needs the items'
order, which the view knows and the Bundle does not, so Ranged { id, order }
carries it; Selection.between(order, from, to) is the pure span. The
Behavior, Selection.behavior(Declared, args)(Slots)<Model, Message>({ container?, item, items, click? }),
writes aria-selected on each item and aria-multiselectable on the
container, and wires a plain click on each enabled item to Activated; pass
click: false when Press or the view owns the click. For a Shift range,
Press's Pressed { pointerType, shiftKey } says whether Shift was held, and
the placement's onOut dispatches Ranged. For a selection no slot has to
reflect, SelectionSet in state is lighter.
LiveAnnounce speaks to assistive technology: one Bundle, placed once,
holds the text of a polite and an assertive live region.
say(Declared)(text, politeness?) builds the Message to return from update
or an onOut; an announcement waits debounceMs so a burst reads once, then
clears after clearAfterMs, both on Effect's clock with a generation so a
superseded timer changes nothing; the same text twice gets a trailing no-break
space toggled, which is what makes a screen reader read it again.
LiveAnnounce.view(slice, h) renders the two regions: put it once in the page
and hide them visually with a rule on [data-foldkit-plus-live], never
display: none.