Packages

Cloves catalog

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

A gilded garlic bulb with three separated cloves on the sand.A gilded garlic bulb with three separated cloves on the sand.

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

Terminal
pnpm add ajo@0.1.35 ajo-cloves@0.1.2

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

src/notes/details.tsx
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 Details

Options 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

ExportPurposeOptions and view
controlledControlled 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.
dismissEscape and optional outside-pointer dismissal.active, inside, escape ('host', 'document' or false; default host), outside (default false), prevent, onDismiss.
hoverHover intent across named zones.openDelay, closeDelay (ms, default 0), onChange. View open, hold, release, sync, cancel.
timerOne-shot timeout with pause and resume.No options. View start(ms, fn), stop, pause, resume, running, remaining.
rovingArrow, Home and End movement over a live item list.items, orientation, dir, loop, current, onMove. View handle(event), move(step, event).
typeaheadPrintable-key buffer with prefix matching.items, text, delay, onMatch. View handle(event), reset.
selectionSingle or multiple selection of string values.multiple, required, fallback, onChange. View values, has, toggle, set, sync.
restoreCapture focus and return it later.No options. View capture(element?), restore.
movePointer-drag sessions with deltas and cancellation.onStart, onMove, onEnd receive x, y, dx, dy, canceled. View start(event), active.
gridTwo-dimensional key movement for grids and calendars.rtl, onMove with a GridMove. View handle(event).
spinSpinbutton stepping keys.onMove with a SpinMove. View handle(event).
labelIds and ARIA wiring for one form field.prefix. Returns a LabelView of attr bags.
hotkeyA global single-chord keyboard shortcut.keys, onPress, active, prevent (default true).
announcePolite 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

ExportPurposeOptions and view
indicatorWrites 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

ExportPurposeOptions and view
mediaA media-query match, shared per query string.query, fallback. View matches, sync.
schemeThe operating system’s dark-scheme preference.No options. View dark.
storageA localStorage or sessionStorage string that follows other tabs.key, fallback, area ('local' or 'session'). View value, set, remove.
scrollingFrame-coalesced scroll tracking for a live element.target, onScroll, onEnd. View sync.
resizeShared ResizeObserver notifications for a live element.target, onResize. View sync.
overflowStamps data-overflow-x and data-overflow-y (start, end or both) while content overflows.target. View sync.
visibilityDocument 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

ExportPurpose
HostThe 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, callRefCall 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:

src/notes/title-field.tsx
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 TitleField

controlAttrs 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.signal aborts: 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 as timer and storage also 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.

src/theme-toggle.tsx
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 ThemeToggle

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

src/notes/quick-note.tsx
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

Source of truth: ajo-cloves README