Skip to content

bigskysoftware/moxi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🥊 moxi.js - just a bit 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, and a compact query helper - all inline, all in one attribute.

Part of the fixi project.

Where 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 perfectly well on its own.

The moxi api consists of three attributes, eight event modifiers, seven handler helpers, and three lifecycle 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.

Minimalism

Philosophically, moxi is to hyperscript what fixi is to htmx: a smaller, less ambitious version of the same idea, with fewer features, no DSL, and no parser beyond a couple of regexes. You write plain JavaScript - moxi just gives you a tiny, DOM-flavored scope to write it in.

As such, it does not include many of the features found in hyperscript, Alpine, or Vue:

  • a bespoke scripting language
  • reactive stores / shared JS state
  • templating or iteration directives (x-for, v-for)
  • two-way data binding (v-model, x-model)
  • component model / custom elements
  • fetch, swap, or any network behavior - that's fixi's job

moxi takes advantage of some modern JavaScript features:

A hard constraint on the project is that the unminified, uncompressed size of moxi.js must be less than the minified + gzipped size of preact. Current sizes are listed on the fixi project site.

Another goal is that users should be able to debug moxi easily, since it is small enough to use unminified.

Like fixi, moxi has very few moving parts:

  • No dependencies (including test and development)
  • No public JS API (beyond the events and __moxi property)
  • No minified moxi.min.js file
  • No package.json
  • No build step

The moxi project consists of four files:

  • moxi.js, the code for the library
  • test.html, the test suite for the library
  • demo.html, interactive examples you can open directly
  • This README.md, which is the documentation

test.html is a stand-alone HTML file that implements its own visual testing infrastructure and can be opened using the file: protocol for easy testing.

Installing

moxi is designed to be easily vendored - that is, copied into your project:

<script src="moxi.js"></script>

That's the entire install. moxi auto-initializes on DOMContentLoaded and uses a MutationObserver to pick up elements added later.

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
.preventCalls event.preventDefault() before the handler body runs.
.stopCalls event.stopPropagation() before the handler body runs.
.haltEquivalent to .prevent.stop - a shorthand for the common case.
.onceRemoves the listener after the first successful fire. Plays correctly with .self and .outside - skipped invocations don't consume the listener.
.selfSkips the handler when event.target !== this. Ignores bubbled events from children.
.capturePasses {capture: true} to addEventListener.
.passivePasses {passive: true} to addEventListener. Required for smooth scroll/touch handlers.
.outsideAttaches the listener to document instead of this, and only fires when the event happened outside the element. Useful for dismissing menus and modals.
.ccCamel-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.

Handler Scope

Inside every on-* and live expression, moxi injects the following variables:

name type description
thisElementThe element the attribute is on.
eventEventAvailable in on-* handlers; undefined for on-init and live.
q(sel)fn -> proxyQuery helper. See The q() Helper below.
trigger(type, detail)fnDispatches a bubbling, cancelable CustomEvent from this.
wait(x)fn -> Promisex can be a number (ms delay) or a string (event name, resolves with the event object).
debounce(ms)fn -> PromisePer-handler debouncer - superseded calls never resolve. Use with await.
transition(fn)fnWraps fn in document.startViewTransition(), with a fallback if unsupported.
take(cls, from, to)fnRemoves cls from every element matching selector from, then adds it to to. Perfect for active-tab / active-nav patterns.

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 the helpers above (q, trigger, wait, ...) or to globals.
  • 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(selector) returns a proxy over matched elements. The selector 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 XThe first X after this in document order.
prev XThe last X before this in document order.
closest XThe same as this.closest(X).
first XThe first X in the scope.
last XThe 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 = vSets 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(...).countReturns 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)Dispatches the event from every matched element.
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.

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 via trigger:

<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="take('active', '.tab', this)">One</button>
  <button class="tab"        on-click="take('active', '.tab', this)">Two</button>
  <button class="tab"        on-click="take('active', '.tab', this)">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>

Extensions

Because moxi ships no public JS API beyond its scope helpers and lifecycle events, extensions are mostly a matter of hanging additional behavior off the mx:init and mx:inited events. A suggested convention when adding moxi extension attributes is to use the ext-mx prefix:

// ext-mx-log: logs every time the element receives focus
document.addEventListener("mx:inited", (evt) => {
  if (evt.target.hasAttribute("ext-mx-log")) {
    evt.target.addEventListener("focus", () => console.log("focused:", evt.target))
  }
})
<input ext-mx-log placeholder="focus me">

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