Explainer โข Features โข Modular HTML โข HTML Imports โข Data Binding โข Data Plumbing โข Implementation โข Examples โข License
Object-Oriented HTML (OOHTML) is a set of features that extend standard HTML and the DOM to enable authoring modular, reusable and reactive markup - with a "buildless" and intuitive workflow as design goal! This project revisits the HTML problem space to solve for an object-oriented approach to HTML!
Building Single Page Applications? OOHTML is a special love letter! Writing Web Components? Now you can do so with zero tooling! Love vanilla HTML but can't go far with that? Well, now you can!
Versions
This is documentation for OOHTML@4
. (Looking for OOHTML@1
?)
- Actively maintained
- A working implementation
- Proposed at the WICG
- Open to contributions
OOHTML may be used today. This implementation adheres closely to the spec and helps evolve the proposal through a practice-driven process.
Load from a CDN
โโโโโโโโโโ
<script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
โ This is to be placed early on in the document and should be a classic script without any defer
or async
directives!
โ For @webqit/[email protected]
and below, you would need an external polyfill - like the samthor/scoped polyfill - for the Scoped Styles feature:
<head>
<script src="https://unpkg.com/style-scoped/scoped.min.js"></script>
</head>
โ Being an integral part of OOHTML, the Observer and Quantum JS APIs are also accessible on loading the OOHTML polyfill:
const { QuantumFunction, QuantumAsyncFunction, QuantumScript, QuantumModule, QuantumAsyncScript, State, Observer } = window.webqit;
Install from NPM
โโโโโโโโโโ
npm i @webqit/oohtml @webqit/quantum-js
// Import
import * as Quantum from '@webqit/quantum-js/lite'; // Or from '@webqit/quantum-js'; See implementation notes below
import init from '@webqit/oohtml/src/init.js';
// Initialize the lib
init.call(window, Quantum[, options = {}]);
โ Being an integral part of OOHTML, the Observer API, in addition to the Quantum JS APIs, is also available from the OOHTML installation:
import * as Observer from '@webqit/observer';
โ To use the polyfill on server-side DOM instances as made possible by libraries like jsdom, simply install and initialize the library with the DOM instance as above.
โ But all things "SSR" for OOHTML are best left to the @webqit/oohtml-ssr
package!
Extended usage concepts
If you'll be going ahead to build a real app with OOHTML, you may want to consider also using:
-
the
@webqit/oohtml-cli
package for operating a file-based templating system. -
the modest, OOHTML-based Webflo framework to greatly streamline your workflow!
Implementation Notes
-
Scoped/Quantum Scripts. This feature is an extension of Quantum JS. While the main OOHTML build is based on the main Quantum JS APIs, a companion "OOHTML Lite" build is also available based on the Quantum JS Lite edition. The trade-off is in the execution timing of
<script quantum></script>
and<script scoped></script>
elements: being "synchronous/blocking" with the former, and "asynchronous/non-blocking" with the latter! (Seeasync
/defer
.)Of the two, the "OOHTML Lite" edition is the recommend option on web pages (as used above) for faster load times unless there's a requirment to emulate the native synchronous timing of classic scripts, in which case you'd need the main OOHTML build:
<head> <script src="https://unpkg.com/@webqit/oohtml/dist/main.js"></script> </head>
-
Loading Requirements. As specified above, the OOHTML script tag is to be placed early on in the document and should be a classic script without any
defer
orasync
directives!If you must load the script "async", one little trade-off would have to be made for
<script scoped>
and<script quantum>
elements to have them ignored by the browser until the polyfill comes picking them up: employing a custom MIME type in place of the standardtext/javascript
andmodule
types, in which case, a<meta name="scoped-js">
element is used to configure the polyfill to honor the custom MIME type:<head> <meta name="scoped-js" content="script.mimeTypes=module|text/javascript|application/javascript|some-mime"> <script async src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script> </head> <body> <script type="some-mime" scoped> console.log(this); // body </script> </body>
The custom MIME type strategy also comes in as a "fix" for when in a browser or other runtime where the polyfill is not able to intercept
<script scoped>
and<script quantum>
elements ahead of the runtime - e.g. where...<body> <script scoped> console.log(this); // body </script> </body>
...still gives the
window
object in the console. -
Syntax. The syntax for attribute names and API names across features - e.g. the
def
andref
attributes, therender
attribute - isn't finalized, and may change on subsequent iterations, albeit with same principle of operation. But the polyfill is designed to be configurable via meta tags, and to honour any such "overrides". Here's an example:<head> <!-- Configurations come before the polyfil --> <meta name="data-binding" content="attr.render=render;"> <meta name="namespaced-html" content="attr.id=id;"> <meta name="html-imports" content="attr.def=def; attr.ref=ref;"> <script src="https://unpkg.com/@webqit/oohtml/dist/main.js"></script> <head>
Now, even when the default syntax change, your
def
,ref
, etc. overrides will keep your implementation intact.Additionally, you could employ a global prefix in your implementation:
<meta name="webqit" content="prefix=wq;">
...which automatically applies to all
webqit
attributes and APIs (with the exception of thescoped
,quantum
, anddata-*
attributes), such that:<template def="foo"></template>
now becomes:<template wq-def="foo"></template>
,<import ref="foo"></import>
now becomes:<wq-import wq-ref="foo"></wq-import>
,document.import()
now becomes:document.wqImport()
,document.bind()
now becomes:document.wqBind()
,document.bindings
now becomes:document.wqBindings
,- etc.
Show the full syntax table
Spec:
<meta name="data-binding">
Config Default Syntax Description attr.render
render
The "render" attribute for inline data binding. (Docs) attr.itemIndex
data-index
The "item index" attribute for assigning indexes to list items. (Docs) Spec:
<meta name="bindings-api">
Config Default Syntax Description attr.bindingsreflection
bindings
The attribute for exposing an element's bindings. api.bind
bind
The document.bind()
andElement.prototype.bind()
methods. (Docs)api.bindings
bindings
The document.bindings
andElement.prototype.bindings
object properties. (Docs)Spec:
<meta name="context-api">
Config Default Syntax Description attr.contextname
contextname
The "context name" attribute on arbitrary elements. (Docs) api.contexts
contexts
The document.contexts
andElement.prototype.contexts
object properties. (Docs)Spec:
<meta name="html-imports">
Config Default Syntax Description elements.import
import
The tag name for "import" elements. (Docs) attr.def
def
The "definition" attribute on the <template>
elements. (Docs)attr.fragmentdef
Inherits the value of attr.def
The "definition" attribute on a <template>
's contents. (Docs)attr.extends
extends
The "extends" attribute for extending definitions. (Docs) attr.inherits
inherits
The "inherits" attribute for inheriting definitions. (Docs) attr.ref
ref
The "import ref" attribute on "import" elements. (Docs) attr.importscontext
importscontext
The "importscontext" attribute on arbitrary elements. (Docs) api.def
def
The readonly string property for accessing an element's "def" value. (Docs) api.defs
defs
The readonly object property for accessing a <template>
's list of definitions. (Docs)api.import
import
The document.import()
andElement.prototype.import()
methods. (Docs)Spec:
<meta name="namespaced-html">
Config Default Syntax Description attr.namespace
namespace
The "namespace" attribute on arbitrary elements. (Docs) attr.id
id
The "id" attribute on arbitrary elements. (Docs) api.namespace
namespace
The "namespace" object property on arbitrary elements. (Docs) Spec:
<meta name="scoped-css">
(TODO)Spec:
<meta name="scoped-js">
(TODO)
Show
Amidst a multitude of approaches, vanilla HTML remains an attractive option for the UI author! But the current authoring experience still leaves much to be desired in how the language lacks modularity, reusability, and certain modern paradigms like data binding! Authors still have to rely on tools - and, for the most part, have to do half of the work in HTML and half in JS - to express even basic concepts!
"As an author, I want to be able to do 'x' declaratively in HTML instead of imperatively in JavaScript or by means of a special Custom Element!" "As a Web Component author, I want to be able to leverage conventions that keep my component logic concise!" All such "user stories" represent important developer intuitions that has yet to be met in HTML; much of which belong under a broad subject: an object-oriented markup language! This subject is what we explore with OOHTML!
OOHTML comes, not as a specific technology, but as a conceptual "framework" of features that solves for HTML as an object-oriented language - whether that means re-aligning existing features or introducing new ones! While features may be discussed or explored individually, the one agenda "Object-Oriented HTML" helps us stay aligned with the original problem! Each of these features has been introduced below with a small explainer.
OOHTML is effectively different from Web Components (and from the related Declarative Custom Elements and Declarative Shadow DOM efforts) in its focus on "arbitrary" HTML and the DOM rather than on just the Custom Element or Shadow DOM "subset" of the language. This in turn lets us have a niftier authoring experience in Web Components as the latter actually just relies on the very HTML and DOM.
โ You may want to learn more in the introductory article: Revisiting the HTML Problem Space and Introducing OOHTML
The modern UI is best approached with a modular architecture (think UI component frameworks) wherein we are able to author in bits and pieces while having each piece encapsulate their structure, styling and logic!
OOHTML makes this possible by introducing "namespacing" and style and script scoping!
Naming things is hard! That's especially so where you have one global namespace and a miriad of potentially conflicting identifiers to coordinate!
Learn more
You want to see how IDs are, in fact, by default, exposed as global variables:
<div id="foo"><div>
console.log(window.foo); // div
Here, we get a modular naming convention that let's us create a naming context for identifiers in a given subtree - by means of a new namespace
attribute:
<form>
<fieldset namespace>
<legend>Home Address</legend>
<label for="~address-line">Address</label>
<input id="address-line">
<label for="~city">City</label>
<input id="city">
<fieldset>
<fieldset namespace>
<legend>Delivery Address</legend>
<label for="~address-line">Address</label>
<input id="address-line">
<label for="~city">City</label>
<input id="city">
<fieldset>
</form>
This lets us have repeating structures with identical but non-conflicting identifiers. These identifiers are then referenced locally using "local IDREFS
" - denoted by the ~
prefix.
Local IDREFS
are resolved within the namespace where they're used (not globally; not deeply):
// Matches "#city" within the fieldset's namespace; not super namespace, not sub namespace
const city = fieldset.querySelector('#~city');
And when used from the document context, these are resolved against top-level IDs; i.e. IDs in the document namespace itself (not deeply):
<div id="user" namespace>
<a id="url" href="https://example.org">
<span id="name">Joe Bloggs</span>
</a>
<a id="email" href="mailto:[email protected]" >[email protected]</a>
</div>
// Namespace aware ID selectors
console.log(document.querySelector('#user')); // div#user
console.log(document.querySelector('#~user')); // div#user
console.log(document.getElementById('user')); // div#user
console.log(document.getElementById('~user')); // div#user
console.log(document.querySelector('#url')); // a#url
console.log(document.querySelector('#~url')); // null... not directly in the "document" namespace
console.log(document.getElementById('url')); // a#url
console.log(document.getElementById('~url')); // null... not directly in the "document" namespace
And these also play well as navigation targets, with additional support for path expressions given a hierarchy of namespaces:
<a href="#~user/email">Jump to Email</a>
And JavaScript applications are able to consume namespace structures as an object model:
user
โโโ url
โโโ name
โโโ email
// The document.namespace API
let { user } = document.namespace;
// The Element.prototype.namespace API
let { url, name, email } = user.namespace;
All in realtime
The Namespace API is designed to always reflect the DOM in real-time. This may be observed using the general-purpose object observability API - Observer API:
// Observing the addition or removal of elements with an ID
Observer.observe(document.namespace, changeCallback);
const paragraph = document.createElement('p');
paragraph.setAttribute('id', 'bar');
document.body.appendChild(paragraph); // Reported synchronously
// Observing the addition or removal of elements with an ID
paragraph.toggleAttribute('namespace', true);
Observer.observe(paragraph.namespace, changeCallback);
const span = document.createElement('span');
span.setAttribute('id', 'baz');
paragraph.appendChild(span); // Reported synchronously
function changeCallback(changes) {
console.log(changes[0].type, changes[0].key, changes[0].value, changes[0].oldValue);
}
Implementation details
In the current implementation, a small random string is automatically prepended to each ID and IDREF token in the DOM to give the browser something "unique" to work with, but without that implementation detail leaking into your application. So, while an element may be seen in the browser console as having a random hash prepended to their ID or IDREF:
<!-- Original -->
<label for="~real-id">Question 1:</label>
<input id="real-id">
<!-- Transformed -->
<label for="~hg3j:real-id">Question 1:</label>
<input id="~hg3j:real-id">
the values your application sees are the unprefixed IDs and IDREFs:
console.log(label.htmlFor); // ~real-id
console.log(input.id); // real-id
console.log(label.getAttribute('for')); // ~real-id
console.log(input.attributes[0].value); // real-id
Now, for URL targets, e.g. #~user/email
, the "target" element is given a custom :target
class while it matches the URL fragment, and this may be accessed in CSS as:
.\:target {
background-color: whitesmoke;
}
or, to be more complete:
:target, .\:target {
background-color: whitesmoke;
}
We often need a way to keep component-specific style sheets and scripts scoped to a component. This is especially crucial to "page components" in an SPA architecture.
Here, we get a new scoped
attribute that lets us do just that:
<div>
<style scoped>
:scope { color: red }
</style>
<script scoped>
console.log(this) // div
</script>
</div>
And the special namespace-aware ID selector is supported from within scoped style sheets:
<div namespace>
<a id="url" href="https://example.org">
<span id="name">Joe Bloggs</span>
</a>
<a id="email" href="mailto:[email protected]" >[email protected]</a>
<style scoped>
#\~name { color: red }
</style>
</div>
And everything comes with a complementary low-level API that exposes said assets to tools:
let { styleSheets, scripts } = user; // APIs that are analogous to the document.styleSheets, document.scripts properties
Learn more
Here, the scoped
attribute has two effects on the <script>
element:
- The
this
keyword is implicitly bound to the script's host element - The
<script>
element is (re)executed on each re-insertion into the DOM
HTML Imports is a realtime import system for HTML that's drawn entirely on HTML - and which addresses a different pain point in comparison to the abandoned <link type="import">
feature and the HTML Modules proposal! Something like it is the <defs>
and <use>
system in SVG.
Here, we get a way to both define and reuse a snippet out of same document:
<head>
<template def="foo">
<div></div>
</template>
</head>
<body>
<import ref="foo"></import>
</body>
...while optionally supporting remote content without a change in paradigm:
<head>
<template def="foo" src="/foo.html"></template>
</head>
<body>
<import ref="foo"></import>
</body>
OOHTML makes this possible in a simple new def
attribute and a complementary new <import>
element!
A "module" here is any piece of markup that can be reused.
Here, we get the def
attribute for defining those - both at the <template>
element level and at its direct children (fragments) level:
<head>
<template def="foo">
<div def="fragment1">A module fragment that can be accessed independently</div>
<div def="fragment2">Another module fragment that can be accessed independently</div>
<p>An element that isn't explicitly exposed.</p>
</template>
</head>
--> with module nesting for code organization:
<head>
<template def="foo">
<div def="fragment1"></div>
<template def="nested">
<div def="fragment2"></div>
</template>
</template>
</head>
We shouldn't need a different mechanism to work with remote content.
Here, OOHTML extends the <template>
with an src
attribute that lets us have self-loading <template>
elements:
<template def="foo" src="/foo.html"></template>
<!-- which links to the file below -->
-- file: /foo.html --
<div def="fragment1"></div>
<template def="nested" src="/nested.html"></template>
<!-- which itself links to the file below -->
-- file: /nested.html --
<div def="fragment2"></div>
--> which just draws on the existing semantics of src
in elements like <img>
; terminating with either a load
or an error
event:
foo.addEventListener('load', loadCallback);
foo.addEventListener('error', errorCallback);
HTML snippets should be reusable entirely out of HTML! So, we get an <import>
element that lets us do just that:
<body>
<import ref="/foo#fragment1"></import> <!-- Pending resolution -->
<import ref="/foo/nested#fragment2"></import> <!-- Pending resolution -->
</body>
<body>
<div def="fragment1"></div> <!-- After resolution -->
<div def="fragment2"></div> <!-- After resolution -->
</body>
All in realtime
Here, import refs are live bindings that are sensitive to:
- changes in the ref itself (as to refs that are later defined/undefined/redefined)
- changes in the referenced defs themselves (as to these later becoming available/loaded/unavailable)
And an <import>
element that has been resolved will self-restore in the event that:
- the above changes invalidate the reference.
- the previously slotted contents have all been programmatically removed and slot is empty.
With SSR support
On the server, these <import>
elements will retain their place in the DOM, but this time, serialized into comment nodes, while having their output rendered just above them as siblings.
The above resolved imports would thus give us something like:
<body>
<div def="fragment1"></div>
<!--<import ref="/foo#fragment1"></import>-->
<div def="fragment2"></div>
<!--<import ref="/foo/nested#fragment2"></import>-->
</body>
But they also will need to remember the exact imported nodes that they manage so as to be able to re-establish relevant relationships on getting to the client. This information is automatically encoded as part of the serialised element itself, in something like:
<!--<import ref="/foo/nested#fragment2" nodecount="1"></import>-->
Now, on getting to the client and getting "hydrated" back into an <import>
element, that extra bit of information is decoded, and original relationships are formed again. But, the <import>
element itself stays invisible in the DOM while still continuing to kick as above!
Note: We know we're on the server when
window.webqit.env === 'server'
. This flag is automatically set by OOHTML's current SSR engine: OOHTML-SSR
JavaScript applications will need more than a declarative import mechanism.
Here, we get an HTMLImports API for imperative module import:
const moduleObject1 = document.import('/foo#fragment1');
console.log(moduleObject1.value); // divElement
const moduleObject2 = document.import('/foo/nested#fragment2');
console.log(moduleObject2.value); // divElement
--> with the moduleObject.value
property being a live property for when results are delivered asynchronously; e.g. in the case of remote modules:
Observer.observe(moduleObject2, 'value', e => {
console.log(e.value); // divElement
});
--> with an equivalent callback
option on the import()
API itself:
document.import('/foo#fragment1', divElement => {
console.log(divElement);
});
--> with an optional live
parameter for staying subscribed to live results:
const moduleObject2 = document.import('/foo/nested#fragment2', true/*live*/);
console.log(moduleObject2.value);
Observer.observe(moduleObject2, 'value', e => {
console.log(e.value);
});
document.import('/foo#fragment1', true/*live*/, divElement => {
console.log(divElement); // To be received after remote module has been loaded
});
...both of which get notified on doing something like the below:
document.querySelector('template[def="foo"]').content.firstElementChild.remove();
--> with a moduleObject.abort()
method for unsubscribing from live updates:
--> with an optional signal
parameter for passing in a custom AbortSignal
instance:
const abortController = new AbortController;
const moduleObject2 = document.import('/foo/nested#fragment2', { live: true, signal: abortController.signal });
setTimeout(() => {
abortController.abort(); // which would also call moduleObject2.abort()
}, 1000);
We should be able to defer module loading until we really need them.
Here, we get the loading="lazy"
directive for that; and loading is only then triggered on the first attempt to import those or their contents:
<!-- Loading doesn't happen until the first time this is being accessed -->
<template def="foo" src="/foo.html" loading="lazy"></template>
<body>
<import ref="/foo#fragment1"></import> <!-- Triggers module loading and resolves on load success -->
</body>
const moduleObject2 = document.import('/foo#fragment1'); // Triggers module loading and resolves at moduleObject2.value on load success
We'll often have repeating markup structures across component layouts.
Here, we get module nesting with inheritance to facilitate more reusability:
<template def="foo">
<header def="header"></header>
<footer def="footer"></footer>
<template def="nested1" inherits="header footer"> <!-- Using the "inherits" attribute -->
<main def="main"></main>
</template>
<template def="nested2" inherits="header footer"> <!-- Using the "inherits" attribute -->
<main def="main"></main>
</template>
</template>
<template def="foo">
<template def="common">
<header def="header"></header>
<footer def="footer"></footer>
</template>
<template def="nested1" extends="common"> <!-- Using the "extends" attribute -->
<main def="main"></main>
</template>
<template def="nested2" extends="common"> <!-- Using the "extends" attribute -->
<main def="main"></main>
</template>
</template>
<body>
<import ref="/foo/nested1#header"></import>
</body>
We should be able to have relative import refs that resolve against local contexts in the document tree.
Here, we call these "Imports Contexts", and these could be:
- Simple Base Path Contexts (below)
- Scoped Module Contexts (below)
- Named Contexts (below)
- Extended Scoped Module Contexts (below)
And to facilitate working with contexts, we also get an Element.prototype.import()
API that is context-aware.
Base paths may be defined at arbitrary levels in the page using the importscontext
attribute:
<body importscontext="/foo">
<section>
<import ref="#fragment1"></import> <!-- Relative path (beginning without a slash), resolving to: /foo#fragment1 -->
</section>
</body>
<body importscontext="/foo/nested">
<main>
<import ref="#fragment2"></import> <!-- Relative path (beginning without a slash), resolving to: /foo/nested#fragment2 -->
</main>
</body>
--> with said base paths being able to "nest" nicely:
<body importscontext="/foo">
<section>
<import ref="#fragment1"></import> <!-- Relative path (beginning without a slash), resolves to: /foo#fragment1 -->
</section>
<div importscontext="nested"> <!-- Relative path (beginning without a slash), resolves to: /foo/nested -->
<main>
<import ref="#fragment2"></import> <!-- Relative path (beginning without a slash), resolves to: /foo/nested#fragment2 -->
</main>
</div>
</body>
--> with the Element.prototype.import()
API for equivalent context-based imports:
// Using the HTMLImports API to import from context
const contextElement = document.querySelector('section');
const response = contextElement.import('#fragment1'); // Relative path (beginning without a slash), resolving to: /foo#fragment1
// Using the HTMLImports API to import from context
const contextElement = document.querySelector('main');
const response = contextElement.import('#fragment2'); // Relative path (beginning without a slash), resolving to: /foo/nested#fragment2
Some modules will only be relevant within a specific context in the page, and those wouldn't need to have a business with the global scope.
Here, we get the scoped
attribute for scoping those to their respective hosts, to give us an object-scoped module system (like what Scoped Registries seek to be to Custom Elements):
<section> <!-- Host object -->
<template def="foo" scoped> <!-- Scoped to host object and not available globally -->
<div def="fragment1"></div>
</template>
<div>
<import ref="foo#fragment1"></import> <!-- Relative path (beginning without a slash), resolving to the local module: foo#fragment1 -->
<import ref="/foo#fragment1"></import> <!-- Absolute path, resolving to the global module: /foo#fragment1 -->
</div>
</section>
--> with the Element.prototype.import()
API for equivalent context-based imports:
// Using the HTMLImports API for local import
const contextElement = document.querySelector('div');
const localModule = moduleHost.import('foo#fragment1'); // Relative path (beginning without a slash), resolving to the local module: foo#fragment1
// Using the HTMLImports API for global import
const contextElement = document.querySelector('div');
const globalModule = contextElement.import('/foo#fragment1'); // Absolute path, resolving to the global module: /foo#fragment1
Imports Contexts may be named for direct referencing:
<body contextname="context1" importscontext="/foo/nested">
<import ref="#fragment2"></import> <!-- Relative path (beginning without a slash), resolves to: /foo/nested#fragment2 -->
<section importscontext="/foo">
<import ref="#fragment1"></import> <!-- Relative path (beginning without a slash), resolves to: /foo#fragment1 -->
<div>
<import ref="@context1#fragment2"></import> <!-- Context-relative path (beginning with a context name), resolves to: /foo/nested#fragment2 -->
</div>
</section>
</body>
--> with the Element.prototype.import()
API for equivalent context-based imports:
// Using the HTMLImports API to import from a named Imports Context
const contextElement = document.querySelector('div');
const result = contextElement.import('@context1#fragment2'); // Resolving to the module:/foo/nested#fragment2
Scoped Module Contexts may also have a Base Path Context that they inherit from:
<body contextname="context1" importscontext="/bar">
<section importscontext="nested"> <!-- object with Scoped Modules, plus inherited context: /bar/nested -->
<template def="foo" scoped> <!-- Scoped to host object and not available globally -->
<div def="fragment1"></div>
<div def="fragment2"></div>
</template>
<div>
<import ref="foo#fragment2"></import> <!-- Relative path (beginning without a slash), resolving to the local module: foo#fragment2, and if not found, the inherited module: /bar/nested/foo#2 -->
<import ref="/foo#fragment1"></import> <!-- Absolute path, resolving to the global module: /foo#fragment1 -->
<import ref="@context1#fragment1"></import> <!-- Relative path with a named context, resolving to the global module: /bar#fragment1 -->
</div>
</section>
</body>
--> with the Element.prototype.import()
API for equivalent context-based imports:
// Using the HTMLImports API
const contextElement = document.querySelector('div');
const result = contextElement.import('foo#fragment2'); // the local module: foo#fragment2, and if not found, the inherited module: /bar/nested#fragment2
Data binding is the idea of declaratively binding the UI to application data, wherein the relevant parts of the UI automatically update as application state changes.
OOHTML makes this possible in just simple conventions - via a new comment-based data-binding syntax <?{ }?>
and a complementary new render
attribute!
And for when we need to write extensive reactive logic on the UI, a perfect answer: Quantum Scripts!
Here, we get a comment-based data-binding syntax <?{ }?>
(or <!--?{ }?-->
), which works as a regular HTML comment but also as an insertion point for application data:
<html>
<head>
<title></title>
</head>
<body>
<hi><?{ app.title }?></h1>
Hi, I'm <?{ name ?? 'Default name' }?>!
and here's another way to write the same comment: <!--?{ cool }?-->
</body>
</html>
Resolution details
Here, JavaScript references are resolved from the closest node up the document tree that exposes a corresponding binding on its Bindings API (discussed below). For the above markup, our underlying data structure could be something like the below:
document.bind({ name: 'James Boye', cool: '100%', app: { title: 'Demo App' } });
document.body.bind({ name: 'John Doe' });
document: { name: 'James Boye', cool: '100%', app: { title: 'Demo App' } }
โโโ html
โโโ head
โโโ body: { name: 'John Doe' }
Now, the name
reference remains bound to the name
binding on the <body>
element until the meaning of "closest node" changes again:
delete document.body.bindings.name;
While the cool
reference remains bound to the cool
binding on the document
node until the meaning of "closest node" changes again:
document.body.bindings.cool = '200%';
With SSR support
On the server, these data-binding tags will retain their place in the DOM while having their output rendered to their right in a text node.
The following expression: <?{ 'Hello World' }?>
would thus give us: <?{ 'Hello World' }?>Hello World
.
But they also will need to remember the exact text node that they manage, so as to be able to re-establish relevant relationships on getting to the client. That information is automatically encoded as part of the declaration itself, and that brings us to having a typical server-rendered binding in the following form:
<?{ 'Hello World'; [=11] }?>Hello World
Now, on getting to the client, that extra bit of information gets decoded, and original relationships are forned again. But the binding tag itself graciously disappears from the DOM, while the now "hydrated" text node continues to kick!
Note: We know we're on the server when
window.webqit.env === 'server'
. This flag is automatically set by OOHTML's current SSR engine: OOHTML-SSR
For attribute-based data binding, OOHTML deviates from the usual (and problematic) idea of bringing markup-style bindings into attribute texts: title="Hello { titleValue }"
, as though attributes had the same semantics as markup. Instead, we get a dedicated "render" attribute - render
- for a nifty, key/value data-binding language:
Note that in OOHTML <= v3 the
render
attribute wasexpr
.
<div render="<directive> <param>: <arg>;"></div>
--> where:
<directive>
is a directive, which is always a symbol<param>
is the parameter being bound, which could be a CSS property, class name, attribute name, Structural Directive - depending on the givin directive<arg>
is the bound value or expression
--> which would give us the following for a CSS property:
<div render="& color:someColor; & backgroundColor:'red'"></div>
--> without being space-sensitive:
<div render="& color:someColor; &backgroundColor: 'red'"></div>
--> the rest of which can be seen below:
Directive | Type | Usage |
---|---|---|
& |
CSS Property | <div render="& color:someColor; & backgroundColor:someBgColor;"></div> |
% |
Class Name | <div render="% active:app.isActive; % expanded:app.isExpanded;"></div> |
~ |
Attribute Name | <a render="~ href:person.profileUrl+'#bio'; ~ title:'Click me';"></a> |
Boolean Attribute | <a render="~ ?required:formField.required; ~ ?aria-checked: formField.checked"></a> |
|
@ |
Structural Directive: | See below |
@text |
Plain text content | <span render="@text:firstName+' '+lastName;"></span> |
@html |
Markup content | <span render="@html: '<i>'+firstName+'</i>';"></span> |
@items |
A list, of the following format | <declaration> <of|in> <iterable> / <importRef> See next two tables |
For ... Of
Loops
Idea | Usage |
---|---|
A for...of loop over an array/iterable |
<ul render="@items: value of [1,2,3] / 'foo#fragment';"></ul> |
Same as above but with a key declaration |
<ul render="@items: (value,key) of [1,2,3] / 'foo#fragment';"></ul> |
Same as above but with different variable names | <ul render="@items: (product,id) of store.products / 'foo#fragment';"></ul> |
Same as above but with a dynamic importRef |
<ul render="@items: (product,id) of store.products / store.importRef;"></ul> |
For ... In
Loops
Idea | Usage |
---|---|
A for...in loop over an object |
<ul render="@items: key in {a:1,b:2} / 'foo#fragment';"></ul> |
Same as above but with a value and index declaration |
<ul render="@items: (key,value,index) in {a:1, b:2} / 'foo#fragment';"></ul> |
Resolution details
Here, JavaScript references are resolved from the closest node up the document tree that exposes a corresponding binding on its Bindings API (discussed below). For the above CSS bindings, our underlying data structure could be something like the below:
document.bind({ someColor: 'green', someBgColor: 'yellow' });
document.body.bind({ someBgColor: 'silver' });
document: { someColor: 'green', someBgColor: 'yellow' }
โโโ html
โโโ head
โโโ body: { someBgColor: 'silver' }
Now, the someBgColor
reference remains bound to the someBgColor
binding on the <body>
element until the meaning of "closest node" changes again:
delete document.body.bindings.someBgColor;
While the someColor
reference remains bound to the someColor
binding on the document
node until the meaning of "closest node" changes again:
document.body.bindings.someColor = 'brown';
All in realtime
Bindings are resolved in realtime! And in fact, for lists, in-place mutations - additions and removals - on the iteratee are automatically reflected on the UI!
With SSR support
For lists, generated item elements are automatically assigned a corresponding key with a data-key
attribute! This helps in remapping generated item nodes to their corresponding entry in iteratee during a rerendering or during hydration.
We often still need to write more serious reactive logic on the UI than a declarative data-binding language can provide for. But we shouldn't need to reach for special tooling or some "serious" programming paradigm on top of JavaScript.
Here, from the same <script>
element we already write, we get a direct upgrade path to reactive programming in just the addition of an attribute: quantum
- for Quantum Scripts:
<script quantum>
// Code here
console.log(this); // window
</script>
<script type="module" quantum>
// Code here
console.log(this); // undefined
</script>
--> which gives us fine-grained reactivity on top of literal JavaScript syntax; and which adds up really well with the scoped
attribute for Single Page Applications:
<main>
<script scoped quantum>
// Code here
console.log(this); // main
</script>
</main>
<main>
<script type="module" scoped quantum>
// Code here
console.log(this); // main
</script>
</main>
--> with content being whatever you normally would write in a <script>
element, minus the "manual" work for reactivity:
<main>
<script type="module" scoped quantum>
import { someAPI } from 'some-module';
let clickCount = 0;
console.log(clickCount);
someAPI(clickCount);
this.addEventListener('click', e => clickCount++);
</script>
</main>
--> within which dynamic application state/data, and even things like the Namespace API above, fit seamlessly:
<main namespace>
<script scoped quantum>
if (this.namespace.specialButton) {
console.log('specialButton present!');
} else {
console.log('specialButton not present!');
}
let specialButton = this.namespace.specialButton;
console.log(specialButton);
</script>
</main>
const main = document.querySelector('main');
const button = document.createElement('button');
button.id = 'specialButton';
const addButton = () => {
main.appendChild(button);
setTimeout(removeButton, 5000);
};
const removeButton = () => {
button.remove();
setTimeout(addButton, 5000);
};
Learn more
It's Imperative Reactive Programming (IRP) right there and it's the Quantum runtime extension to JavaScript!
Here, the runtime executes your code in a special execution mode that gets literal JavaScript expressions to statically reflect changes. This makes a lot of things possible on the UI! The Quantum JS documentation has a detailed run down.
Now, in each case above, reactivity terminates on script's removal from the DOM or via a programmatic approach:
const script = document.querySelector('script[quantum]');
// const script = document.querySelector('main').scripts[0];
script.state.dispose();
// which also happens on doing script.remove()
But note that while said termination is automatic on script's removal, DOM event handlers bound via addEventListener()
would still need to be terminated in their own way.
Components often need to manage, and be driven by, dynamic application state. That could get pretty problematic and messy if all of that should go on DOM nodes as direct properties:
Example
// Inside a custom element
connectedCallback() {
this.prop1 = 1;
this.prop2 = 2;
this.prop3 = 3;
this.style = 'tall-dark'; // ??? - conflict with the standard HTMLElement: style property
}
// Outside the component
const node = document.querySelector('my-element');
node.prop1 = 1;
node.prop2 = 2;
node.prop3 = 3;
node.normalize = true; // ??? - conflict with the standard Node: normalize() method
This calls for a decent API and some data-flow mechanism!
A place to maintain state need not be a complex state machine! Here, that comes as a simple, read/write, data object exposed on the document object and on DOM elements as a readonly bindings
property. This is the Bindings API.
--> it's an ordinary JavaScript object that can be read and mutated:
// Read
console.log(document.bindings); // {}
// Modify
document.bindings.app = { title: 'Demo App' };
console.log(document.bindings.app); // { title: 'Demo App' }
const node = document.querySelector('div');
// Read
console.log(node.bindings); // {}
// Modify
node.bindings.style = 'tall-dark';
node.bindings.normalize = true;
--> with a complementary bind()
method that lets us make multiple mutations in one batch:
// ------------
// Set multiple properties
document.bind({ name: 'James Boye', cool: '100%', app: { title: 'Demo App' } });
// ------------
// Replace existing properties with a new set
document.bind({ signedIn: false, hot: '100%' });
// Inspect
console.log(document.bindings); // { signedIn: false, hot: '100%' }
// ------------
// Merge a new set of properties with existing
document.bind({ name: 'James Boye', cool: '100%' }, { merge: true });
// Inspect
console.log(document.bindings); // { signedIn: false, hot: '100%', name: 'James Boye', cool: '100%' }
--> which also provides an easy way to pass data down a component tree:
// Inside a custom element
connectedCallback() {
this.child1.bind(this.bindings.child1Data);
this.child2.bind(this.bindings.child2Data);
}
--> and with the Observer API in the picture all the way for reactivity:
Observer.observe(document.bindings, mutations => {
mutations.forEach(mutation => console.log(mutation));
});
// Inside a custom element
connectedCallback() {
Observer.observe(this.bindings, 'style', e => {
// Compunonent should magically change style
console.log(e.value);
});
}
const node = document.querySelector('my-element');
node.bindings.style = 'tall-dark';
Implementation details
In the current OOHTML implementation, the document.bindings
and Element.prototype.bindings
APIs are implemented as proxies over their actual bindings interface to enable some interface-level reactivity. This lets us have reactivity over literal property assignments and deletions on these interfaces:
node.bindings.style = 'tall-dark'; // Reactive assignment
delete node.bindings.style; // Reactive deletion
For mutations at a deeper level to be reactive, the corresponding Observer API method would need to be used:
Observer.set(document.bindings.app, 'title', 'Demo App!!!');
Observer.deleteProperty(document.bindings.app, 'title');
Component trees on the typical UI often call for more than the normal "top-down" flow of data that the Bindings API facilitates. We still often require the ability to "look up" the component tree to directly access specific data, or in other words, get data from "context". This is where a Context API comes in.
Interestingly, the Context API is the underlying "resolution" infrastructure for the Namespace API and the Data Binding and HTML Imports features in OOHTML!
Here, we simply leverage the DOM's existing event system to fire a "request" event and let an arbitrary "provider" in context fulfill the request. This becomes very simple with the Context API which is exposed on the document object and on element instances as a readonly contexts
property.
--> with the contexts.request()
method for firing requests:
// ------------
// Get an arbitrary
const node = document.querySelector('my-element');
// ------------
// Prepare and fire request event
const requestParams = { kind: 'html-imports', detail: '/foo#fragment1' };
const response = node.contexts.request(requestParams);
// ------------
// Handle response
console.log(response.value); // It works!
--> and the contexts.attach()
and contexts.detach()
methods for attaching/detaching providers at arbitrary levels in the DOM tree:
// ------------
// Define a CustomContext class
class FakeImportsContext extends DOMContext {
static kind = 'html-imports';
handle(event) {
console.log(event.detail); // '/foo#fragment1'
event.respondWith('It works!');
}
}
// ------------
// Instantiate and attach to a node
const fakeImportsContext = new FakeImportsContext;
document.contexts.attach(fakeImportsContext);
// ------------
// Detach anytime
document.contexts.detach(fakeImportsContext);
Details
In the current OOHTML implementation, the Context API interfaces are exposed via the global webqit
object:
const { DOMContext, DOMContextRequestEvent, DOMContextResponse, DuplicateContextError } = window.webqit;
Now, by design...
-
a provider will automatically adopt the
contextname
, if any, of its host element:<div contextname="context1"></div>
// Instantiate and attach to a node const host = document.querySelector('div'); const fakeImportsContext = new FakeImportsContext; host.contexts.attach(fakeImportsContext); // Inspect name console.log(fakeImportsContext.name); // context1
...which a request could target:
const requestParams = { kind: FakeImportsContext.kind, targetContext: 'context1', detail: '/foo#fragment1' }; const response = node.contexts.request(requestParams);
-
and providers of same kind could be differentiated by an extra "detail" - an arbitrary value passed to the constructor:
const fakeImportsContext = new FakeImportsContext('lorem'); console.log(fakeImportsContext.detail); // lorem
-
and a provider could indicate to manually match requests where the defualt "kind" matching, and optional "targetContext" matching, don't suffice:
// Define a CustomContext class class CustomContext extends DOMContext { static kind = 'html-imports'; matchEvent(event) { // The default request matching algorithm + "detail" matching return super.matchEvent(event) && event.detail === this.detail; } handle(event) { console.log(event.detail); event.respondWith('It works!'); } }
-
and a request could choose to stay subscribed to changes on the requested data; the request would simply add a
live
flag:// Set the "live" flag const requestParams = { kind: FakeImportsContext.kind, targetContext: 'context1', detail: '/foo#fragment1', live: true };
...then stay alert to said updates on the returned
DOMContextResponse
object or specify a callback function at request time:// Handle response without a callback const response = node.contexts.request(requestParams); console.log(response.value); // It works! Observer.observe(response, 'value', e => { console.log(e.value); // It works live! });
// Handle response with a callback node.contexts.request(requestParams, value => { console.log(value); // It works! // It works live! });
...while provider simply checks for the
event.live
flag and keep the updates flowing:// Define a CustomContext class class CustomContext extends DOMContext { static kind = 'html-imports'; handle(event) { event.respondWith('It works!'); if (event.live) { setTimeout(() => { event.respondWith('It works live!'); }, 5000); } } }
...or optionally implement a
subscribed
andunsubscribed
lifecycle hook for when a "live" event enters and leaves the instance:// Define a CustomContext class class CustomContext extends DOMContext { static kind = 'html-imports'; subscribed(event) { console.log(this.subscriptions.size); // 1 } unsubscribed(event) { console.log(this.subscriptions.size); // 0 } handle(event) { event.respondWith('It works!'); if (event.live) { setTimeout(() => { event.respondWith('It works live!'); }, 5000); } } }
-
live requests are terminated via the returned
DOMContextResponse
object...response.abort();
...or via an initially specified custom
AbortSignal
:// Add a signal to the original request const abortController = new AbortController; const requestParams = { kind: FakeImportsContext.kind, targetContext: 'context1', detail: '/foo#fragment1', live: true, signal: abortController.signal };
abortController.abort(); // Which also calls response.abort();
-
now, where a node in a provider's subtree is suddenly attached an identical provider, any live requests the super provider may be serving are automatically "claimed" by the sub provider:
document: // 'fake-provider' here โโโ html โโโ head โโโ body: // 'fake-provider' here. Our request above is now served from here.
And where the sub provider is suddenly detached from said node, any live requests it may have served are automatically hoisted back to super provider.
document: // 'fake-provider' here. Our request above is now served from here. โโโ html โโโ head โโโ body:
While, in all, the requesting code is spared all of that "admin" work!
--> all of which gives us a standardized API across context-based features in HTML - like HTMLImports and Data Binding:
<div contextname="vendor1">
<div contextname="vendor2">
...
<my-element>
<!-- Declarative import -->
<import ref="@vendor1/foo#fragment1"></import>
<!-- Declarative Data Binding -->
<?{ @vendor2.app.title }?>
</my-element>
...
</div>
</div>
// ------------
// Equivalent import() approach
const response = myElement.import('@vendor1/foo#fragment1');
// ------------
// Equivalent Context API approach
const requestParams = { kind: 'html-imports', targetContext: 'vendor1', detail: 'foo#fragment1' };
const response = myElement.contexts.request(requestParams);
// ------------
// Handle response
console.log(response.value);
// ------------
// Context API request for bindings
const requestParams = { kind: 'bindings', targetContext: 'vendor2', detail: 'app' };
const response = myElement.contexts.request(requestParams);
// ------------
// Handle response
console.log(response.value.title);
Here are a few examples in the wide range of use cases these features cover. While we'll demonstrate the most basic form of these scenarios, it takes roughly the same principles to build an intricate form and a highly interactive UI.
Example 1: Single Page Application
โโโโโโโโโโ
The following is how something you could call a Single Page Application (SPA) could be made - with zero tooling:
--> First, two components that are themselves analogous to a Single File Component (SFC):
<template def="pages">
<template def="layout">
<header def="header"></header>
<footer def="footer"></footer>
</template>
<!-- Home Page -->
<template def="home" extends="layout">
<main def="main" namespace>
<h1 id="banner">Home Page</h1>
<a id="cta" href="#/products">Go to Products</a>
<template scoped></template>
<style scoped></style>
<script scoped></script>
</main>
</template>
<!-- Products Page -->
<template def="products" extends="layout">
<main def="main" namespace>
<h1 id="banner">Products Page</h1>
<a id="cta" href="#/home">Go to Home</a>
<template scoped></template>
<style scoped></style>
<script scoped></script>
</main>
</template>
</template>
--> Then a 2-line router that alternates the view based on the URL hash:
<body importscontext="/pages/home">
<import ref="#header"></import>
<import ref="#main"></import>
<import ref="#footer"></import>
<script>
const route = () => { document.body.setAttribute('importscontext', '/pages' + location.hash.substring(1)); };
window.addEventListener('hashchange', route);
</script>
</body>
Example 2: Multi-Level Namespacing
โโโโโโโโโโ
The following is a Listbox component lifted directly from the ARIA Authoring Practices Guide (APG) but with IDs effectively "contained" at different levels within the component using the namespace
attribute.
<div namespace class="listbox-area">
<div>
<span id="ss_elem" class="listbox-label">
Choose your animal sidekick
</span>
<div id="ss_elem_list"
tabindex="0"
role="listbox"
aria-labelledby="~ss_elem">
<ul role="group" namespace aria-labelledby="~cat">
<li role="presentation" id="cat">
Land
</li>
<li id="ss_elem_1" role="option">
Cat
</li>
<li id="ss_elem_2" role="option">
Dog
</li>
<li id="ss_elem_3" role="option">
Tiger
</li>
<li id="ss_elem_4" role="option">
Reindeer
</li>
<li id="ss_elem_5" role="option">
Raccoon
</li>
</ul>
<ul role="group" namespace aria-labelledby="~cat">
<li role="presentation" id="cat">
Water
</li>
<li id="ss_elem_6" role="option">
Dolphin
</li>
<li id="ss_elem_7" role="option">
Flounder
</li>
<li id="ss_elem_8" role="option">
Eel
</li>
</ul>
<ul role="group" namespace aria-labelledby="~cat">
<li role="presentation" id="cat">
Air
</li>
<li id="ss_elem_9" role="option">
Falcon
</li>
<li id="ss_elem_10" role="option">
Winged Horse
</li>
<li id="ss_elem_11" role="option">
Owl
</li>
</ul>
</div>
</div>
</div>
Example 3: Dynamic Shadow DOM
โโโโโโโโโโ
The following is a custom element that derives its Shadow DOM from an imported <tenplate>
element. The idea is to have different Shadow DOM layouts defined and let the "usage" context decide which variant is imported!
--> First, two layout options defined for the Shadow DOM:
<template def="vendor1">
<template def="components-layout1">
<template def="magic-button">
<span id="icon"></span> <span id="text"></span>
</template>
</template>
<template def="components-layout2">
<template def="magic-button">
<span id="text"></span> <span id="icon"></span>
</template>
</template>
</template>
--> Next, the Shadow DOM creation that imports its layout from context:
customElements.define('magic-button', class extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
this.import('@vendor1/magic-button', template => {
shadowRoot.appendChild( template.content.cloneNode(true) );
});
}
});
--> Then, the part where we just drop the component in "layout" contexts:
<div contextname="vendor1" importscontext="/vendor1/components-layout1">
<magic-button></magic-button>
<aside contextname="vendor1" importscontext="/vendor1/components-layout2">
<magic-button></magic-button>
</aside>
</div>
Example 4: Declarative Lists
โโโโโโโโโโ
The following is a hypothetical list page!
<section>
<!-- The "items" template -->
<template def="item" scoped>
<li><a render="~href: '/animals#'+name;"><?{ index+': '+name }?></a></li>
</template>
<!-- The loop -->
<ul render="@items: (name,index) of ['dog','cat','ram'] / 'item';"></ul>
</section>
Example 5: Imperative Lists
โโโโโโโโโโ
The following is much like the above, but imperative. Additions and removals on the data items are also statically reflected!
<section namespace>
<!-- The "items" template -->
<template def="item" scoped>
<li><a>Item</a></li>
</template>
<!-- The loop -->
<ul id="list"></ul>
<script scoped>
// Import item template
let itemImport = this.import('item');
let itemTemplate = itemImport.value;
// Iterate
let items = [ 'Item 1', 'Item 2', 'Item 3' ];
for (let entry of items) {
const currentItem = itemTemplate.content.cloneNode(true);
// Add to DOM
this.namespace.list.appendChild(currentItem);
// Remove from DOM whenever corresponding entry is removed
if (typeof entry === 'undefined') {
currentItem.remove();
continue;
}
// Render
currentItem.innerHTML = entry;
}
// Add a new entry
setTimeout(() => items.push('Item 4'), 1000);
// Remove an new entry
setTimeout(() => items.pop(), 2000);
</script>
</section>
All forms of contributions are welcome at this time. For example, syntax and other implementation details are all up for discussion. Also, help is needed with more formal documentation. And here are specific links:
MIT.