fixi.js is an experimental, minimalist implementation of generalized hypermedia controls
The fixi api consists of six HTML attributes, nine events & two properties
Here is an example:
<button fx-action="/content" <!-- URL to issue request to -->
fx-method="get" <!-- HTTP Method to use -->
fx-trigger="click" <!-- Event that triggers the request -->
fx-target="#output" <!-- Element to swap -->
fx-swap="innerHTML"> <!-- How to swap -->
Get Content
</button>
<output id="output"></output>
When this fixi-powered button
is clicked it will issue an HTTP GET
request to the
/content
relative URL and swap the HTML content of
the response inside the output
tag below it.
Philosophically, fixi is scheme to htmx's common lisp: it is designed to be as lean as possible while still being useful for real world projects.
As such, it doesn't have many of the features found in htmx, including:
- request queueing & synchronization
- extended selector support
- extended event support
- attribute inheritance
- request indicators
- CSS transitions
- history support
fixi takes advantage of some modern JavaScript features not used by htmx:
async
functions- the
fetch()
API - the use of
MutationObserver
for monitoring when new content is added - The View Transition API (used by htmx, but the sole mechanism for transitions in fixi)
A hard constraint on the project is that the unminified, uncompressed size must be less than that of the minified & compressed version of the (excellent) preact library (currently 4.6Kb).
The current uncompressed size is 3479
bytes and the gzip'd size is 1389
bytes as determined by:
ls -l fixi.js | awk '{print "raw:", $5}'; gzip -k fixi.js; ls -l fixi.js.gz | awk '{print "gzipped:", $5}'; rm fixi.js.gz
Another goal is that users should be able to debug fixi easily, since it is small enough to use unminified.
The code style is dense, but the statements are structured for debugging.
Like a fixed-gear bike, fixi has very few moving parts:
- No dependencies (including test and development)
- No JavaScript API (beyond the events)
- No
fixi.min.js
file - No
package.json
- No build step
The fixi project consists of three files:
fixi.js
, the code for the librarytest.html
, the test suite for the library- This
README.md
, which is the documentation
test.html
is a stand-alone HTML file that implements its own visual testing infrastructure, mocking for
fetch()
, etc. and that can be opened using the file:
protocol for easy testing.
fixi is intended to be vendored, that is, copied, into your project:
curl https://raw.githubusercontent.com/bigskysoftware/fixi/refs/tags/0.2.2/fixi.js >> fixi-0.2.2.js
The SHA256 of v0.2.2 is
+VRZVkmnpfeRIqD4JQx7uV9v9V+lHtQW7tFMtYso9jc=
generated by the following command line script:
cat fixi.js | openssl sha256 -binary | openssl base64
Alternatively can download the source from here:
https://github.com/bigskysoftware/fixi/archive/refs/tags/0.2.2.zip
You can also use the JSDelivr CDN for local development or testing:
<script src="https://cdn.jsdelivr.net/gh/bigskysoftware/[email protected]/fixi.js"
crossorigin="anonymous"
integrity="sha256-+VRZVkmnpfeRIqD4JQx7uV9v9V+lHtQW7tFMtYso9jc="></script>
fixi is not distributed via NPM.
attribute | description | example |
---|---|---|
fx‑action
|
The URL to which an HTTP request will be issued, required |
fx‑action='/demo'
|
fx‑method |
The HTTP Method that will be used for the request (case‑insensitive), defaults to GET |
fx‑method='DELETE' |
fx‑target |
A CSS selector specifying where to place the response HTML in the DOM, defaults to the current element | fx‑target='#a‑div' |
fx‑swap |
A string specifying how the content should be swapped into the DOM, one of innerHTML , outerHTML , beforestart , afterstart , beforeend or afterend . outerHTML is the default. |
fx‑swap='innerHTML' |
fx‑trigger |
The event that will trigger a request. Defaults to submit for form elements, change for input ‑like elements & click for all other elements |
fx‑trigger='click' |
fx‑ignore |
Any element with this attribute on it or on a parent will not be processed for fx‑* attributes |
fixi works in a straight-forward manner & I encourage you to look at the source as you read through this. The three components of fixi are:
- Processing elements in the DOM (or added to the DOM)
- Issuing HTTP requests in response to events
- Swapping new HTML content into the DOM
The main entry point is found at the bottom of fixi.js: on the DOMContentLoaded
event fixi does two things:
- It establishes a MutationObserver to watch for newly added content with fixi-powered elements
- It processes any existing fixi-powered elements
fixi-powered elements are elements with the fx-action
attribute on them.
When fixi finds one it will establish an event listener on that element that will dispatch an AJAX request via
fetch()
to the URL specified by fx-action
.
fixi will ignore any elements that have the fx-ignore
attribute on them or on a parent.
The event that will trigger the request is determined by the fx-trigger
attribute.
If that attribute is not present, the trigger defaults to:
submit
forform
elementschange
forinput:not([type=button])
,select
&textarea
elementsclick
for everything else.
When a request is triggered, the HTTP method of the request
will be determined by the fx-method
attribute. If this attribute is not present, it will default to GET
. This
attribute is case-insensitive.
fixi sends the request header FX-Request
, with the value
true
. You can add or remove headers using the evt.detail.cfg.headers
object, see the fx:config
event
below.
If an element is within a form element or has a form
attribute, the values of that form will be included with the
request. Otherwise, if the element has a name
, its name
& value
will be sent with the request. You can add or
remove values using the evt.detail.cfg.form
FormData
object in the fx:config
event.
GET
& DELETE
requests will include values via query parameters, other request types will submit them as a form
encoded body.
Before a request is sent, the aforementioned fx:config
event is triggered, which can be used to configure
aspects of the request. If preventDefault()
is invoked in this event, the request will not be sent.
The evt.detail.cfg.drop
property will be set to true
if there is an existing outstanding request associated with
the element and, if it is not set to false
in an event handler, the request will be dropped (i.e. not issued).
In the fx:config
event you can also set the evt.detail.cfg.confirm
property to a no-argument function.
This function can return a Promise and can be used to asynchronously confirm that the request should be issued:
function showAsynConfirmDialog() {
//... a Promise-based confirmation dialog...
}
document.addEventListener("fx:config", (evt) => {
evt.detail.cfg.confirm = showAsynConfirmDialog;
})
Note that confirmation will only occur if the fx:config
event is not canceled and the request is not
dropped.
After the configuration step and the confirmation, if any, the fx:before
event will be triggered,
and then a fetch()
will be issued. The evt.detail.cfg
object from the events above is passed to the fetch()
function as the second RequestInit argument.
When fixi receives a response it triggers the fx:after
event.
In this event there are two more properties available on evt.detail.cfg
:
response
the fetch Response objecttext
the text of the response
These can be inspected, and the text
value can be changed if you want to transform it in some way.
If an error occurs the fx:error
event will be triggered instead of fx:after
.
Note that fetch()
only triggers errors
when a request fails due to a bad URL or network error,
so valid HTTP responses with non-200
response codes will not trigger an error. If you wish to handle those differently
you should check the response code in the fx:after
event:
document.addEventListener("fx:after", (evt)=>{
// rewire 404s to the body, remove current head so new head can replace it
if (evt.detail.cfg.response.status == 404){
document.head.remove()
evt.detail.cfg.target = docuement.body
evt.detail.cfg.swap = 'outerHTML'
}
})
The fx:finally
event will be triggered regardless if an error occurs or not.
fixi then swaps the response text into the DOM using the mechanism specified by fx-swap
, targeting the element specified
by fx-target
. If the fx-swap
attribute is not present, fixi will use outerHTML
.
If the fx-target
attribute is not present, it will target the element making the request.
The swap mechanism and target can be changed in the request-related fixi events.
You can implement a custom swapping mechanism by setting a function into the evt.detail.cfg.swap
property in one of
the request related events. This function should take two arguments: the target element and the text to swap. You can
see an example below showing how to do this.
By default, swapping will occur in a View Transition
if they are available. If you don't want this to occur, you can set the evt.detail.cfg.transition
property to false
in one of the request-related events.
Finally, when the swap and any associated View Transitions have completed, the fx:swapped
event will be triggered on
the element.
Here is an complete example using all the attributes available in fixi:
<button fx-action="/demo" fx-method="GET"
fx-target="#output" fx-swap="innerHTML"
fx-trigger="click">
Get Content
</button>
<output id="output" fx-ignore>--</output>
In this example, the button will issue a GET
request to /demo
and put the resulting HTML into the innerHTML
of the
output element with the id output
.
Because the output
element is marked as fx-ignore
, any fx-action
attributes in the new content will be ignored.
fixi fires the following events, broken into two categories:
category | event | description |
---|---|---|
initialization |
fx:init
|
triggered on elements that have a fx-action attribute and are about to be initialized by fixi
|
fx:inited
|
triggered on elements have been initialized by fixi (does not bubble) | |
fx:process
|
fixi listens on the document object for this event and will process (that is, enable any fixi-powered
elements) within that element.
|
|
fetch |
fx:config
|
triggered on an element immediately when a request has been triggered, allowing users to configure the request |
fx:before
|
triggered on an element just before a fetch() request is made
|
|
fx:after
|
triggered on an element just after a fetch() request finishes normally but before content is swapped
|
|
fx:error
|
triggered on an element if an exception occurs during a fetch()
|
|
fx:finally
|
triggered on an element after a request no matter what | |
fx:swapped
|
triggered after the swap and any associated View Transition has completed |
The fx:init
event is triggered when fixi is processing a node with an fx-action
attribute. One property
is available in evt.detail
:
options
- An Options Object that will be passed toaddEventListener()
If this event is cancelled via preventDefault()
, the element will not be initialized by fixi.
The fx:inited
event is triggered when fixi finished processing a node with an fx-action
attribute.
Unlike other fixi events, this event does not bubble.
fixi listens for the fx:process
event on the document
and will enable any unprocessed fixi-powered elements within
the element as well as the element itself.
fixi uses the fetch()
API to issue HTTP requests. It
triggers events around this call that allow users to configure the request.
The first event triggered is fx:config
. This event can be used to configure the arguments passed to fetch()
via the
fixi config object, which can be found at evt.detail.cfg
.
This config object has the following properties:
trigger
- The event that triggered the requestmethod
- The HTTP Method that is going to be usedaction
- The URL that the request is going to be issued toheaders
- An Object of name/value pairs to be sent as HTTP Request Headerstarget
- The target element that will be swapped when the response is processedswap
- The mechanism by which the element will be swappedbody
- The body of the request, if present, a FormData object that holds the data of the form associated with thedrop
- Whether this request will be dropped, defaults totrue
if a request is already in flighttransition
- Whether to use the View Transition API for swaps, defaults totrue
preventTriggerDefault
- A boolean (defaults to true) that, if true, will callpreventDefault()
on the triggering eventsignal
- The AbortSignal of the related AbortController for the requestabort()
- A function that can be invoked to abort the pending fetch request
Mutating the method
, etc. properties of the cfg
object will change the behavior of the request dynamically. Note
that the cfg
object is passed to fetch()
as the second argument of type RequestInit
, so any properties you want
to set on the RequestInit
may be set on the cfg
object (e.g. credentials
).
Another property available on the detail
of this event is requests
, which will be an array of any existing
outstanding requests for the element.
fixi does not implement request queuing like htmx does, but you can implement a simple "replace existing requests in flight" rule with the following JavaScript:
document.addEventListener("fx:config", (evt) => {
evt.detail.cfg.drop = false; // allow this request to be issued even if there are other requests
evt.detail.requests.forEach((cfg) => cfg.abort()); // abort all existing requests
})
If you call preventDefault()
on this event, no request will be issued.
The fx:before
event is triggered just before a fetch()
is issues. The config will again be available in the
evt.detail.cfg
property, but after any confirmation is done. The requests will also be available in
evt.detail.requests
and will include the current request.
If you call preventDefault()
on this event, no request will be issued.
The fx:after
event is triggered after a fetch()
successfully completes. The config will again be available in the
evt.detail.cfg
property, and will have two additional properties:
response
- The response object from thefetch()
calltext
- The text of the response
At this point you may still mutate the swap
, etc. attributes to affect swapping, and you may mutate the text
if you
want to modify it is some way before it is swapped.
Calling preventDefault()
on this event will prevent swapping from occurring.
The fx:error
event is triggered when a network error occurs. In this case the cfg.txt
will be set to a blank
string, and the evt.detail.cfg
object is available for modification.
Calling preventDefault()
on this event will prevent swapping from occurring. Note that AbortError
s will also prevent
swapping.
The fx:finally
event is triggered regardless if an error occurs or not and can be used to clean up after a request.
Again the evt.detail.cfg
object is available for modification.
The fx:swapped
event is triggered once the swap and any associated View Transitions complete. The
evt.detail.cfg
object is available.
fixi adds two properties to elements in the DOM
property | description |
---|---|
document.__fixi_mo
|
The MutationObserver that fixi uses to watch for new content to process new fixi-powered elements. |
elt.__fixi
|
The event handler created by fixi on fixi-powered elements |
fixi stores the Mutation Observer that it uses to watch for new content in the __fixi_mo
property on the document
.
You can use this property to temporarily disable mutation observation for performance reasons:
// disable processing
document.__fixi_mo.disconnect()
/* ... heavy mutation code that should not be processed by fixi */
// reenable processing
document.__fixi_mo.observe(document.body, {childList:true, subtree:true})
Similar code can be used to adjust the MutationObserver to listen for mutations in some subset of the document.
Finally, you can also switch to entirely manual processing using the fx:after
,
fx:swapped
& fx:process
events:
document.__fixi_mo.disconnect()
document.addEventListener("fx:after", (evt)=>{
// capture the parent element of the target in the config before swapping
evt.detail.cfg.parent = evt.detail.cfg.target.parentElement
})
document.addEventListener("fx:swapped", (evt)=>{
// reprocess the parent
evt.detail.cfg.parent.dispatchEvent(new CustomEvent("fx:process"), {bubble:true})
})
The __fixi
property will be added to any element that has an fx-action
attribute on it assuming that the element
or a parent is not marked fx-ignore
.
The value of the property will be the event listener that is added to the element. It also has two properties:
evt
- the string event name that will trigger the handlerrequests
- the config values of any open requests (may benull
)
This property can be used to remove the fixi-generated event handler like so:
elt.removeEventListener(elt.__fixi.evt, elt.__fixi)
If you want to reprocess the element you will need to remove the property entirely and trigger the
fx:process
event on it:
elt.removeEventListener(elt.__fixi.evt, elt.__fixi)
delete elt.__fixi
elt.dispatchEvent(new CustomEvent("fx:process"), {bubble:true})
You can also use this property to store extension-related information. See the polling example below.
Here are some basic examples of fixi in action
The htmx click to edit example can be easily ported to fixi:
<div id="target-div">
<div><label>First Name</label>: Joe</div>
<div><label>Last Name</label>: Blow</div>
<div><label>Email</label>: [email protected]</div>
<button fx-action="/contact/1/edit" fx-target="#target-div" class="btn primary">
Click To Edit
</button>
</div>
The delete row example from htmx can be implemented in fixi like so:
<tr id="row-1">
<td>Angie MacDowell</td>
<td>[email protected]</td>
<td>Active</td>
<td>
<button class="btn danger" fx-action="/contact/1" fx-method="delete" fx-target="#row-1">
Delete
</button>
</td>
</tr>
Note that this version does not have a confirmation prompt, you would need to implement that yourself using the
fx:config
event.
The htmx lazy loading example can be ported to fixi using the
fx:inited
event:
<div fx-action="/lazy-content" fx-trigger="fx:inited">
Content Loading...
</div>
Because fixi is minimalistic the user is responsible for implementing many behaviors they want via events. We have already seen how to abort an existing request that is already in flight.
The convention when you are adding fixi extension attributes is to use the ext-fx
prefix, and to process the extension
in the fx:init
method. You may find it useful to use the __fixi
property on the element to store values on
Here are some examples of useful fixi extensions implemented using events.
Here is an example that will use attributes to disable an element when a request is in flight:
document.addEventListener("fx:init", (evt)=>{
if (evt.target.matches("[ext-fx-disable]")){
var disableSelector = evt.target.getAttribute('ext-fx-disable')
evt.target.addEventListener('fx:before', ()=>{
let disableTarget = disableSelector == "" ? evt.target : document.querySelector(disableSelector)
disableTarget.disabled = true
evt.target.addEventListener('fx:after', (afterEvt)=>{
if (afterEvt.target == evt.target){
disableTarget.disabled = true
}
})
})
}
})
<button fx-action="/demo" ext-fx-disable>
Demo
</button>
Here is an example that will use attributes a fixi-request-in-flight
class to show an indicator of some kind:
document.addEventListener("fx:init", (evt)=>{
if (evt.target.matches("[ext-fx-indicator]")){
var disableSelector = evt.target.getAttribute("ext-fx-indicator")
evt.target.addEventListener("fx:before", ()=>{
let disableTarget = disableSelector == "" ? evt.target : document.querySelector(disableSelector)
disableTarget.classList.add("fixi-request-in-flight")
evt.target.addEventListener("fx:after", (afterEvt)=>{
if (afterEvt.target == evt.target){
disableTarget.classList.remove("fixi-request-in-flight")
}
})
})
}
})
<style>
#indicator {
display: none;
}
#indicator .fixi-request-in-flight {
display: inline-block;
}
</style>
<button fx-action="/demo" ext-fx-indicator="#indicator">
Demo
<img src="spinner.gif" id="indicator">
</button>
This example can be modified to use classes or other mechanisms for showing indicators as well.
Here is an implementation of the Active Search example from htmx done in fixi, utilizing the config confirm
feature
to return a promise that resolves to true
after the number of milliseconds specified by fx-ext-debounce
if no
other triggers have occurred:
document.addEventListener("fx:init", (evt)=>{
let elt = evt.target
if (elt.matches("[ext-fx-debounce]")){
let latestPromise = null;
elt.addEventListener("fx:config", (evt)=>{
evt.detail.drop = false
evt.detail.cfg.confirm = ()=>{
let currentPromise = latestPromise = new Promise((resolve) => {
setTimeout(()=>{
resolve(currentPromise === latestPromise)
}, parseInt(elt.getAttribute("ext-fx-debounce")))
})
return currentPromise
}
})
}
})
<form action="/search" fx-action="/search" fx-target="#results" fx-swap="innerHTML">
<input id="search" type="search" fx-action="/search" fx-trigger="input" ext-fx-debounce="200" fx-target="#results" fx-swap="innerHTML"/>
<button type="submit">
Search
</button>
</form>
<table>
...
<tbody id="results">
...
</tbody>
</table>
htmx-style polling can be implemented in the following manner:
document.addEventListener("fx:inited", (evt)=>{ // use fx:inited so the __fixi property is available
let elt = evt.target
if (elt.matches("[ext-fx-poll-interval]")){
// squirrel away in case we want to call clearInterval() later
elt.__fixi.pollInterval = setInterval(()=>{
elt.dispatchEvent(new CustomEvent("poll"))
}, parseInt(elt.getAttribute("ext-fx-poll-interval")))
}
})
<h3>Live News</h3>
<div fx-action="/news" fx-trigger="poll" ext-fx-poll-interval="300">
... initial content ...
</div>
This extension implements a simple confirm()
based confirmation if the ext-fx-confirm
attribute is found. Note that
it does not use a Promise, just the regular old blocking confirm()
function
document.addEventListener('fx:config', (evt)=>{
var confirmationMessage = evt.target.getAttribute("ext-fx-confirm")
if (confirmationMessage){
evt.detail.cfg.confirm = ()=> confirm(confirmationMessage)
}
})
<button fx-action="/demo" fx-method="delete" ext-fx-confirm="Are you sure?">
Delete It
</button>
This extension implements relative selectors for the fx-target
attribute.
document.addEventListener('fx:config', (evt)=>{
console.log("here")
var target = evt.target.getAttribute("fx-target") || ""
if (target.indexOf("closest ") == 0){
evt.detail.cfg.target = evt.target.closest(target.substring(8))
} else if (target.indexOf("find ") == 0){
evt.detail.cfg.target = evt.target.closest(target.substring(5))
} else if (target.indexOf("next ") == 0){
var matches = Array.from(document.querySelectorAll(target.substring(5)))
evt.detail.cfg.target = matches.find((elt) => evt.target.compareDocumentPosition(elt) === Node.DOCUMENT_POSITION_FOLLOWING)
} else if (target.indexOf("previous ") == 0){
var matches = Array.from(document.querySelectorAll(target.substring(9))).reverse()
evt.detail.cfg.target = matches.find((elt) => evt.target.compareDocumentPosition(elt) === Node.DOCUMENT_POSITION_PRECEDING)
}
})
<button fx-action="/demo" fx-target="next output">
Get Data
</button>
<output></output>
You can implement a custom swap strategies using the fx:config
event, and wiring in a function for the
evt.detail.cfg.swap
property. Here is an example that allows you to use
Idiomorph, a morphing algorithm, with the morph
& innerMorph
values
in fx-swap
:
document.addEventListener("fx:config", (evt) => {
if (evt.detail.cfg.swap == 'morph') evt.detail.cfg.swap = (target, text)=>Idiomorph.morph(target, text, { morphStyle: "outerHTML" })
if (evt.detail.cfg.swap == 'innerMorph') evt.detail.cfg.swap = (target, text)=>Idiomorph.morph(target, text, { morphStyle: "innerHTML" })
});
<h3>Morph</h3>
<button fx-action="/demo" fx-swap="morph" fx-target="#output">
Morph New Content
</button>
<output id="output"></output>
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 LIABLEs
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.
/**
* Adding a single line to this file requires great internal reflection
* and thought. You must ask yourself if your one line addition is so
* important, so critical to the success of the company, that it warrants
* a slowdown for every user on every page load. Adding a single letter
* here could cost thousands of man hours around the world.
*
* That is all.
*/