Skip to content

bigskysoftware/moxi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🥊 moxi.js - just a little more...

moxi.js is an experimental, minimalist companion to fixi.js 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: 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, nine event modifiers, three globals plus a handler scope, and four events.

Here is an example:

<input id="name" placeholder="name">
<output live="this.innerText = 'hello ' + q('#name').value"></output>
<button on-click="q('#name').value = ''">clear</button>

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, that is, copied, into your project:

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:

cat moxi.js | openssl sha256 -binary | openssl base64

Alternatively can download the source from here:

https://github.com/bigskysoftware/moxi/archive/refs/tags/0.1.0.zip

You can also use the JSDelivr CDN for local development or testing:

<script src="https://cdn.jsdelivr.net/gh/bigskysoftware/moxi@0.1.0/moxi.js"
        crossorigin="anonymous"
        integrity="sha256-mrpYW3yY45ec7RlIyDVCDx2/NnrZOQNB4v+OavwQo7Q="></script>

Finally, moxi is available on NPM as the @bigskysoftware/moxi-js package.

API

Attributes

attribute description example
on-<event> Binds a handler for <event> 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 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

<button on-fx:config="event.detail.cfg.confirm = () => confirm('Delete?')">delete</button>

you can drop the event.detail. prefix and write

<button on-fx:config="cfg.confirm = () => confirm('Delete?')">delete</button>

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:

[<direction> ]<css-selector>[ in (this | <scope-selector>)]

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. 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-<event>[.<mod>...] 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 compose cleanly. Because moxi listens for events via on-*, you can react to fixi's lifecycle events with an ordinary handler:

<div fx-action="/data" on-fx:after="q('closest section').classList.add('loaded')">
    ...
</div>

or trigger a fixi request from a moxi handler by dispatching from the proxy:

<button on-click="q('#target').trigger('refresh')">Reload</button>
<div id="target" fx-action="/data" fx-trigger="refresh">...</div>

Examples

Reactive Output

<input id="name" placeholder="type something">
<output live="this.innerText = 'hello ' + (q('#name').value || 'stranger')"></output>

Click Counter

<button on-init="this.count = 0"
        on-click="this.count++; q('next output').value = this.count">click me
</button>
<output>0</output>

Active Tab With take()

<nav>
    <button class="tab active" on-click="q(this).take('active', '.tab')">One</button>
    <button class="tab" on-click="q(this).take('active', '.tab')">Two</button>
    <button class="tab" on-click="q(this).take('active', '.tab')">Three</button>
</nav>

Debounced Search

<input on-input="await debounce(250); q('next output').innerText = 'searching ' + this.value">
<output></output>

View Transition on Toggle

<button on-click="transition(() => q('#panel').classList.toggle('open'))">toggle</button>
<div id="panel">...</div>

Click-Outside-To-Dismiss

<button on-click="q('#menu').hidden = false">open menu</button>
<div id="menu" hidden on-click.outside="this.hidden = true">
    Menu contents...
</div>

Parent-Listens-For-Child-Emits

<dialog on-confirm="alert('confirmed: ' + event.detail)">
    <button on-click="trigger('confirm', 'yes')">yes</button>
    <button on-click="trigger('confirm', 'no')">no</button>
</dialog>

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.

About

moxi.js - a companion to fixi.js

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors