ELT is a typescript library for building user interfaces in a web environment. It is not meant to build websites ; its purpose is to write applications.
Weighing less than 15kb minified and gziped, it is meant as an alternative to React, Angular and the likes. Unlike several of them, it does not make use of any kind of virtual DOM. Instead, it provides the developper with an Observable
class and a few easy to use hooks on the node life cycle to react to their presence in the document.
It makes use of fairly modern standards, such as Map
, Set
, Symbol
and WeakMap
. As such, it probably won't run on IE without using polyfills. In general, support is limited to less than two years old browser versions.
While it is of course usable with plain javascript, its real intended audience is Typescript users.
It is built with three objectives in mind :
- Writing and reading code using it must be pleasant
- All overheads induced by its use should be kept as low as possible
- Everything must be typed correctly. This library must be refactoring-friendly.
Join the discord for questions or use the tags #typescript
and #elt
on stack overflow, go to the repository to file issues.
-
You use typescript and don't want a javascript library that use patterns that the typing system doesn't always gracefully support. Everything is Element was built with type inference in mind. The
Observable
ecosystem tries hard to keep that valuable typing information without getting in your way and have you type everything by hand. -
You are strict about typing and do not like to cheat with
any
. The recommended way to enjoy this library is with"strict": true
in yourtsconfig.json
. -
You like the Observer pattern but you're afraid your app is going to leak as this pattern is prone to do. Element solves this elegantly by tying the observing to the presence of a Node in the DOM, removing the need to un-register observers that would otherwise leak. See
node_observe
and$observe()
. Also, theObservables
are buffed ; they can be transformed and combined, which makes for very fun data pipelines. -
You like to manipulate the DOM directly. In ELT, all the
<jsx>code</jsx>
return DOM Elements that can be manipulated with vanilla javascript. In general, the library makes use of standards and tries as much as possible not to deviate from them. -
You like expliciteness. The "moving parts" of your application should be identifiable just by scanning the source code visually ; the Observables and Verbs are a clear giveaway of what parts of your application are subject to change. Also, every symbol you use should be reachable with the go-to definition of your code editor ; html string templates are just plain evil.
-
You're tired of packages with dozens of dependencies. Element has none. It uses plain, vanilla JS, and doesn't shy away from reimplementing simple algorithms instead of polluting your node_modules, all the while trying to provide enough batteries to not have to import dozens of packages to get work done.
ELT offers the following concepts to get this done :
-
For binding data to the document in an MVVM manner, there is an
Observable
class, which is essentially a wrapper around an immutable object that informsObserver
s whenever its value is changed. Observables can also be combined together or transformed to get new observables whose value change whenever their base Observable change. -
Since observing an observable makes them keeps a reference to their observers in the memory, observers have to be deregistered properly when they're not used anymore. To alleviate the burden on the programmer and avoid forgetting to stop the observers -- and thus create memory leaks, ELT associates observing with nodes and whether they're in the document or not. See
$observe()
, andnode_observe()
. -
Since the DOM does not offer a simple way to know when a node is added or removed from the document other than using a
MutationObserver
, ELT offers a way to react to these events by setting up the observer itself and registering callbacks directly on theNode
s. See$inserted()
,$removed()
, but also$init()
. -
Instead of creating components that change what they render based on the values of Observables, such as an hypothetical
<If condition={...}>
, ELT uses "verbs" ; functions whose name starts with an upper-case letter. While a component-based approach would work perfectly, the "verb" approach is more explicit about where dynamicity is happening in the code. SeeIf()
,Repeat
,RepeatScroll
andSwitch
. -
To avoid declaring a boatload of variables to modify nodes that are being created, ELT defines "decorators" which are callback functions that can be added as children of a node. See all the
$
prefixed functions followed by a lower-case letter. Their naming scheme was thought to differenciate them from function calls that actually create Nodes. -
At last, ELT offers a simple way to build applications with the
App
class and friends. While it is not mandatory to use it to get things done, it's small enough to not add much weight to the library, and convenient enough to build complex applications to justify its inclusion in the core library and not become "yet another package".
All the examples should be runnable, testable and modifiable.
The documentation is set up to use the E()
version of e()
. They're the same, but ELT infects the global namespace and adds E()
on window
to make it more convenient (only if E
did not exist before, of course). This saves import
statements and hopefully makes for a less cluttered documentation.
Also, setup_mutation_observer
is called automatically in the examples to reduce verbosity.
First, install elt in your project
npm install elt
# Alternatively
yarn add elt
In your tsconfig.json
, you will need to add the following :
"strict": true, // not needed, but strongly advised
"lib": ["es6", "dom"], // elt uses some es6 specific classes, and of course a lot of the DOM api
"jsx": "react",
"jsxNamespace": "E", // alternatively "jsxNamespace": "e", but you then have to
// `import { e } from 'elt'` in all your .tsx files.
Note: You can also use
"jsxFactory": "E"
instead ofjsxNamespace
, but to use fragments, you have toimport { Fragment } from 'elt'
and then use the<Fragment></Fragment>
construct instead of<></>
. You may of course rename it to something terser, such asimport { Fragment as $ }
and<$></$>
. The plus side of this approach is that typescript will only generateE()
calls instead ofE.createElement()
, resulting in smaller, easier to read compiled code.
At last, you need to setup the MutationObserver
which will call the life-cycle callbacks used extensively by elt. This has to be done only once per document, see setup_mutation_observer
import { setup_mutation_observer } from 'elt'
setup_mutation_observer(document)
// you
You can use the library through import statements. It is perfectly fine to use with webpack, rollup or parcel, although as of now no effort was put to make elt tree-shakable.
import { o, $bind, setup_mutation_observer } from 'elt'
setup_mutation_observer(document)
const o_says = o('hello world')
document.body.appendChild(<div>
<p><input>{$bind.string(o_says)}</input></p>
<p>Element says {o_says} !</p>
</div>)
ELT supports being used as an umd module in a regular <script>
import, in which case its global name is elt.
const { o, $bind, setup_mutation_observer } = elt
setup_mutation_observer(document)
// ... profit !
Use TSX (the typescript version of JSX) to build your interfaces. The result of a TSX expression is alwas a DOM Node
.
This means that the result of a tsx expression (or a e()
call) is directly insertable into the document.
// You can write that.
import { setup_mutation_observer } from 'elt'
setup_mutation_observer(document)
document.body.appendChild(<div class='some-class'>Hello</div>)
Typescript's TSX is awesome. Unfortunately, as of today (version 3.8), its system still considers a TSX element as the type defined as the JSX.Element
type, which is why as far as the type system is concerned, var div = <div/>
will always have the type Node
.
// This is correct, as ELT will create an HTMLDivElement, but unfortunately, typescript won't allow it.
var div: HTMLDivElement = <div/>
// This works
var div = <div/> as HTMLDivElement
// But so does this, which is incorrect
var div = <input/> as HTMLDivElement // this should be HTMLInputElement
// when using the as keyword, Typescript allows a cast as a subtype without complaining.
It is possible to use E()
(or e()
) directly ; they use the correct types. The e
function is made to be a target for tsx code generation, but its signature is a tad more flexible. Check its documentation for more informations.
var div = E('div') // div is infered as HTMLDivElement, hurray !
var div2 = E('div', {class: 'my-class'}, 'Some text content')
ELT provides a few helper functions to work without tsx without too much pain ;
import { o, $bind } from 'elt'
var o_contents = o('')
document.body.appendChild(
E.DIV(
E.SPAN('span contents !'),
E.INPUT($bind.string(o_contents)),
o_contents
)
)
Nodes can of course have children. ELT defines a Renderable
type which defines which types can safely rendered as a child to a node.
You may thus add variables of type :
string
, which will be rendered as isnumber
, which will be converted using.toString()
null
andundefined
, which render nothingNode
, which will be added as-is- An array of all of them. Arrays may be nested ; ELT will traverse through them and flatten them when rendering.
- Finally, an
Observable
of all the previously mentionned types, which will then update the DOM whenever its value change.
This means that for any Observable that should be rendered into the dom, it first has to be converted to one of these types to appear.
import { o } from 'elt'
// A small exemple which works
const o_txt = o('some text')
const o_date = o(new Date())
const date_format = new Intl.DateTimeFormat('fr')
document.body.appendChild(<div>
<span>{o_txt}</span>
{1234}
{['hello', 'world', ['hows', 'it', 'going?']]}
{null}
{/* here, o_date is transformed (tf) to another observable that holds a string, which can then be rendered. */}
<div>{o_date.tf(d => date_format.format(d))}</div>
</div>)
The non-jsx version works by adding children as arguments.
import { o } from 'elt'
const o_txt = o('observable')
const o_date = o(new Date())
const date_format = new Intl.DateTimeFormat('fr')
document.body.appendChild(E.DIV(
E.SPAN(o_txt),
1234,
['hello', 'world', ['hows', 'it', 'going?']],
null,
E.DIV(o_date.tf(d => date_format.format(d)))
))
All attributes on HTMLElement
can have observables passed as value, in which case the attribute is updated as the observable changes.
If the observable is boolean, then the value of the attribute will be ''
.
<div contenteditable={o_boolean}/>
class
and style
on elements can receive Observable
as well as regular values.
class
can be a o.RO<string>
or an object of class definitions, where the properties are the class name and their values the potentially observable condition that will determine if the class is attributed. On top of that, class can receive an array of the two former to build complex classes.
import { o } from 'elt'
const o_class = o('class2')
const o_bool = o(true)
<div class={['class1', o_class, {class3: o_bool}]}/>
// -> <div class='class1 class2 class3'/>
// ... some later code runs the following :
o_bool.set(false)
// -> <div class='class1 class2'/>
o_class.set('another-class')
// -> <div class='class1 another-class'>
The style
attribute does not accept text. Since it is considered good practice to not use this attribute, only its object form is supported for those cases where you can't do without.
const o_width = o('432px')
<Elt style={ {width: o_width} }>
Verbs are simply functions whose name is a verb (hence the name), that usually start with an uppercase letter to add a visual emphasis on their presence. The reason they're named "verbs" is to emphasise the fact they represent dynamicity, things that change.
While they could have been implemented as Components, the choice was deliberately made to make them regular function calls to insist on the fact that they're not just some html component that will sit in the document once rendered.
They usually work in concert with Observables to control the presence of nodes in the document.
For instance, If
will render its then arm only if the given observable is truthy, and the else otherwise.
Repeat
repeats the contents of an array, with an optional separator. RepeatScroll
does the same, but stops rendering elements once they overflow past the bottom of the $scrollable
block they're in.
Decorators are a handy way of playing with a node without having to assign it to a variable first.
As the Renderable
type controls what types can safely be appended to a node, the Insertable
type controls what can be put as a child, without necessarily mean that it will have a visual representation.
Decorators are part of Insertable
, and are simply functions that take the current node as an argument.
document.body.appendChild(
<div>
<input>
{inp => {
// here, inp is of type HTMLInputElement
inp.value = 'some value'
}}
</input>
<div>
This div is all uppercase
{div => {
div.style.textTransform = 'uppercase'
}}
</div>
</div>
)
Note: The above warning about returning Node and having to be cast to their correct type does not affect the functionnality of decorators. Declaring a function in a child will work with the type inferer ;
Decorators may return any Insertable
, even if it is another decorator.
See the existing decorators to see what they can do.
Observables are the mechanism through which we achieve MVVM. They are not RxJS's Observable (see src/observable.ts
).
Basically, an Observable
holds a value. You can retrieve it with .get()
or modify it with .set()
.
import { o } from 'elt'
const o_bool = o(true)
o_bool.get() // true
o_bool.set(false)
o_bool.get() // false
They can be transformed, and these transformations can be bidirectional.
import { o, $click } from 'elt'
const o_obj = o({a: 1, b: 'hello'})
const o_a = o_obj.p('a') // o_a is a new Observable that watches the 'a' property. Its type is o.Observable<number>
o_a.set(3)
const o_tf = o_a.tf({transform: val => val * 2, revert: (nval: number) => nval / 2})
o_tf.get() // 6
o_tf.set(8) // o_a is now 4, and o_obj is {a: 4, b: '!!!'}
// A transform can also be unidirectionnal
const o_tf2 = o_a.tf(val => val * 3)
o_tf2.get() // 9
// But then, the resulting observable is read only !
o_tf2.set(3) // Compile error ! Runtime error too !
document.body.appendChild(<div>
<div>
o_obj is: <code>{o_obj.tf(value => JSON.stringify(value))}</code> and o_a is: <code>{o_a}</code>
<button>{$click(() => o_a.set(3))}
Set o_a
</button>
<button>
{$click(() => o_obj.p('b').set('!!!'))}
Set o_b
</button>
</div>
</div>)
The value in an observable is immutable. Whenever a modifying method is called, the object inside it is cloned.
import { o } from 'elt'
const o_obj = o({a: 1, b: 'b'})
const prev = o_obj.get()
o_obj.p('b').set('something else')
document.body.appendChild(<span>{prev === o_obj.get() ? 'true' : 'false'}</span>)
They can do a lot more than these very simple transformations. Check the Observable documentation.
Two or more observables can be joined together to make a new observable that will update when any of its constituents change. See o.combine
, o.join
and o.merge
.
A notable case is the .p()
method on Observable, which creates a new Observable based on the property of another ; the property itself can be an Observable. If the base object or the property change, the resulting observable is updated.
import { o, Fragment } from 'elt'
type SomeType = {a: string, b: number}
const o_obj = o({a: 'string !', b: 2} as SomeType)
const o_key = o('a' as keyof SomeType)
const o_prop = o_obj.p(o_key)
o_key.set('b') // o_prop now has 2 as a value
// o_prop now has 3
document.body.appendChild(<Fragment>
<div>o_obj: {o_obj.tf(v => JSON.stringify(v))}</div>
<div>o_prop: {o_prop}</div>
<div>
<DemoBtn do={() => o_key.set('a')}/>
<DemoBtn do={() => o_key.set('b')}/>
<DemoBtn do={() => o_obj.set({a: 'world', b: 3})}/>
</div>
</Fragment>)
A component function takes an attribute object and return a Node.
The attrs
argument represents what attributes can be set on the component. In simple cases, it is enough to give the arguments with the &
operator.
Children are always appended at the toplevel element returned by the function. If you want to control where they go, use the ShadowRoot with the $shadow decorator in tandem with <slot>
.
import { Attrs, Renderable } from 'elt'
function MyComponent(attrs: Attrs<HTMLDivElement> & {title: string}) {
return <div>
{$shadow(<>
<h1>{attrs.title}</h1>
{/* children will be inserted in the body div. */}
<div class='body'><slot/></div>
</>)}
</div> as HTMLDivElement
}
document.body.appendChild(<MyComponent title='Some title'>
Content <span>that will be</span> appended.
</MyComponent>)
If the attributes are complex, then it is advisable to define an interface.
import { Attrs } from 'elt'
interface MyComponentAttrs extends Attrs<HTMLDivElement> {
title: string
more_content?: Renderable
}
function MyComponent(attrs: MyComponentAttrs, children: Renderable[]) {
/// ...
}
Since these three attributes are ubiquitous on just any element type, they are handled separately.
They're still passed along the attrs
objects given to the components, but they don't have to be handled. They're applied automatically to the root node returned by the component.
const o_cls = o('some_class')
// this is valid and works on any component
<MyComponent class={o_cls} id='some-id' style={{width: '350px'}}/>