Skip to content

Latest commit

 

History

History
425 lines (324 loc) · 15.1 KB

basics.md

File metadata and controls

425 lines (324 loc) · 15.1 KB

DOM & Observables

In this page, we describe how to build DOM in GrainJS and how to tie it to observables.

DOM Construction

Here’s an example of DOM construction using GrainJS:

import {dom} from 'grainjs';

dom('a', {href: 'https://github.com/gristlabs/grainjs'},
  dom.cls('biglink'),
  'Hello ', dom('span', 'world')
);

This constructs an element equivalent to the following HTML:

<a href="https://github.com/gristlabs/grainjs" class="biglink">
  Hello <span>world</span>
</a>

The workhorse of DOM construction is the function dom(). Various helpers useful with it are available as its properties, e.g. dom.cls(), dom.attr(), etc. The function has the following usage:

dom(tag, ...args)

where args are optional, and may be:

  • Nodes - which become children of the created element (e.g. dom('span', 'world') above).
  • strings - which become text node children (e.g. 'Hello ' above).
  • objects - of the form {attr: val} to set additional attributes on the element (e.g. {href: ...} above).
  • Arrays - which are flattened with each item processed recursively.
  • functions - which are called with the element being constructed as the argument, for a chance to modify it. Return values are processed recursively. E.g. (elem) => elem.classList.add('myclass') is a valid argument.
  • "dom methods" - expressions such as dom.cls('biglink') above, or dom.hide(obs), which are actually special cases of the "functions" category. More on this later, as this is where observables come in.

The first tag argument is the tag name of the element to create, e.g. "div".

The tag may contain optional #foo suffix to add the ID "foo" to the element, and zero or more .bar suffixes to add a CSS class "bar" (for example, dom('div#foo.bar')), but these optional suffixes are not actually recommended. They add minimal convenience and prevent accurate typings. For example:

  • dom('input', {id: 'foo'}, (elem) => ...) --> elem has type HTMLInputElement (recommended)
  • dom('input#foo', (elem) => ...) --> elem has type HTMLElement

Note that DOM id attributes are almost never needed when using GrainJS (the element constructed is always available as a variable), and CSS classes are usually better assigned by using the styled() function of GrainJS. In both cases, by avoiding direct use of DOM IDs and classes, we avoid worrying about name collisions. Javascript does a better job of modularizing code, so it’s better to identify things using JS variables (or better yet TypeScript variables).

Observables

Observables are merely variables that allow listening to changes in them. In GrainJS, the recommended way to create an observable is:

const showPanelObs: Observable<boolean> = Observable.create(owner, false);

The first argument to Observable.create is the owner of the resulting object -- more on that later. You may pass in null in its place, as in Observable.create(null, initialValue).

Once you have an observable, you can get or set its value:

showPanelObs.set(true);
if (!showPanelObs.get()) { ... }

And, importantly, you can subscribe to changes to its value:

const listener = showPanelObs.addListener(val => console.log("New value:", val));

Computed Observables

A "Computed" or a "Computed Observable" is an observable whose value depends on other observables and gets recalculated automatically when they change.

For example, let's say we have some existing observables (which may themselves be instances of Computed). We can create a computed whose value is equal to their sum:

const obs1 = Observable.create(owner, 5);
const obs2 = Observable.create(owner, 12);
const computed1 = Computed.create(owner, use => use(obs1) + use(obs2));

Here, the value of computed1, i.e. computed1.get(), will be 17.

The call use(obs1) returns the same value as obs1.get(), but also tells the computed to depend on this observable. So if you call obs1.set(10), then computed1 will get recomputed, and computed1.get() will evaluate to 22.

The use() function, made available to the callback supplied to the Computed, is one significant difference to Knockout.js (which inspired this feature). Knockout creates a dependency on any observable used while the callback is executing; GrainJS intentionally makes dependency-creation explicit -- it only happens when the use function is, well, used.

There is another, even more explicit way to make a computed depend on another observable: pass in the dependencies into the constructor:

const computed2 = Computed.create(owner, obs1, obs2, (use, value1, value2) => value1 + value2);

