otterly js

small · fast · fierce

v1.7

It’s otterly intuitive

Otterly JS is a frontend javascript framework built to be small, highly customizable and intuitive.

The core of otterly JS are units of code attached to HTML elements. We can then add event-handlers within that unit. This looks like:

<div data-unit="Logger">
	<p data-on="click->log['Hi!']"> click me! </div>
</div>

But why stop there? Otterly offers much more. We have AJAX and form helpers, as well as Single Page Application functionality.

  • We are in Version 0.2.0, the library works well and I use it regularly! You may run into some issues but its mostly stable. Major changes may still occur.
  • The framework will stay small, understandable and hackable! This does mean Defining alternate behavior yourself when needed.

Compared to stimulus js…

Otterly JS is motivated by my experience using stimulus JS. Some issues I had were:

  1. StimulusJS does not have AJAX or Single Page Application functionality. Which is fine - you can get it elsewhere or write your own. The otterly framework however, is meant to be small but fierce (like an otter. Get it? Otterly Javascript? No?) .
  2. in Stimulus, elements can have many controllers, and accessing them is… complicated. In otterly js elements have only one “unit” (the controller analog) that you access like this:
    let unit = element._unit
  3. StimulusJS has some “extra” features which I personally Dont like. Like targets (just use a querySelector) or values (just use data-attributes). In many ways stimulus js makes its life difficult by reimplementing existing functionality - or worrying about completely seperating controllers from elements for the niche case in which an element has two controllers.

Get Your Javascript Kung Fu on 🥋

otterly js relys on previous knowlege of the following javascript information. If you dont know, mdn is the best reference for javascript.

Setup

Install otterly as an npm package by adding to it your package manager:

npm i @luketclancy/otterly

Here is an example entry setup for your JS with Otterly:

import {Otty, AfterDive, UnitHandler, Generic, Debug} from 'otterly'

//otterly stuff
startApp = function(){
	let csrfSelector = 'meta[name="csrf-token"]'
	let csrfSendAs = 'X-CSRF-Token'
	let isDev = true
	
	//Set up otty as a global variable named otty, along with various settings.
	//AfterDive is set as the response handling class for the otty.dive method.
	window.otty = Otty.init(isDev, AfterDive, csrfSelector, csrfSendAs)

	//Set up units, events and shortcuts. Add your units to this list. Generic is set as the default the other units are built on
	otty.unitHandler = UnitHandler.init(Generic, [Generic, Debug])
	
	//Optional SPA navigation 
	otty.handleNavigation()
}

//double check correct and only script running
let version = 1
if(window.yourApp && window.yourApp.version != version){
	window.location.reload()
} else if(!(window.yourApp)) {
	window.yourApp = {version: version}
	startApp()
}

Interacting with the DOM (how does it work?)

Shortcuts

the UnitHandler defines a couple useful shortcuts. In my opinion it makes things easier to read, and likely makes file sizes shorter. You dont have to use them though, they are purely optional!:

event.currentTarget.dataset.potatoes = this.element.querySelector('#potatoCount').dataset.potatoes
//into
event.ct.ds.potatoes = this.el.qs('#potatoCount').ds.potatoes
  • Events:
    • ct: currentTarget
  • HTMLElements:
    • ds: dataset
    • qs: querySelector
    • qsa: querySelectorAll -getA: getAttribute -setA: setAttribute
  • document element:
    • qs: querySelector
    • qsa: querySelectorAll
  • units:
    • el: element

Units

Units underpin otterlyjs’s interaction with the DOM. This is the unit I used to syntax highlight this text block:

export default {
	unitName: "Syntax",
	onConnected: function(){
		let lang = this.element.ds.language
		let txt = this.element.innerText
		let out = otty.highlighter.highlight(txt, {language: lang})
		this.el.innerHTML = out.value
	}
}

I then import it and add it to the UnitHandler in the setup

import {Syntax} from "./js/units/Syntax"
//setup otty.highligher here...
otty.unitHandler = UnitHandler.init(Generic, [Generic, Debug, Syntax])

I can invoke the unit in the html with the data-unit attribute

<code data-unit="Syntax" data-language='javascript'>
	let a = "otterly"
</code>

