Squeezing more Juice out of UI libraries

Sometimes you want the configuration of a view component to be reactive. You want the arguments in the constructor to become part of the value in a view. For example, making the options in a select part of the value enables you to back-write into the view to update the drop down. You don't want to do this using dataflow because its part of a single cell UI.

This utility moves configuration parameters into the value. Hopefully this helps us squeeze a little more juice out of existing Input libraries, a useful technique for scaling UI development

This utility was created in response to conversations with @mkfreeman and @dleeftink who both independently had a requirement for a select with mutable options. I decided it would be useful to solve this problem in a general way so we could take any UI component and pull out its configuration to suit.

    ~~~js
    import {juice} from '@tomlarkworthy/juice'
    ~~~

fastest way to make UI components

Convert static renderers into reactive components:

const profile = juice((name, age) => html`Your name is ${name} your age is ${age}`, {
  name: "[0]", // we index into the ...arguments array
  age: "[1]"
})
//viewof example = profile("tom", 21) // profile now constructs a reactive component
//const example = view(profile("tom", 21))
const exampleElement = profile("tom", 21);
const example = Generators.input(exampleElement)
exampleElement
example
//Inputs.bind(Inputs.range([0, 99]), viewof example.age) // so you can do granular biunding
//Inputs.bind(Inputs.range([0, 99]), example.age)
Inputs.bind(Inputs.range([0, 99]), exampleElement.age)

juice API

    ~~~
        juice(VIEW_BUILDER, JUICE_CONFIG) => NEW_VIEW_BUILDER
    ~~~

1st arg is a view builder

juice is a higher order function that takes a view builder function as its 1st arg, and returns a new view builder function. Inputs.select is an example of a view builder function that can be found in the standard library. View builders are a common form of packaging a UI component on Observable.

A view builder is a function that takes some configuration as its arguments and returns a view

    ~~~js
        juice(VIEW_BUILDER, ???) => VIEW_BUILDER
    ~~~

2nd arg is the argument remapping

The 2nd argument of juice configures how static configuration arguments are moved the view's value output. It is is expressed as an key-value object dictionary. The key is the property name in the resultant composite view, the value is a lodash path into the view builders configuration ...arg array, have a look at the examples below to see how the path syntax works.

    ~~~js
        juice(VIEW_BUILDER, {subview => ...args path}) => VIEW_BUILDER
    ~~~

Returns a view builder with a composite value

The result of applying juice is a new view builder.

The new builder has an identical argument list to the original one. Input arguments form the base args for internal calls to the view builder.

The new builder has a very different value type though. The value becomes a dictionary of values. One entry "result" is the original return value. The other entries correspond to entries in the argument remapping configuration mentioned above. Note the fields are mutable, and you can write back into them to update the UI configuration.

Works with any functional UI

You can animate your own custom constructors or D3 charts

Example

If we juice the range builder:

  ~~~js
  dynamicRange = juice(Inputs.range, {
    label: "[1].label",
    min: "[0][0]", 
    max: "[0][1]", 
  })
  ~~~

We can instantiate ranges as normal:-

~~~js
viewof myDynamicRange = dynamicRange([0, 10], {label: "cool"})
~~~

But we end up with a view whose value is of the form

~~~js
{label: "...", min: -1, max: 1, result: 0}
~~~

And we back-write into it from anywhere else in the notebook

  ~~~js
  {
    viewof myDynamicRange.max.value = 1000;
    viewof myDynamicRange.max.dispatchEvent(new Event('input', {bubbles: true}));
  }
  ~~~

Because the value is a nested view, each subview supports Inputs.bind individually, see scaling UI development for why this is important.

Open Issues

DOM state lost when parameters

When a configuration parameter is updated, the DOM node is deleted and replaced with a fresh node, this breaks things like mouse event handlers, caret position etc. My normal goto solution for DOM state loss is nanomorph, but nanomorph does not work with Inputs (bug). So, for now, we do the crude DOM swap and live with the UI state loss glitches.

//viewof stateLostExample = dynamicRange()
//const stateLostExample = view(dynamicRange())
const stateLostExampleElement = dynamicRange()
const stateLostExample = Generators.input(stateLostExampleElement)
stateLostExampleElement
stateLostExample
stateLostExample.label
stateLostExampleElement.label
const stateLostExampleUpdater = await (async () => {
  let i = 0;
  const banner = "Label updates break the slider :( ";
  while (true) {
    //yield Promises.delay(100);
    await Promises.delay(100);
    stateLostExample.label = (banner + banner).substring(i, i + 15);
    i = (i + 1) % banner.length;
  }
})();
display(stateLostExampleUpdater)

Implementation

helpers

Range with dynamic max and min