Here, computed2 will depend on obs1 and obs2, and any time either of those is updated, the callback will get called with their explicit values. This way is slightly more efficient. Otherwise, the two ways of creating computed observables are equivalent, and you may mix and match explicit dependencies, and dependencies created with the use() function.

DOM Bindings

The DOM construction approach and the observables are made to work together. Let’s take the DOM example we started with, and make it more dynamic:

const isBigObs = Observable.create(null, true);
const hrefObs = Observable.create(null, 'https://github.com/gristlabs/grainjs');
const nameObs = Observable.create(null, 'world');

dom('a',
  dom.cls('biglink', isBigObs),
  dom.attr('href', hrefObs),
  'Hello ', dom('span', dom.text(nameObs))
);

So far, this builds the exact same DOM as the original example. But now, changing any of the observables will immediately update the DOM. For instance:

isBigObs.set(false);          // Turns off `biglink` CSS class
hrefObs.set('about:blank');   // Changes the 'href' attribute
nameObs.set('Bart');          // Changes the text of the SPAN to "Bart"

Note that to get this dynamic behavior, we had to use methods that support observables, e.g. replace {href: value} with dom.attr('href', value), and use dom.text(contentString) rather than pass in a plain string as an argument.

Let’s say that instead of adding the 'biglink' CSS class whenever isBigObs is true, you want to add the 'small-link' CSS class whenever isBigObs is false. One way is to create a suitable computed for the inverted condition:

const isSmallObs = Computed.create(null, use => !use(isBigObs));
dom('a',
  dom.cls('small-link', isSmallObs),
  ...
);

Such manipulations are common enough that there is a shortcut. You can replace an observable argument with a callback which will be used to create a computed automatically:

dom('a',
  dom.cls('small-link', use => !use(isBigObs)),
  ...
);

Notice also a difference here with React-like DOM construction. GrainJS is very direct. The use of an observable creates a subscription, so that whenever the observable value changes, the corresponding property gets updated directly. There is no constructing of virtual DOM and figuring out differences. This can be a plus or a minus, depending on the situation.

Notice also that the goal here is to allow you to describe DOM declaratively once. Anything dynamic is controlled by observables. You don’t need to tweak DOM later; to update what the user sees, you only set the observables. In other words, the observables are the model of your application state, and the DOM with bindings is the view.

Conditional DOM

Sometimes you want to include an element if some observable is set, and to omit it otherwise. The method for that is dom.maybe. For example:

dom('div',
  dom.maybe(isChangedObs, () =>
    dom('button', 'Save')
  )
);

Whenever isChangedObs is true, a BUTTON element will be created and attached to the DIV; whenever it’s false, the BUTTON will be removed. Note that it’s OK for dom.maybe() to be present among other child nodes of the DIV; it will be inserted into the right spot.

Sometimes, depending on the observable, you’ll want to insert different DOM elements. For that, use dom.domComputed:

dom('div',
  dom.domComputed(isChangedObs, (isChanged) =>
    isChanged ?
      [dom('button', 'Save'), dom('button', 'Revert')] :
      dom('button', 'Close')
   )
);

In this case, when isChangedObs is true, two elements will be inserted — “Save” and “Revert” buttons (yes, you may return an array of elements). When it’s false, those two buttons will be removed, and a single “Close” button will be inserted instead.

Repeating DOM

If you want to insert multiple DOM elements, remember that you can simply include an array of them as an argument to the dom() function:

const items = ['Apples', 'Pears', 'Peaches'];
dom('ul',
  items.map(item => dom('li', item))
);

But what if the list of items may change? If so, make it an observable, and use dom.forEach():

const items = Observable.create(null, ['Apples', 'Pears', 'Peaches']);
dom('ul',
  dom.forEach(items, item => dom('li', item))
);

If you now set items.set(['Bananas']), the three LI elements created initially will get removed, and a single LI element for the new item will be inserted.

For array-valued observables that are likely to change in small increments, GrainJS provides obsArray:

const items = obsArray(['Apples', 'Pears']);
dom('ul',
  dom.forEach(items, item => dom('li', item))
);
items.push('Peaches');          // One more LI element will be appended
items.splice(1, 1, 'Bananas');  // Replace LI element for Pears with one for Bananas

The purpose of obsArray is to allow observing small changes like those above, and so to update relevant DOM elements without rebuilding them all.