In niche scenarios, if I add multiple unit names, the units will be merged into one. This is convenient, but troublesome from an Object Oriented view-point. In practice, this will rarely if ever be a problem.

  • This can be used to assign an element multiple functionalities.
  • Beware of collisions and unexpected functionality.
  • the onConnected and onRemoved functions help avoid collisions as they are not overrode, but combined.
  • Consider for more complex scenarios: Importing unit functionality into a new unit, and using that instead.
<code data-unit="Logger Syntax" data-language='javascript'>
//equivilent to Object.assign({}, Generic, Logger, Syntax).
//I know 'Logger' doesn't use data-language, and Syntax only uses onConnected, so this should be fine.
</code>

Base Functions:

unitConnected

Overridable function for when unit is connected.

unitRemoved

Overridable function for when unit is removed.

onConnected

Function for when unit is connected, If there are multiple units assigned, each units’s onConnected function will be called in sequence

onRemoved

similar to onConnected but for when the unit is removed.

relevantData(e)

collects the dataset of the unit’s element aswell as the unit’s id (as unitId). If an event is provided, collects the currentTargets dataset and id as (submitterId)

dive (AKA the AJAX function)

an event function for AJAX. It is used to send the relevantData(e) above to a url. Optionally, it can also send inputs. It then accepts back JSON, which determines what actions it takes in the AfterDive class. For example:

	<div data-unit='Generic' data-on="click->dive" data-url="/test"> </div>

and if one wanted to intercept a form to validate inputs server side before leaving the page…

<form data-unit='Generic' data-on="submit->dive[{\"withform\":  true}]" data-url="/test">
	<input type='hidden' name='otter_count' value=5/>
	<button data-hit-button="true" type='submit'> OK! </button>
</form>

dive can then accept a json response like this from the server:

[{
	"morph": {
		"html": "<div id='test'><h1> HI! </h1></div>",
		"id": "test"
	}
}, {
	"log": "tested!!"
}]

and act upon that json as defined in the AfterDive class. I intercept all my forms, as I do not want to lose input values before I leave the page. If the creation/update was successfull, I return this:

{"redirect": "object path here..."}
  • dive will treat the following data variables specially: data-url, data-method, data-csrf-content, data-csrf-header, data-csrf-selector, data-confirm.
  • the following data-variables wont work as they are used internally: data-with-credentials, data-e, data-submitter, data-form-info, data-xhr-change-f, data-base-element
  • the “withform” option, outsde of input elements, also replicates functionality of the following form attributes : “action”, “formaction”, “method”, “formmethod”
  • if a “data-method”, “method”, or “formmethod” is not active, dive reverts to the “POST” method.

untested functions

  • childUnits(unitc): Gets all child units, optionally with an identifier.
  • childUnitsDirect(unitc): Gets all direct child units, optionally with an identifier.
  • childUnitsFirstLayer(unitc): Gets all child units who dont have a unit between. Optionally with an identifier.
  • parentUnit(unitc): Gets the first pasrentUnit, optionally with an identifying name.
  • addUnitEvent/removeUnitEvent/unitEvents: internal event tracking for data-on functionality. Maybe usefull for debugging?

Events, the Data-On Property:

The event handling of otterlyjs. They are attached to an element like this:

<div data-unit='Generic' data-on="click->dive" data-url="/test">

the data-on string is made up of four parts, where two are optional.

  1. the event name: what action will make up the event?
  2. [optional] the unit name: what unit type the function belongs to. By default we assume the function belongs to the closest unit.
  3. the function name: What function will the event listener call?
  4. [optional] bind arguments (json): by adding brackets to the end of the function, otterly will parse those brackets as json, and add any elements as additional arguments to the function. See arg1…argN here

bind arguments are good at quick functionality and options, but that data can not be changed in the html. Due to this, data attributes are often but not always prefered..

1       2        3        4
click->PostCard#comment[{"withform": true}]

1        3
click->comment

Here is a rough idea of how this works, with ‘element’ being and element with a data-on attribute

//click->dive
let unit = element.closest('[data-unit]')._unit
let dive = unit.dive.bind(unit)
element.addEventListener('click', dive )

//click->dive[{input: true}]
let unit = element.closest('[data-unit]')._unit
let dive = unit.dive.bind(unit, {input: true})
element.addEventListener('click', dive)

//click->PostCard#dive[{input: true}]
let unit = element.closest("[data-unit~='PostCard']")._unit
let dive = unit.dive.bind(unit, {input: true})
element.addEventListener('click', dive)

