Behaviors
Zero's components are composed from a small set of plain setup-time factories
over signals. Every one of them is exported from @sigx/zero/behaviors (and the package
root), so an ecosystem component uses the same machinery zero's own components do — and so
you can read a component's behavior off the primitives it spreads.
import {
createControllableState, createId, zeroPlugin,
createListController, createRovingKeydown, createTypeahead, createTreeController,
createDismissable, createAnchorPosition, pointAnchor, fixedPositionStrategy,
createPressFeedback, createSpinPress,
createFocusRestore, focusFirst, getTabbables, isFocusVisible,
useFieldContext, provideFieldContext, segmentOptions,
} from '@sigx/zero/behaviors';
@sigx/zero/behaviors/core is the platform-neutral subset — controllable state, ids, the
field context, option segmentation and the list controller with its element type left open —
importable without lib.dom. See Lynx.
Controllable state
createControllableState(getModel, defaultValue, onChange) is the primitive behind every
model: it reads and writes through the bound model when there is one
and through an internal signal otherwise, and fires onChange on every actual change in both
modes.
SSR-safe ids
ARIA wiring needs ids that are unique per app and identical between the server render and
client hydration. createId(prefix) mints one from an injectable generator; zeroPlugin()
installs a per-app generator so an SSR app's per-request app factory resets the counter:
app.use(zeroPlugin());
Client-only apps can skip the plugin — the fallback singleton only needs uniqueness.
Lists, roving focus and typeahead
createListController() is the registration primitive for anything with items: each item
registers { value, el, disabled } and the controller answers items() /
enabledItems() in DOM order — trusting only connected elements, with registration order
(depth-first render order) as the fallback for created-but-unattached ones, which is also
what makes it SSR-safe. moveHighlight and optionText are the listbox-highlight helpers
Select and Combobox step with.
createRovingKeydown({ list, orientation, loop, rtl, onMove }) returns a keydown handler
implementing the APG roving pattern: arrow keys move through the enabled items (Home / End
jump to the edges), the orientation decides which arrows, rtl flips the horizontal pair,
and the caller decides what "move" means — Tabs selects on focus in automatic mode, Menu
highlights, Select moves aria-activedescendant.
createTypeahead({ list, onMatch }) is first-character typeahead: printable keys accumulate
in a one-second buffer and the first enabled item whose text starts with it, searching from
after the current item, wins.
createTreeController({ isExpanded }) is the hierarchical version TreeView uses. It
implements the flat ListController interface with items() returning only the visible
nodes — every ancestor expanded — in DOM order, so roving and typeahead work on a tree
unchanged. It adds registerNode, visibleItems, level(value) (for aria-level),
childrenOf(parentValue) and findNode. Expansion state lives in the component as a
model; the controller only asks.
Dismissal
createDismissable({ getElement, isOpen, dismiss, outsidePress?, escape?, getExtraTargets? })
wires outside-press and Escape for an overlay surface while isOpen() is true, on a
module-scoped layer stack so nested overlays dismiss innermost-first. Where the platform
already dismisses — a modal <dialog>'s Escape, popover="auto" light dismiss — the
component syncs the native event back into its model instead. Combobox uses it because its
popover="manual" popup must survive a caret click; a non-modal Dialog uses it because a
non-modal <dialog> fires no cancel.
Positioning
The popover attribute lifts an element into the top layer but does not position it.
createAnchorPosition({ getAnchor, getFloating, isOpen, placement?, offset?, flip?, strategy? })
keeps a floating element positioned against its anchor while open (SSR-inert), and returns a
handle whose update() re-resolves the anchor and re-runs the strategy without an
open/close transition — a second right-click re-anchoring an open context menu.
The strategy is pluggable through the PositionStrategy interface
(apply(anchor, floating, opts) → cleanup). The built-in fixedPositionStrategy computes
fixed coordinates from the anchor rect — placement (default bottom), offset (default
6), viewport flip (default on) and shifting into the viewport — and tracks scroll and
resize. A richer engine such as @floating-ui/dom fits the same interface without zero
depending on it. Whatever the strategy, it stamps the popup's data-placement with where it
actually landed after flipping.
An anchor is anything that can report a client rect: an element, or a virtual anchor.
pointAnchor(x, y, size?) builds one at client coordinates — what Menu.ContextTrigger
anchors to under a right-click. The rect is captured once; a moved pointer means a new
pointAnchor plus update().
Press feedback
CSS can react to :active, but it cannot see where a press landed or let a one-shot
effect run past release. createPressFeedback({ getElement, isDisabled?, oneShot? })
publishes exactly that, as data a design system consumes in pure CSS:
data-pressed— present while the pointer or key is physically down.data-press-animating— present from press-start until the design system's press animation ends, however it ends: finished, cancelled, or destroyed along with the stylesheet that declared it. Not until release — so a 50 ms tap still plays a full ripple. If the active design system attaches no animation to the flag, it is removed synchronously.--press-x/--press-y— the press point in px, relative to the part's border box. Keyboard presses (Enter / Space) write the box centre.--press-r— the distance from the press point to the farthest corner, so a covering circle iscalc(var(--press-r) * 2)wide without CSS trigonometry. Coordinates survive release deliberately: a release fade may still be reading them.
The lifecycle rule: a press ends when the gesture ends, and pointer capture defines the
gesture. An uncaptured pointerleave cancels the press (drag off a button to cancel); a
captured pointer — a native range input dragging, touch's implicit capture — holds it until
pointerup or pointercancel. A drag surface (Slider) spreads no key handlers, no
pointerleave, and passes oneShot: false: data-pressed and the coordinates still
publish, the animating flag does not. Releases that land off-element are caught by a
one-shot window listener installed at press start.
Every interactive part publishes it: button root; tabs tab; dialog / popover / drawer
trigger and close; menu trigger and item; select and combobox trigger and item;
collapsible / accordion trigger; switch / checkbox control and radio-group item-control
(a press anywhere in the label row lands on the visible control); slider control
(data-pressed only); pagination item and prev/next triggers; steps item. The tooltip
trigger is the deliberate exception — it declares only disabled, and a recipe that spreads
a press-aware helper into it would emit a ripple that can never fire.
This is what makes a pointer-anchored effect — a Material ink ripple, a selection-control halo, a slider-thumb halo — expressible as pure CSS. A recipe reads the pair and the custom properties; see Recipes.
Spin press
createSpinPress({ onSpin, isDisabled?, delay?, interval? }) is press-and-hold auto-repeat
for NumberInput's stepper triggers: one spin fires immediately on press, repetition starts
after delay (default 400 ms) and ticks every interval (default 64 ms), the gesture ends
wherever the pointer is released, and dragging off the trigger stops the repeat. Call
stop() from onUnmounted. Keyboard needs none of this — a held arrow key auto-repeats
keydown natively.
Focus
isFocusVisible(el)— the platform's:focus-visibleheuristic, read at focus time so a component can publish thefocus-visibleflag on a different element.createFocusRestore(isOpen)— restore focus to the previously focused element whenisOpen()turns false; the cover for surfaces the platform does not restore for (show()dialogs, popovers).focusFirst(container)/getTabbables(container)— move focus into a surface on open.
Field context
useFieldContext() / provideFieldContext(ctx) carry a Field's
control id and its disabled / invalid / required flags to the control inside it, plus
aria-describedby for its description and error. Outside a Field the injectable falls back
to an inert context, so a bare control still renders.
Option segmentation
segmentOptions(options) turns the options array Select, NativeSelect and Combobox accept
({ value, label?, disabled?, group? }[]) into ordered groups — one per distinct group in
first-appearance order, ungrouped options in a group of their own — the shape the default
composition renders from.
