Packages
Cloves catalog
ajo-cloves: ready-made behaviors for interaction, focus and sensing.


ajo-cloves is a catalog of ready-made cloves: plain functions that attach one behavior to a stateful Ajo component and return a live view of it. Controlled state, dismissal, keyboard movement, focus return, media queries and storage each come as a separate function, so a component takes only what it needs. The catalog is also the behavior layer under ajo-ui.
Every clove follows the pattern described in Cloves: it takes the component host, cleans up through host.signal, re-renders through host.next(), and returns an object whose identity never changes. Call cloves before the render loop; there are no call-order rules.
Install
pnpm add ajo@0.1.35 ajo-cloves@0.1.2The package requires ajo ^0.1.35, has a single root entry point, and is side-effect-free, so a bundle keeps only the cloves you import.
Compose two behaviors
A details button for a note shows the pattern. controlled lets the parent own the open state or leave it to the component; dismiss closes the panel on Escape or on a pointer press outside it. Neither knows anything about the markup.
import type { Stateful } from 'ajo'
import { controlled, dismiss } from 'ajo-cloves'
type Args = {
label: string
open?: boolean
onOpenChange?: (open: boolean, event?: Event) => void
}
const Details: Stateful<Args> = function* (args) {
let trigger: HTMLButtonElement | null = null
let panel: HTMLDivElement | null = null
const open = controlled(this, {
fallback: false,
onChange: (value, event) => args.onOpenChange?.(value, event),
})
dismiss(this, {
active: () => open.value,
inside: () => [trigger, panel],
outside: true,
onDismiss: event => open.set(false, event),
})
for (args of this) {
open.sync(args.open) // undefined leaves the component in charge
yield (
<>
<button
type="button"
ref={el => trigger = el}
aria-expanded={open.value ? 'true' : 'false'}
set:onclick={event => open.set(!open.value, event)}
>
{args.label}
</button>
{open.value && <div ref={el => panel = el}>Created on Tuesday · 3 revisions</div>}
</>
)
}
}
export default DetailsOptions that are functions, such as active and inside, are read when the behavior runs, so they always see the latest state and arguments. Reassigning args in for (args of this) keeps the onChange closure current too.
Catalog
Every clove takes the host first and, when it has options, one options object. The tables list each export as the published type declarations define it.
Interaction
| Export | Purpose | Options and view |
|---|---|---|
controlled | Controlled or uncontrolled value state. | Options fallback, onChange. View value, controlled, sync(arg), set, accept, init. sync(undefined) means uncontrolled; any other value, null included, binds. |
dismiss | Escape and optional outside-pointer dismissal. | active, inside, escape ('host', 'document' or false; default host), outside (default false), prevent, onDismiss. |
hover | Hover intent across named zones. | openDelay, closeDelay (ms, default 0), onChange. View open, hold, release, sync, cancel. |
timer | One-shot timeout with pause and resume. | No options. View start(ms, fn), stop, pause, resume, running, remaining. |
roving | Arrow, Home and End movement over a live item list. | items, orientation, dir, loop, current, onMove. View handle(event), move(step, event). |
typeahead | Printable-key buffer with prefix matching. | items, text, delay, onMatch. View handle(event), reset. |
selection | Single or multiple selection of string values. | multiple, required, fallback, onChange. View values, has, toggle, set, sync. |
restore | Capture focus and return it later. | No options. View capture(element?), restore. |
move | Pointer-drag sessions with deltas and cancellation. | onStart, onMove, onEnd receive x, y, dx, dy, canceled. View start(event), active. |
grid | Two-dimensional key movement for grids and calendars. | rtl, onMove with a GridMove. View handle(event). |
spin | Spinbutton stepping keys. | onMove with a SpinMove. View handle(event). |
label | Ids and ARIA wiring for one form field. | prefix. Returns a LabelView of attr bags. |
hotkey | A global single-chord keyboard shortcut. | keys, onPress, active, prevent (default true). |
announce | Polite or assertive screen-reader announcements. | No options. View polite(message), assertive(message). |
set and accept on controlled both update the value; set notifies onChange first and accept after, and init seeds an uncontrolled value without notifying. The GridMove, SpinMove and LabelView types are exported too.
Positioning
| Export | Purpose | Options and view |
|---|---|---|
indicator | Writes a marked child’s box to its container as --indicator-x, -y, -w and -h, with data-indicator="true" while a mark exists. | target, of, on. View sync. |
A theme draws a pseudo-element from those variables and transitions it, so an active marker glides between tabs instead of jumping. This site’s documentation sidebar uses it for the current page.
Sensors
| Export | Purpose | Options and view |
|---|---|---|
media | A media-query match, shared per query string. | query, fallback. View matches, sync. |
scheme | The operating system’s dark-scheme preference. | No options. View dark. |
storage | A localStorage or sessionStorage string that follows other tabs. | key, fallback, area ('local' or 'session'). View value, set, remove. |
scrolling | Frame-coalesced scroll tracking for a live element. | target, onScroll, onEnd. View sync. |
resize | Shared ResizeObserver notifications for a live element. | target, onResize. View sync. |
overflow | Stamps data-overflow-x and data-overflow-y (start, end or both) while content overflows. | target. View sync. |
visibility | Document visibility. | No options. View visible. |
Cloves with a target option observe an element you hold in a ref. Call sync() in the render loop, after the ref may have changed, and the clove moves its listeners to the new element.
Infrastructure
| Export | Purpose |
|---|---|
Host | The Ajo host type, Host<TElement, TArgs>, for clove authors. |
browser() | True when both window and document exist. |
dom(value) | True for a real element, false for an ajo/html protocol-only host. |
listen(host, type, handler, opts?) | Adds a host listener that stops with the host or an optional caller signal; inert under SSR. |
statefulRootAttrs(attrs) | Maps plain attributes onto a stateful host, prefixing DOM attributes with attr:. |
callHandler, callRef | Call a consumer’s optional event handler or callback ref. |
clamp(value, min, max) | Clamps a number to an inclusive range. |
remember(cache, key, value, limit?) | Stores a value in an insertion-ordered map of at most 32 keys by default. |
id(prefix) | A monotonic id per prefix. |
shared(key, start, fn, signal) | Starts one source for all subscribers with the same key and stops it after the last one aborts. |
frame(fn) | Coalesces calls into one run on the next animation frame; the scheduler has cancel(). |
Attr bags
When a behavior needs several attributes and handlers on one rendered element, its view exposes an attr bag to spread in JSX. A bag holds HTML attributes such as id, role, tabindex, aria-* and data-*, plus Ajo set:on* handlers. ajo/html renders the attributes on the server and the client attaches the handlers during hydration. Ajo reapplies a bag when keyed reconciliation reuses an element.
In the catalog, label returns bags for every part of a form field:
import type { Stateful } from 'ajo'
import { label } from 'ajo-cloves'
type Args = { hint?: string; error?: string }
const TitleField: Stateful<Args> = function* () {
const field = label(this, { prefix: () => 'title' })
for (const { hint, error } of this) {
field.reset() // a new render pass
field.sync(Boolean(error)) // invalid state and error wiring
field.describe(Boolean(hint)) // whether a description renders
yield (
<>
<label {...field.labelAttrs}>Title</label>
<input name="title" {...field.controlAttrs} />
{hint && <p {...field.descriptionAttrs}>{hint}</p>}
{error && <p {...field.errorAttrs}>{error}</p>}
</>
)
}
}
export default TitleFieldcontrolAttrs carries the input’s id, aria-describedby and, when invalid, aria-invalid and aria-errormessage. buttonAttrs and groupAttrs serve button-like controls and grouped fields.
Runtime behavior
- Work attached to a host stops when
host.signalaborts: on unmount, and on a reset that restarts the generator, after which the cloves set up again. APIs that accept a caller signal stop when either signal aborts. - State changes run inside
host.next(), so they re-render the component and are ignored after it ends. Views such astimerandstoragealso become inert once their host has ended. - Sensors that read the same source share it. Two components watching the same media query or storage events use one listener, which stops with its last subscriber.
- DOM behavior follows native browser behavior and accessible interaction patterns. Cloves compute movement and state; your component decides where focus goes and what renders.
More examples
A theme toggle
scheme follows the system preference and storage remembers an explicit choice, in every open tab. The component writes to the document only in a browser. This site’s own toggle uses the same two cloves.
import type { Stateful } from 'ajo'
import { browser, scheme, storage } from 'ajo-cloves'
const ThemeToggle: Stateful = function* () {
const system = scheme(this)
const saved = storage(this, { key: () => 'theme', fallback: 'system' })
while (true) {
const dark = saved.value === 'dark' || (saved.value === 'system' && system.dark)
if (browser()) document.documentElement.dataset.theme = dark ? 'dark' : 'light'
yield (
<button
type="button"
aria-pressed={dark ? 'true' : 'false'}
set:onclick={() => saved.set(dark ? 'light' : 'dark')}
>
Dark theme
</button>
)
}
}
export default ThemeToggleA keyboard shortcut
hotkey listens on the window for one chord: modifiers mod, ctrl, meta, alt and shift joined with + to one key. mod matches Ctrl or Meta, but not both at once. A match calls preventDefault() unless prevent: false, and active can switch the shortcut off.
import type { Stateful } from 'ajo'
import { hotkey } from 'ajo-cloves'
const QuickNote: Stateful = function* () {
let input: HTMLInputElement | null = null
hotkey(this, { keys: () => 'mod+k', onPress: () => input?.focus() })
while (true) yield (
<>
<label for="quick-note">New note</label>
<input id="quick-note" name="text" ref={el => input = el} />
</>
)
}
export default QuickNote- ClovesThe pattern: write your own clove in a few lines.
- Unstyled UIComponent families built on these behaviors.
- LifecycleHow this.signal and this.next() drive cleanup and updates.
Source of truth: ajo-cloves README