frameorc dom is a JavaScript library that helps building user interfaces in browsers by manipulating DOM trees.
It provides unique, flexible and composable syntax that is nothing else but standard JavaScript, applied in an intelligent way.
The library consists of a dozen short functions in a single file, minified version of which is about 13Kb long. By the number of lines, this README is longer than the library code.
It does not require any build steps, bundlers, transpilers and such tools to function. However, if one sees them necessary, frameorc does not preclude their usage in any way.
It does not impose any methodology or ideology on the programmer. Mixes and matches well with declarative, functional and imperative styles where appropriate. The same with promises, callbacks or async/await.
Let's have a look at some code:
Compare
c.main.important`Alert`
to
<div class="main important">Alert</div>
Here is a fragment of a keypad component example:
body(
c.H2`Keypad example`,
c.keypad(
c.Button(
'C',
on.click(() => v(0)),
css.color`red`.fontWeight`bold`),
c.Button(
'-',
on.click(() => v(-v()))),
Array.from({ length: 10 }, (_, i) =>
c.Button(
i,
attr.type`button`,
key('bn', i),
on.click(() => v(10*v() + i)))),
c.Input.display(
hook.insert((el) => el.elm.focus()),
prop.value(v),
cls.odd(() => v() % 2),
cls.even(() => !(v() % 2)),
on.input(e => v(Number(e.target.value) || 0)))),
);
For something more complex see how TodoMVC is implemented in frameorc in just 130 lines.
One can obtain the files that comprise this library in any convenient way. For
example, using git clone
, or by downloading a zip archive of a particular
branch or tag, or even not downloading anything and just hotlinking the content
from GitHub.
Let's suppose you have downloaded and put the files into the directory that
is accessible as /lib
on your server. In that case the minimal working example
will consist of these two files:
index.html
<script src="index.js" type="module">
This is an application. Enable JavaScript to run it.
</script>
index.js
import { body } from '/lib/dom.js';
body('Hello, world!');
After setting up your server1, visit the URL on which index.html
is served,
and you should see 'Hello, world!' text in the browser.
The library allows creation of DOM trees with elements, attributes and styles, and provides highly efficient and expressive syntax to that end.
The functions comprising the library are: body
, c
, attr
, css
,
on
, Val
, prop
, cls
, key
, hook
, Ref
, attach
, operator
.
For the start, let's have a look at the body
setter function, and three
combinators: c
, attr
and css
.
body
function, setting the content of elementsbody
that sets the content of the current document's
<body>
element.body('Hello')
body
is used, but the same stands for c
, Val
and Ref
explained further in the text.body('Hello ', 'world')
body(
'Hello ',
[
'brave ',
['and ', 'new ']
],
'world'
)
let a = ['and ', 'new '];
let b = (v) => [v, a];
let c = 'world';
body('Hello ', b('brave '), c);
f()
and g()
would obviously work in the next example, as these calls produce
simple values. What we demonstrate is that f
and g
, note the lack of parentheses
designating a call, work as-is too, in combination with nested references and
arrays.let f = () => 'Hello';
let g = () => [f, ', ', 'world'];
body(g);
null
, undefined
and false
are skipped, not generating any child content
in the element. All other data types are converted to strings and added as such.body(
false && 'this will not be displayed',
null,
undefined,
[],
[[], [[]]],
'this will be displayed: ',
0,
'', // an empty text node
true)
c
combinatorc
combinator. By default, it creates a <div>
body(
c('Hello'),
c('World'))
c
and body
can take exactly the same arguments: strings, arrays, functions,
other c
s and operators discussed further in the document.
body(
c.Span('Hello '),
c.B('World'))
body(
c.important('text'))
body(
c.MyElement('my-element'),
c.multiWordClass('multi-word-class'))
The first underscore is replaced with minus. Any uppercase latin letter is changed into lowercase. If this uppercase letter is not the beginning character, it will be prepended by minus. This results in the following substitutions:
"BackgroundColor" => "background-color",
"_backgroundColor" => "-background-color", and
"_BackgroundColor" => "--background-color".
This choice of case translation rules conform to the way JavaScript identifiers
correspond to property names in CSSOM. The translation is automatically applied
by frameorc to tag names, class names and CSS property names (see further the
css
combinator section).
The translation is not done automatically for attributes (presented in the
attr
section) and properties (prop
combinator), as the former may have
both camel-case and dashed names, depending on the tastes of the people who
dictated the standards at the time of their introduction2; the latter are
all JavaScript entities and, therefore, conform to its camelCase syntax
preference.
body(
c.MyElement.withClassOne.andClassTwo.andClassThree('yes, we can do that'))
let tagName = 'MyElement';
let class1 = 'withClassOne';
let class3 = 'andClassThree';
body(c[tagName][class1].andCassTwo[class3]('yes'));
let elm = c.Li.numbered('Item');
body(c.Ul(elm, elm, elm));
body(
c.Span.important('Really?').additionalClass1.andClass2)
let elm = c.Li.numbered('Item');
body(c.Ul(elm, elm.selected, elm));
body(
c.Span.important('This is a div.important').Div)
let elm = c.Li.numbered('Item');
body(
c.Ul(
elm,
elm.Dt.current, // <li> becomes <dt>
elm));
let elm = c.Li.numbered('Item');
body(
c.Ul(
elm,
// The following will contain the text:
// "Item with more content"
elm(' with more content'),
elm));
body(
c.Span.cls1('Hello')
.Div.red.bold(', brave')
(' and new ')
.darkBorder.italicFont('world'))
has the same effect as
body(
c.Div.cls1.red.bold.barkBorder.italicFont(
'Hello', ', brave', ' and new ', 'world'))
body(c`Hello`)
let world = c.Span` World`;
body(c`Hello, ${ world }!`);
The other equivalents are:
body(
c(
'Hello, ',
c.Span`World`,
'!'))
body(c`Hello, `(c.Span`World`, '!'))
These mechanisms are primarily meant for complex cases, such as authoring and styling several layers of components. In simple cases, it is recommended to keep it simple. The author hopes that more powerful techniques, when used responsibly, will allow competent programmers to write succinct, elegant and efficient code.
body(c.Svg(c.Rect()))
attr
combinatorattr
combinator as the child
of the element. This combinator can be repeated, and can be used in any position
between the other children of the element. The same is true about furtherly
discussed other combinators that affect various properties of elements.body(
c.Svg(
c.Rect(
attr.x(0),
attr.y(0),
attr.height(10),
attr.width(10),
)))
attr
allows to chain the attributesbody(
c.Svg(
c.Rect(
attr.x(0).y(0).height(10).width(10),
)))
attr
can combine the attributes to which the same value is assignedbody(
c.Svg(
c.Rect(
attr.x.y(0).height.width(10),
)))
attr.fill('#', '00', 12, '34')
const toHex = v => ('00' + v.toString(16)).slice(-2);
let r = 0, g = 12, b = 34;
let rect = c.Rect(attr.fill('#', [r, g, b].map(toHex)));
body(
c.Svg(
rect(attr.x.y(10).height.width(20)),
rect(attr.x(10).y(40).height.width(20)),
));
attr.fill`#001234`.stroke`#000000`
css
combinatorcss
for brevity, but it affects the style
property
of a DOM element.Inline styles have the highest priority, overriding anything assigned by the
CSS content from linked stylesheet files and <style></style>
tags. That may
be seen as an advantage, because the style of the element will be defined right
in the element, which is a direct, clear and most predictable approach.
let size = 10;
body(
c.P(
css.fontSize.lineHeight(size, 'px')
.color`#333`.border`solid black 1px`,
'Styles can be applied inline'));
let negative = css.backgroundColor`#222`.color`#ccc`;
body(
c(negative, 'Hello. Missed me?'));
body(
c.Style`
@import url("/lib/fonts/inter.css");
body {
font: 12px/1.35 Inter, sans-serif;
margin: 0;
}
`,
c.H1`Hello and welcome`,
c.P`This is a frameorc tutorial`,
)
If the code above develops into something less pleasant to manage, I expect that the user of this library knows how to factor it using variables, functions and other mechanisms people learn while mastering the art of programming. The same goes without saying about the knowledge of CSS, HTML, DOM APIs and related topics.
The mechanisms discussed so far are mostly similar to static HTML, but with the benefit of more flexible and powerful syntax. On top of that foundation, the library provides building blocks that allow you to create DOM subtrees with dynamic attributes and structure. These building blocks are simple and composable, and they work well regardless of whether the programmer chooses a declarative, functional, imperative, or combination approach.
on
combinatoron
combinator. It behaves in the same
way as attr
or css
, except that its arguments are not converted to strings.
The accepted arguments of on
combinator are functions. Strings and template
strings are not supported, as they do not make much sense as event handlers3.body(
c.Button(
on.click.tap((evt) => alert('Ouch')) // two events, same handler, and...
.dblclick.dbltap( // for both events use these two handers:
(evt) => alert('Yeow!'), // handler 1
(evt) => alert('Ow!')), // handler 2
'Click me'))
on
can appear before, between
or after any other children of its parent element, in any place, and can be
repeated any number of times, with different or the same events. The following
code illustrates that, and has the same effect as the code in the previous example:let mkHandler = s => (evt) => alert(s),
ouch = mkHandler('Ouch'),
yeow = mkHandler('Yeow!'),
ow = mkHandler('Ow!');
body(
c.Button(
on.click(ouch),
on.tap(ouch),
on.dblclick(yeow),
on.dblclick(ow),
on.dbltap(yeow),
on.dbltap(ow),
'Click me'));
body.refresh
methodbody.refresh
updates the contents of the body. If a function is used
(NB. used, not called) anywhere in the code that participated in the
formation of the contents, it will be called again.body(
'Current time is: ',
// This is a function, and the resulting text
// will update on every refresh call:
() => new Date().toISOString(),
'. The time when we started was ',
// The following is not a function, just the result of a call.
// It will not be updating.
new Date().toISOString(),
);
setInterval(body.refresh, 1000);
let currentTime = () => new Date().toISOString();
let clock = (caption) => c.clock(
c.caption(caption),
c.time(currentTime), // NB. a function, not a call
);
// A call, but it returns a structure in which, deeply embedded, there is a "live" function
body(clock('Current time'));
setInterval(body.refresh, 1000);
body.refresh()
is ok to call frequently, as it only schedules an update
at the next cycle. It returns a promise. If you need to continue after the real
DOM content has been updated, await
for the call, or use .then()
to queue
a function which will be called after that single update operation really
completes.When setting the content by calling body(...)
, this call also returns the
promise, as body.refresh()
does. All calls to those functions will return
the same Promise
object until it gets resolved upon the application of changes
to the real DOM.
Val
accessorVal
constructs such variables. For example,let name = Val('Grimfang The Crusher')
The code above constructs a variable name
that has some contents (here it is
a name suitable for an orc). If one calls name()
now, it will return the
contained data, 'Grimfang The Crusher'
. The contained value can be changed
by the call with arguments, like this: name('Rokblorggor The Blade')
.
Note that name(undefined)
is not like the call name()
. It will set the
contents to undefined
.
However, call name(...[])
or name.apply(name, [])
is the same as
name()
, it does not assign anything, only returns the contents (a getter
call).
When a value is being assigned to the reactive variable (setter calls, like
name('Yambul')
), an UI refresh will be queued. Don't be afraid to assign
values to such variables or to call body.refresh()
too often. It will not
cause an avalanche of immediate state recomputations and redraws. It will only
schedule an update which happens once per cycle of a JavaScript engine event
loop. Programmatically, the new values will be available immediately, but they
will appear on the screen at the right time. If you need to do something after
that time, await body.refresh()
, or body.refresh.then(something)
.
let name = Val('World'), count = Val(0);
body(
c(
c.Span`Hello, ${ name }!`,
c.Label(
'Name: ',
c.Input(on.input((evt) => name(evt.target.value))))),
c('Number of clicks: ', count),
c(c.Button(on.click(() => count(count() + 1)), 'Click me')));
For example, the code below defines and uses the component Status
, which changes
its contents after being embedded into the DOM tree. Changes happen asynchronously,
as additional information is being loaded. Note also a technique allowing the component
to restart its process in case of an error.
function Status() {
let content = Val();
async function worker() {
content(c.status.loading('Loading...'));
try {
let res = await fetch('/status');
let data = await res.json();
content(c.status.ready('Status: ', data.status));
} catch(e) {
content(c.status.error(
c.error(e),
c.Button('Retry', on.click(worker));
));
}
}
worker();
return content;
}
body(Status());
let count = Val(0), newCount = Val('0');
body(
c.Button('-', on.click(() => count(count()-1))),
c.Span('Count: ', count),
c.Button('+', on.click(() => count(count()+1))),
c.Input(attr.value(count), on.input(e => newCount(e.target.value))),
c.Button('set', on.click(() => count(Number(newCount())))));
The attentive reader will notice that the value in the input field is initially
updating as +
and -
buttons are being clicked. However, after something
is entered into that input field, the updates will stop, even after the entered
value coincides again with the counter value. This may be a feature or a bug,
depending on the effect you desire to obtain. If you need a dynamic behaviour,
replace attr
with prop
in the example above and see the next section for a
more detailed description of prop
combinator.
prop
combinatorattr
is not dynamic enough. The most common case is for input
elements, where the value
attribute only specifies the initial value of the field,
and the current value is set and retrieved from the value
property.To manage properties, frameorc has prop
combinator. It behaves like attr
combinator, but it does not automatically convert its arguments to strings.
The arguments are assigned to element properties as they were passed to this
combinator. If several arguments are supplied, they are assigned as an array.
prop
also supports template strings.
If the same property is being set several times, the last assignment overrides all previous.
let v = Val('');
body(
c.H1`These inputs are synchronised`,
c.Label(
'Input 1',
c.Input(prop.value(v), on.input(e => v(e.target.value)))),
c.Label(
'Input 2',
c.Input(prop.value(v), on.input(e => v(e.target.value)))),
c.Button(on.click(() => v('')), 'Clear'));
cls
combinatorcls
combinator:body(c(cls.important, 'Important!'))
body(c(cls.important.red.bold, 'Important!'))
body(c(cls.important().red.bold(), 'Important!'))
cls
combinator can take arguments. If any of the arguments is true
, or
is the value that JavaScript coerces to boolean true
, the preceding chain of
classes will be added to the DOM element.body(
c(
// <div class="important red bold">
// NB. both .green and .old are rejected due to condition following them
cls.important(2 === 2).green.old(3 < 2).red.bold(2 !== 3),
'Important!'))
If another "truthy" value precedes a function, that function will not be called, as one is enough for the classes to be set.
let tf = () => 2 < 3;
body(
c(
cls.important(2 === 2).red.bold(2 === 3, tf),
'Important!'));
body(c(
cls.important(2 === 2).red.bold(2 === 3, 2 < 3),
cls.red(2 > 3), // .red will be unset
'Important!'))
body(c(
cls.important(2 === 2).red.bold(2 === 3, 2 < 3),
cls.red(2 > 3), // .red will be unset
cls.red(2 !== 3), // .red is set again, last setting, affects the result
'Important!'))
key
combinatorSometimes, it is necessary to mark elements in a certain way, so that neighbouring
elements are not mistaken for each other. In various virtual DOM libraries that
is achieved through assigning those virtual DOM elements a special property key
.
That technique is most frequently applied in lists and tables.
Frameorc provides a key
combinator for that purpose. It is the programmer's task to
make sure all sibling elements have different keys.
body(c.Ul(
elements.map(data =>
c.Li(key(data.id), data.text))))
key
can receive multiple arguments, including functions and arrays. It
flattens and evaluates them in order to build the resulting string in the same
way as attr
combinator does with its arguments.
hook
combinatorhook
combinator, so that your handler functions can be
called upon the events of element creation and destruction, for example.function ScrollHandler({ update }) {
let self;
return c(
css.display`none`,
hook
.create(
(_, e) => self = e.elm,
() => window.addEventListener('scroll', update))
.destroy(
() => window.removeEventListener('scroll', update)));
}
Hooks are based on the underlying Snabbdom library, and the following are supported:
Name | Triggered when | Arguments to callback |
---|---|---|
init |
a vnode has been added | vnode |
create |
a DOM element has been created based on a vnode | emptyVnode, vnode |
insert |
an element has been inserted into the DOM | vnode |
prepatch |
an element is about to be patched | oldVnode, vnode |
update |
an element is being updated | oldVnode, vnode |
postpatch |
an element has been patched | oldVnode, vnode |
destroy |
an element is directly or indirectly being removed | vnode |
remove |
an element is directly being removed from the DOM | vnode, removeCallback |
To receive a real DOM element, use the .elm
property of a vnode
passed into your function,
as in the above example.
Ref
accessorIn addition to Val
, there is also Ref
, which acts as an accessor
to a certain property of an object. The value of such property can be
obtained and assigned via the Ref
, and an assignment will trigger
the interface refresh
. In the special cases described in attach
section below, it will be a refresh
function from the corresponding,
independent VDOM tree setter.
let obj = { a: 1, b: 2 };
let r = Ref(obj, 'a');
console.log(r()); // 1
r(3);
console.log(obj); // { a: 3, b: 2 }
There are two special methods of Val
and Ref
accessors: .on(f)
and
.delete(f)
, allowing you to add and remove the functions which will be called
after an assignment has been made via an accessor. Multiple functions can be
added as such handlers by sequentially calling the .add
method:
v.add(f1).add(f2).add(f3)
.
The handler receives its arguments as (next, ...args)
. Then it can call the
next
function before, after or in the middle of its own code. It has liberty
not to call the next function at all, or call it multiple times. It can pass
args
to the next
unchanged, or modify them as necessary. The normal call is
just next(...args)
. It is the handler's author's responsibility to catch the
errors that may arise during the call of the next
function, and to decide
whether to await
on that call or not.
The simpler way of adding a handler is the .on
method, as it receives the function
that does not have the next
argument. The call for the next handler is done
automatically.
The handler cannot be added twice. If the handler is installed, calls of .on
or .add
with the same handler are ignored. To delete the handler, use the
.delete
method.
This functionality is provided via the chain library, so when in doubt, consult its documentation and source code.
Every Val
and Ref
since their creation already have one assignment
event handler installed: refresh
. In default Val
and Ref
it is
body.refresh
, but in special cases described in the following section covering
the attach
function, it will be a refresh
method from the corresponding
VDOM tree setter. Refresh handler is written in such a way that the refresh will
happen after any other handlers registered for the reactive value.
attach
functionattach(el)
takes a DOM element and returns a setter. For example,
let status = attach(document.getElementById('status'));
status(c.Span.red('Alert'));
The setter function can be used to set the contents of the element.
body
setter, from the description of which this document starts,
is nothing else than a result of attach(document.body)
.
This function is useful in case one decides to have multiple VDOM roots.
Updates of such roots, triggered by calling the setter function, or by calling
its refresh
property, will be happening independently of each other.
status('Current time is: ', () => Date.toISOString());
setInterval(status.refresh, 1000);
Also, every setter obtained in this manner has its own Val
s and Ref
s:
let n = status.Val(0);
status(
c.Span('Number of clicks: ', n),
c.Button(
on.click(e => n(n()+1)),
'Click me'));
Setting values to such Val
s and Ref
s will trigger an update of their
corresponding VDOM tree, and will leave other VDOM trees intact.
Only the children will be added to the parent DOM element. The element itself
and its previous content are left intact. Any number of arguments can be passed
to the setter function, as you would normally do with body
function, seen in
the opening examples of this document.
In the examples above, calls to status.refresh()
and status(...)
return
promises that are resolved after the changes are applied to the real DOM.
Frameorc works well with custom elements. You can use this library to create your own elements of that type. No special considerations are required to operate on shadow DOM, other than understanding its API, of course.
let demoElement = document.getElementById('demo');
let demoRoot = demoElement.attachShadow({ mode: 'open' });
let demo = attach(demoRoot); // just like any other element
demo(
c.H1('Hello'),
c.a.b(1).c(2).d.e(3)`4`(5).f(6, attr.x(100))(attr.y`200`),
// cls.red -- will not work on top-level, shadow root does not have classes
c.Slot(), // works as expected, if you have content in the element
c(cls.red), // works too, as we apply the class here to a normal element
);
The only reminder is that shadow roots are not usual DOM elements. They do not
have classes and attributes, so it is not surprising that if you use top level
cls
or attr
, you will get an exception. You must also be careful with
prop
, on
and other operators used on top level, i.e. shadow root itself.
Operators, in terms of this document, are functions that change elements'
properties and contents. In frameorc, c
, attr
, css
, on
, prop
, cls
,
key
and hook
are predefined operators. Yes, c
is an operator in the sense
that it changes the parent element by appending the content generated by itself
to the parent's children. It is implemented exactly that way, no exceptions made.
Syntactically, operators can be among other children of the element, and appear there in any order, freely intermixing with any other arguments.
Operators can be passed to the setter function, such as body
, or the one
obtained by calling attach
. In that case, they will affect the container
element in the same way as they normally do with elements formed by c
combinator.
Apart from using attr
, prop
, on
and so on, you can define your own
operator function by wrapping it in operator
call. When VDOM element is
being formed, it will be called with (parent, ctx)
arguments, where
parent
is VDOM element and ctx
is the processing context, allowing
to pass information from elements to their children and back, and from the
function forming the preceding child to the next one.
One example of how to use operator
to control the focus of an element
is given in the TodoMVC example.
There is another exported function, frag
. It works by creating a fragment.
The only use of fragments in frameorc is to create a node in a virtual DOM tree
that has hooks. That node will not produce its own DOM element. Only its
children will be inserted into the real DOM. However, hooks will work as if
there is an element. When it is created, destroyed or modified, the
corresponding functions will be called.
For example, run python3 -m http.server
in the directory with index
files;
Another way: deno run --allow-net --allow-read https://deno.land/std@0.196.0/http/file_server.ts
↩
For instance, look at the list of SVG attributes and see for yourself what a variety of identifier styles can be found there. ↩
Actually, strings work as event handlers in attributes (attr
combinator),
where defined accordingly in HTML standard. But we are discussing on
combinator
here, which is equivalent to addEventListener method, operating on functions. ↩