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 events within that unit’s element. This looks like:

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

But otterly offers much more. We have AJAX and form helpers, as well as Single Page Application functionality.

  • We are in Version 0.0, things will break, things will change.
  • The framework will stay small, understandable and hackable! But 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. in Stimulus, elements can have many controllers, and accessing them is… complicated. In otterly js there is only one “unit” (the controller analog) that you access like this:
    let unit = element._unit
  2. StimulusJS has some “extra” features which I personally Dont like. Like targets (just use querySelector).
  3. StimulusJS does not have AJAX or Single Page Application functionality. Which is fine - you can get it elsewhere. The otterly framework however, is meant to be small but fierce (like an otter. Get it? Otterly Javascript? No?)

Get Your Javascript Kung Fu 🥋

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

Setup

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

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

//otterly stuff
startApp = function(){
	let csrf_selector = 'meta[name="csrf-token"]'
	let csrf_send_as = 'X-CSRF-Token'
	let is_dev = 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 = new Otty(is_dev, AfterDive, csrf_selector, csrf_send_as)
	
	//Set up units, events and shortcuts. Add your units to this list.
	otty.unitHandler = new UnitHandler([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?)

Units:

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

let Syntax = {
	unitName: "Syntax",
	onConnected: function(){
		let lang = this.el.ds.language
		let txt = this.el.innerText
		// otty.highlighter is an object I set on initialization using https://highlightjs.org/
		let out = otty.highlighter.highlight(txt, {language: lang})
		this.el.innerHTML = out.value
	}
}
export {Syntax}

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

import {Syntax} from "./js/units/Syntax"
otty.unitHandler = new UnitHandler([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 in an Object Oriented view-point.

  • 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: 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[{inputs: 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

  • 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] prepend arguments (json): by adding brackets to the end of the function, otterly will parse those brackets as json, and prepend any elements as additional arguments to the function. See arg1…argN here

prepend arguments are good at quick functionality and options, but that data can not be changed. Data attributes is prefered due to this.

1       2        3        4
click->PostCard#comment[{inputs: 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)

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. 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, :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)
  • poll/subscribeToPoll: untested.

SPA

skipping non-facing…

  • handleNavigation(): Enable navigation handling
  • goto(url, opts = {reload: false}): follow url using SPA functionality.
  • navigationReplaces: css selector attribute to optionally not switch out the body. If it can find on both body and new document, it will replace that. Else it will default back to the bodies.
  • 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.