There are two special event values:

  • _parse: this will run the method as soon as the data-on attribute is parsed.
  • _remove: this will run the method as soon as this data-on event is removed.
  • ex:
I use the following in a recovery step in midflip to check if they have clicked an email
link yet.

data-on='_parse->dive[{"behavior": "repeat"}]'

AfterDive

Navigate to the unit’s dive function above to see how to trigger a dive.

AfterDive is a class containing functions defining how to treat json that is returned from a dive. Feel free to add your own functionality.

  • log: logs whatever is passed ex: {log: “hi!”}
  • reload: reloads the page ex: {reload: true}
  • redirect: redirects to a page using SPA functionality. Useful in form validation. ex: {redirect: “/“}
  • insert: inserts into the dom ex: {insert: {:html, :position, :selector or :id}}
  • morph: morphs an element ex: {morph: {:html, :selector or id, :permanent, :ignore}}
  • remove: removes an element ex: {remove: {:id or :selector}}
  • replace: replaces an element ex: {replace: {:html, :id or :selector, :childrenOnly}}
  • innerHtml: replaces innerHtml ex: {innerHtml: {:html, :selector or :id} }
  • eval: runs some code. ex: {eval: {:code, :data (json), :selector}}. This is what it actually does:
    let x = Function("data", "selector", 'baseElement', 'submitter', `"use strict"; ${data['code']};`)(data, selector, this.baseElement, this.submitter)
  • setData: sets data attributes on the base element: {setData: { “.card”: {open: ‘false’}}}

Otty

otty is the global class instance that we assigned to the widow during our setup. here are some methods on it:

  • obj_to_fd(formInfo, [optional] FormData base object): Converts formInfo to a FormData instance. Optionally adds on to pre-existing formData
  • sendsXHR(opts): Promise wrapped and abstracted xhr request.
  • dive(opts): the non-event based counterpart to the unit’s dive function. Promise wrapped.
  • isLocalUrl(url, [optional] subdomainAccuracy = -2): returns true if the url is equal to / a subdomain of window.location.hostname. For instance:
    //assume hostname = 'a.b.c'
    isLocalUrl('d.b.c') // true
    isLocalUrl('d.b.c', -3) //false
    isLocalUrl('c', -1) //true
  • xss pass: determines what domains will cause a dive to throw an error (beyond the browser built in controls). Defaults to allowing only isLocalUrl(url, -2)

Otty Polling

  • subscribeToPoll(queues, pollInfo, waitTime, pollPath, subPath)
    • queues refer to what you are subscribing to, for instance, a conversation or just a user.
    • pollInfo has information about your queues, for instance, a conversation ID.
    • waitTime is in milliseconds, I suggest 5000
    • pollPath is what route to POST polling to.
    • subPath is what route to POST poll subscriptions to.

subscribeToPoll should autherize the user for the queues provided. subscribeToPoll uses an otty dive. The otty dive should return an encrypted string that contains authentication and queue information. This information will be passed to the poll method as the ‘otty-store’ field. Note that only one polling subscription can exist at a time, but you can add multiple queues to each subscription.

  • Your poll route: Polls can return muliple different responses:
    • when nothing is going on: {returning: "no_updates"}
    • when the poll needs to be re-authenticated: {returning: "should_resub"}
    • when there are updates: [{morph: {...},}, {returning: "[YOUR ENCRYPTED STORE STRING]"}]

If one is having issues with users getting repeat dive commands by doing an action, and then having a poll re-do the action, you can add a dive_id to that action to make sure it does not repeat: {morph: {…}, dive_id: 8470175}.

Otty polling requires some backend work. Here is a partial example in ruby.

SPA

These are SPA functions on Otty. For advanced functionality I suggest extending Otty, as everyone has different priorities. Not all functions listed.

  • handleNavigation(opts): Enable navigation handling. opts.navigationReplaces is a list of querySelectors that have priority when switching DOM during page loads. For example, [‘body’] will always switch the body while [‘div#inner’, ‘body’] will replace some inner div if available before falling back to body if not.
  • goto(url, opts = {reload: false}): follow url using SPA functionality. Setting reload will stop otty from pushing to the history stack.
  • navigationHeadMorph(tmpDocHead): determines how to morph the head.
  • linkClickedF(e): link has been clicked function. Determines how to deal with it. You can, for instance, make it so you always use history.go() if the path was already hit.