Here we extract the ranges first arg, max and min to be their own backwritable subviews

//!!!!!!!!!!!!
//!!! NOTE: We probably should not be operating on .value here; verify Framework implementation
//!!!!!!!!!!!!
const dynamicRange = juice(Inputs.range, {
  label: "[1].label",
  min: "[0][0]", // "range" is first arg (index 0), the min is the 1st arg of the range array
  max: "[0][1]", // "range" is first arg, the max is the 2nd paramater of that array
  result: "[1].value" // "result" can be set in the options.value, options being the 2nd arg (index 0)
})
//viewof dynamicRangeExample = dynamicRange([-1, 1], { label: "dynamic range" })
//const dynamicRangeExample = view(dynamicRange([-1, 1], { label: "dynamic range" }))


const dynamicRangeExampleElement = dynamicRange([-1, 1], { label: "dynamic range" })
const dynamicRangeExample = Generators.input(dynamicRangeExampleElement)
dynamicRangeExampleElement
dynamicRangeExample
//viewof dynamicRangeMin = dynamicRange([-1, 1], {
//const dynamicRangeMin = view(dynamicRange([-1, 1], {
//  label: "dynamic range min",
//  value: -1
//}))
const dynamicRangeMinElement = dynamicRange([-1, 1], {
  label: "dynamic range min",
  value: -1
})
const dynamicRangeMin = Generators.input(dynamicRangeMinElement)
dynamicRangeMinElement
dynamicRangeMin
//viewof dynamicRangeMax = dynamicRange([-1, 1], {
//const dynamicRangeMax = view(dynamicRange([-1, 1], {
//  label: "dynamic range max",
//  value: 1
//}))
const dynamicRangeMaxElement = dynamicRange([-1, 1], {
  label: "dynamic range max",
  value: 1
})
const dynamicRangeMax = Generators.input(dynamicRangeMaxElement)
dynamicRangeMaxElement
dynamicRangeMax
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//!!!! NOTE: Bind is a tricky concept; check the implementation
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!
const inMaxConstraints = () => {
  // We want dynamicRangeMax to constrain the dynamic range's max and min
  //Inputs.bind(viewof dynamicRangeExample.max, viewof dynamicRangeMax.result);
  Inputs.bind(dynamicRangeExample.max, dynamicRangeMax.result);
  //Inputs.bind(viewof dynamicRangeExample.min, viewof dynamicRangeMin.result);
  Inputs.bind(dynamicRangeExample.min, dynamicRangeMin.result);
  // Of course, the max of the min should also be constrained by the max too
  //Inputs.bind(viewof dynamicRangeMin.max, viewof dynamicRangeMax.result);
  Inputs.bind(dynamicRangeMin.max, dynamicRangeMax.result);
  //Inputs.bind(viewof dynamicRangeMax.min, viewof dynamicRangeMin.result);
  Inputs.bind(dynamicRangeMax.min, dynamicRangeMin.result);
}

Select with Dynamic Options

//!!!!!!!!!!!!
//!!! NOTE: We probably should not be operating on .value here; verify Framework implementation
//!!!!!!!!!!!!
const dynamicSelect = juice(Inputs.select, {
  label: "[1].label",
  options: "[0]", // "options" is first arg (index 0) of Inputs.select
  result: "[1].value" // "result" can be set in the options.value, options being the 2nd arg (index 0)
})
//!!!!!!!!!!!!
//!!! NOTE: We stopped operating on .value here; different Framework implementation
//!!!!!!!!!!!!
Inputs.button("deal", {
  reduce: () => {
    const rndCard = () => {
      const card = Math.floor(Math.random() * 52);
      return (
        ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"][
          card % 14
        ] + ["♠", "♥", "♦", "♣"][Math.floor(card / 14)]
      );
    };
   //viewof exampleSelect.options.value = [rndCard(), rndCard()];
   exampleSelect.options = [rndCard(), rndCard()];
   //viewof exampleSelect.options.dispatchEvent(new Event("input"));
   exampleSelect.options.dispatchEvent(new Event("input"));
  }
})
exampleSelect.options
//viewof exampleSelect = dynamicSelect([], { label: "play a card" })
const exampleSelect = view(dynamicSelect([], { label: "play a card" }))
//!!!!!!!!!!!!!!!!!!!!!!!
//!!! Note namespace collision with juice definition of viewUI
//!!!!!!!!!!!!!!!!!!!!!!!
//import { view, variable } from "@tomlarkworthy/view"
import { viewUI, variable } from "/components/view.js"
display(viewUI)
display(variable)
// Notebook indicated viewroutine may not be required.
// import { viewroutine, ask } from "@tomlarkworthy/viewroutine"
 import { viewroutine, ask } from "/components/viewroutine.js"