https://github.com/bigskysoftware/moxi
moxi.js - a companion to fixi.js
https://github.com/bigskysoftware/moxi
Last synced: about 2 months ago
JSON representation
moxi.js - a companion to fixi.js
- Host: GitHub
- URL: https://github.com/bigskysoftware/moxi
- Owner: bigskysoftware
- Created: 2026-04-25T14:50:04.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-04-25T18:54:30.000Z (about 2 months ago)
- Last Synced: 2026-04-25T20:32:00.540Z (about 2 months ago)
- Language: HTML
- Homepage: https://fixiproject.org
- Size: 20.5 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
🥊 moxi.js - just a little more...
moxi.js is an experimental, minimalist companion to [fixi.js](https://github.com/bigskysoftware/fixi) that
lets you put small bits of behavior directly on HTML elements: event handlers, reactive
expressions & a compact query helper.
moxi is part of the [fixi project](https://fixiproject.org): fixi handles the network and swapping, moxi handles local
interactivity.
The two are designed to be used together, but moxi has no dependency on fixi and works fine on its own.
The moxi api consists of two [attributes](#attributes), nine [event modifiers](#event-modifiers),
three [globals plus a handler scope](#scope), and four [events](#events).
Here is an example:
```html
clear
```
When a user types into the `input`, the `output` updates automatically because the `live`
attribute re-runs when the DOM or form state changes.
The `button` clears the input when clicked, and the `output` updates again in response.
## Installing
moxi is designed to be easily [vendored](https://htmx.org/essays/vendoring/), that is, copied, into your project:
```bash
curl https://raw.githubusercontent.com/bigskysoftware/moxi/refs/tags/0.1.0/moxi.js >> moxi-0.1.0.js
```
The SHA256 of v0.1.0 is
`mrpYW3yY45ec7RlIyDVCDx2/NnrZOQNB4v+OavwQo7Q=`
generated by the following command line script:
```bash
cat moxi.js | openssl sha256 -binary | openssl base64
```
Alternatively can download the source from here:
You can also use the JSDelivr CDN for local development or testing:
```html
```
Finally, moxi is available on NPM as the [
`@bigskysoftware/moxi-js`](https://www.npmjs.com/package/@bigskysoftware/moxi-js) package.
## API
### Attributes
| attribute | description | example |
|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------|
| `on-` | Binds a handler for `` on this element. Colons are allowed in the event name (e.g. `on-fx:after`). | `on-click="q('#out').innerText = 'hi'"` |
| `on-init` | Special case - runs once at bind time rather than registering an event listener. Useful for setup code that lives on the element itself. | `on-init="this.dataset.ready = true"` |
| `live` | An expression that is evaluated at bind time and re-evaluated whenever the DOM or form state changes. Great for reactive output. | `live="this.innerText = q('#name').value"` |
| `mx-ignore` | Any element with this attribute on it or on an ancestor will be skipped during processing - no `on-*` or `live` attributes on it will be wired up. | |
### Event Modifiers
Modifiers are dot-separated and composable. They live between the event name and the `=`.
For example, `on-click.prevent.stop="..."` will both `preventDefault()` and `stopPropagation()`
before the body runs.
| modifier | description |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `.prevent` | Calls `event.preventDefault()` before the handler body runs. |
| `.stop` | Calls `event.stopPropagation()` before the handler body runs. |
| `.halt` | Equivalent to `.prevent.stop` - a shorthand for the common case. |
| `.once` | Removes the listener after the first successful fire. Plays correctly with `.self` and `.outside` - skipped invocations don't consume the listener. |
| `.self` | Skips the handler when `event.target !== this`. Ignores bubbled events from children. |
| `.capture` | Passes `{capture: true}` to `addEventListener`. |
| `.passive` | Passes `{passive: true}` to `addEventListener`. Required for smooth scroll/touch handlers. |
| `.outside` | Attaches the listener to `document` instead of `this`, and only fires when the event happened outside the element. Useful for dismissing menus and modals. |
| `.cc` | Camel-cases the event name. `on-my-event.cc` listens for `myEvent`. Useful when consuming custom events from libraries or web components that dispatch camelCase names, since HTML attribute names are lowercased by the parser and can't otherwise express mixed case. |
### Scope
moxi exposes three helpers on `globalThis`:
| name | type | description |
|------------------|---------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| `q(x)` | fn -> proxy | Query helper. `x` can be a selector string, a single element, or any iterable of elements. See [The `q()` Helper](#the-q-helper) below. |
| `wait(x)` | fn -> Promise | If `x` is a number, resolves after `x` milliseconds. If it's a string, resolves with the event the next time an event named `x` fires. |
| `transition(fn)` | fn | Wraps `fn` in `document.startViewTransition()`, with a fallback if unsupported. |
Inside `on-*` and `live` bodies, four additional bindings are in scope:
| name | type | description |
|----------------------------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `this` | `Element` | The element the attribute is on. |
| `event` | `Event` | Available in `on-*` handlers; undefined for `on-init` and `live`. |
| `trigger(type, detail, bubbles)` | fn | Dispatches a cancelable `CustomEvent` from `this`. `bubbles` defaults to `true`. From outside a handler, use `q(elt).trigger(...)` on the proxy instead. |
| `debounce(ms)` | fn -> Promise | Per-handler debouncer - superseded calls never resolve. Use with `await`. Handler-scope only because it carries per-handler state. |
`q()` directionals (`next`, `prev`, `closest`, `in this`) and `wait("event")` are
context-aware: in a handler they resolve relative to `this`; called globally they
resolve relative to `document.documentElement`.
Handler bodies are compiled as **async functions** (via the `AsyncFunction` constructor), so
`await` works anywhere.
#### Bare-name access to `event.detail`
For `on-*` handlers, every key on `event.detail` is also exposed as a top-level
variable inside the handler body. So instead of writing
```html
delete
```
you can drop the `event.detail.` prefix and write
```html
delete
```
Reads, mutations (`cfg.foo = ...`), and even reassignments (`cfg = {...}`) all hit the
underlying `event.detail` object. If a handler updates `cfg.confirm` inside an
`fx:config` listener, fixi sees the change. This is implemented with a `with` block
around the handler body, so:
* If `event.detail` is missing or null (e.g., a plain non-`CustomEvent`), nothing
is injected and the handler still runs.
* Names that aren't on `event.detail` resolve normally to `this`, `event`, `trigger`,
`debounce`, the global helpers (`q`, `wait`, `transition`), or any other binding.
* Assignments to a name that *isn't* already a property of `event.detail` fall
through to the outer scope, so they don't accidentally pollute `detail`.
### The `q()` Helper
`q(x)` returns a proxy over matched elements. `x` is most often a selector string, but
can also be a single `Element` (wrapped) or any iterable of elements (e.g. a `NodeList`
or `Array`). When given a string, the grammar is:
```
[ ][ in (this | )]
```
#### Directions
| direction | result |
|-------------|------------------------------------------------------------------------------|
| _(none)_ | All elements matching the selector in the scope (default scope: `document`). |
| `next X` | The first `X` after `this` in document order. |
| `prev X` | The last `X` before `this` in document order. |
| `closest X` | The same as `this.closest(X)`. |
| `first X` | The first `X` in the scope. |
| `last X` | The last `X` in the scope. |
#### Scoping with `in`
* `q('.row in this')` - scopes the query to `this`
* `q('.row in #panel')` - scopes the query to the element matching `#panel`
* If the scope selector matches nothing, `q` returns an empty proxy (no throw).
#### The Proxy
The object returned by `q()` is a `Proxy` that fans reads, writes, and method calls across
every matched element:
| operation | behavior |
|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `q(...).prop = v` | Sets `prop = v` on every match. |
| `q(...).method(...)` | Calls `method` on every match. Returns the result from the first match - so value-returning methods like `checkValidity()` or `getAttribute()` work naturally. |
| `q(...).prop` (object) | Returns a new proxy over `[e1.prop, e2.prop, ...]`, so nested access like `q('.row').classList.add('sel')` and `q('.row').style.color = 'red'` works. |
| `q(...).prop` (primitive or function) | Returns the value from the first match. |
| `q(...).count` | Returns the number of matched elements. |
| `q(...).arr()` | Returns the matched elements as a plain `Array`, so you can chain `.filter()`, `.map()`, etc. without spreading. |
| `q(...).trigger(type, detail, bubbles)` | Dispatches the event from every matched element. `bubbles` defaults to `true`. |
| `q(...).take(cls, from)` | Removes `cls` from every element matching `from` (a selector string or iterable of elements), then adds it to every matched element. Perfect for active-tab / active-nav patterns. |
| `q(...).insert(pos, html)` | Parses `html` and inserts it at every matched element. `pos` is one of `'before'` / `'start'` / `'end'` / `'after'` - a friendlier spelling of the four `insertAdjacentHTML` positions. |
| `for (let e of q(...))` / `[...q(...)]` | Iterates over the raw matched elements. |
### Events
moxi fires three lifecycle events. All are dispatched on the element being processed; listen
on the `document` for global hooks.
| event | description |
|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `mx:init` | Fired just before moxi initializes an element. Cancelable - calling `preventDefault()` will skip binding that element. |
| `mx:inited` | Fired after the element has been fully initialized. Does not bubble. |
| `mx:process` | moxi listens for this event on the `document` and will process the `evt.target` and its descendants. Dispatch this to force re-scanning after manual DOM changes. |
| `refresh` | moxi listens for this bubbling event on the `document` and re-runs every `live` expression. Dispatch it (e.g. via `trigger('refresh')` from a handler or `document.dispatchEvent(new Event('refresh'))`) when state outside the DOM changes and you want live blocks to recompute. |
### Properties
| property | description |
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `document.__moxi_mo` | The `MutationObserver` that moxi uses to auto-process newly added elements and to drive reactivity. You can `disconnect()` it temporarily for performance during large mutations. |
| `elt.__moxi` | An object mapping event names to the handlers moxi wired up on this element. Useful for debugging and for manually removing listeners. |
## Modus Operandi
moxi's entry point is at the bottom of [moxi.js](moxi.js). On `DOMContentLoaded` it:
1. Starts a `MutationObserver` watching the document for added nodes, attribute changes,
character data changes, and text child changes.
2. Adds capturing document-level listeners for `input` and `change` to drive reactivity.
3. Processes the existing body.
### Discovery
moxi finds elements using a single XPath query:
```
descendant-or-self::*[@live or @*[starts-with(name(),'on-')]]
```
That is - anything with a `live` attribute, or any attribute name starting with `on-`. XPath
means moxi only visits elements it actually needs to wire up, rather than iterating every
descendant.
### `on-*` Handlers
For each `on-[....]` attribute, moxi compiles the attribute value into an async
function with the handler scope described above, then attaches it as an event listener. The
attribute name after the `on-` prefix is the event name (colons allowed), optionally followed
by dot-separated modifiers.
If the event name is the literal string `init`, moxi invokes the function immediately instead
of registering a listener.
### `live` Expressions
For each `live` attribute, moxi compiles the value into an async function, runs it once, and
adds it to a global set of reactive expressions. Whenever the `MutationObserver` sees a
change, or the capturing `input`/`change` listener fires, every live expression is re-run.
To avoid runaway self-mutation cycles, moxi guards recompute behind a `pending` flag cleared
on the next macrotask - so a live expression writing to the DOM will, at worst, settle in two
ticks rather than cycle forever.
Live expressions whose element has been removed from the DOM are removed from the run set on
the next invocation (they detect `!elt.isConnected` and clean up).
### Pairing with fixi
moxi and [fixi](https://github.com/bigskysoftware/fixi) compose cleanly. Because moxi listens
for events via `on-*`, you can react to fixi's lifecycle events with an ordinary handler:
```html
...
```
or trigger a fixi request from a moxi handler by dispatching from the proxy:
```html
Reload
...
```
## Examples
### Reactive Output
```html
```
### Click Counter
```html
click me
0
```
### Active Tab With `take()`
```html
One
Two
Three
```
### Debounced Search
```html
```
### View Transition on Toggle
```html
toggle
...
```
### Click-Outside-To-Dismiss
```html
open menu
Menu contents...
```
### Parent-Listens-For-Child-Emits
```html
yes
no
```
## LICENCE
```
Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
```