When using dom.forEach, the per-item callback may not return an array of DOM elements — it may return a single DOM element or null (to omit that item from DOM).

DOM Events

GrainJS provides some convenient methods for listening to DOM events:

dom('button', 'Click Me',
  dom.on('click', (event, elem) => ...),
  dom.on('focus', (event, elem) => ...),
);

The callback receives the event, as well as the element that the handler is attached to (the BUTTON in the example above). The passed-in element may be different from event.target if the target is some child or descendant of elem.

You may pass in a third argument of {useCapture: true} for the (rare) situations when you want to pass true for the useCapture parameter of the native addEventListener method.

For keyboard events, there are some extra helpers: dom.onKeyPress() and dom.onKeyDown():

dom('input', {type: 'text'},
  dom.onKeyDown({
    Enter: (ev, elem) => ...,
    Escape: (ev, elem) => ...,
    ArrowLeft$: (ev, elem) => ...,
  })
);

The keys of the passed-in object are the KeyboardEvent's Key Values. By default, registered keyboard events are stopped from bubbling with stopPropagation() and preventDefault(). If, however, you register a key with a "$" suffix (i.e. Enter$ instead of Enter), then the event is allowed to bubble normally.

For completeness, we should mention dom.onMatch():

dom.onMatch('.some-selector', 'click', (event, elem) => ...)

It is analogous to JQuery's delegated event handlers. It turns out to be rarely useful. When attached to an element, it listens to DOM events on the descendants of that element which match '.some-selector'. In practice, it listens to the events that bubble up to the element that it’s attached to, but only calls the callback when there is an ancestor of event.target that matches the selector. The matching elememt is then provided as the elem argument to the callback.

Styling DOM

The simplest way to style a DOM element is to assign it a unique CSS class name, and define styles for that class. GrainJS offers a TypeScript-based alternative inspired by React’s Styled Components. The main benefit here is in module-based naming and the various help from TypeScript with names, and with knowing the types of created elements.

You can defined a “styled” element like so:

const cssTitle = styled('h1', `
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`);

const cssWrapper = styled('section', `
  padding: 4em;
  background: papayawhip;
`);

cssWrapper(cssTitle('Hello world'));

This generates unique class names for cssTitle and cssWrapper, adding the styles to the document on first use. The result is equivalent to:

dom('section', {className: cssWrapper.className},
  dom('h1', {className: cssTitle.className},
    'Hello world'));

Calls to styled() should happen at the top level, at import time, in order to register all styles upfront. Actual work to attach styles to the doc happens the first time a style is needed to create an element. Calling styled() elsewhere than at the top level is wasteful and bad for performance.

By convention, styled elements are named with css prefix, and are placed at the bottom of the module in which they are used.

You may create a style that modifies an existing styled() or other component, or any Element-returning function, e.g.

const cssTitle2 = styled(cssTitle, `font-size: 1rem; color: red;`);

Calling cssTitle2('Foo') becomes equivalent to dom('h1') with both cssTitle.className and cssTitle2.className classes turned on.

Styles may incorporate other related styles, or related media queries, by nesting them under the main one as follows:

const cssButton = styled('button', `
    border-radius: 0.5rem;
    border: 1px solid grey;
    font-size: 1rem;

    &:active {
        background: lightblue;
    }
    &-small {
        font-size: 0.6rem;
    }
    @media print {
      & {
        display: none;
      }
    }
`);

In nested styles, ampersand (&) gets replaced with the generated .className of the main element. Blocks of related styles must appear after all the styles that apply to the main element.

The resulting styled component provides a .cls() helper to simplify using prefixed classes. It behaves as dom.cls(), but prefixes the class names with the generated className of the main element. E.g. for the example above,

cssButton(cssButton.cls('-small'), 'Test')

creates a button with both the cssButton style above, and the style specified under &-small. This can also be used with an observable, e.g. cssButton.cls('-small', useSmallButtonsObs).

In a similar approach to creating CSS classes from Javascript, you can create @keyframes animations by using the keyframes() helper. It returns the generated unique name:

const rotate360 = keyframes(`
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
`);

const Rotate = styled('div', `
  display: inline-block;
  animation: ${rotate360} 2s linear infinite;
`);