@webqit/oohtml

3.1.7 • Public • Published

OOHTML

npm version npm downloads bundle License

On the AgendaModular HTMLHTML ImportsData BindingData PlumbingPolyfillExamplesLicense

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", web-native workflow as design goal! This project presents what "modern HTML" could mean today!

Building Single Page Applications? OOHTML is a special love letter!

Versions

This is documentation for OOHTML@3. (Looking for OOHTML@1?)

Motivation

Show

Vanilla HTML is unsurprisingly becoming the most compelling option for an increasing number of developers! But the current authoring experience still leaves much to be desired in how the language lacks modularity, reusability, and other fundamental capabilities like data binding! Authors still have to rely on tools - or, to say the least, have to do half of the work in HTML and half in JS - to get even basic things working!

This project pursues an object-oriented approach to HTML and implicitly revisits much of what inhibits the idea of a component architecture for HTML!

On the Agenda

Modular HTML

Modular HTML is a markup pattern that lets us write arbitrary markup as self-contained objects - with each encapsulating their own structure, styling and logic!

OOHTML makes this possible by introducing "namespacing" and style and script scoping!

Namespacing

Naming things is hard! That's especially so where you have one global namespace and a miriad of potentially conflicting names to coordinate!

Here, we get the namespace attribute for designating an element as own naming context for identifiers instead of the global document namespace:

<div id="user" namespace>
  <a id="url" href="https://example.org">
    <span id="name">Joe Bloggs</span>
  </a>
  <a id="email" href="mailto:joebloggs@example.com" >joebloggs@example.com</a>
</div>

--> and this translates really well to an object model:

user
 ├── url
 ├── name
 └── email

--> with a complementary API that exposes said structure to JavaScript applications:

// 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);
}
Learn more

You want to see how IDs are otherwise exposed as global variables:

<div id="foo"><div>
console.log(window.foo); // div

Read more

Style and Script Scoping

We often need a way to keep things like component-specific stylesheets and scripts scoped to a component.

Here, we get the scoped attribute for doing just that:

<div>

  <style scoped>
    :scope { color: red }
  </style>

  <script scoped>
    console.log(this) // div
  </script>

</div>

--> 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 executed again on each re-insertion into the DOM

HTML Imports

HTML Imports is a realtime module system for HTML that speaks HTML! Something like it is the <defs> and <use> system in SVG.

OOHTML makes this possible in just simple conventions - via a new def attribute and a complementary new <import> element!

Module Definition

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 contents (fragment) 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>

Remote Modules

We shouldn't need a different mechanism to work with remote content.

Here, we get remote-loading modules with same <template> element using the src attribute:

<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 borrow from how src works in elements like <img>; terminating with either a load or an error event:

foo.addEventListener('load', loadCallback);
foo.addEventListener('error', errorCallback);

Declarative Module Imports

HTML snippets should be reusable in HTML itself!

Here, 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 getting defined/undefined/redefined)
  • changes in the referenced defs themselves (as to becoming available/loaded/unavailable)

And an <import> element that has been resolved will self-restore in the event that:

  • the above changes resolve to no imports.
  • 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>
  <!--&lt;import ref="/foo#fragment1"&gt;&lt;/import&gt;-->
  <div def="fragment2"></div>
  <!--&lt;import ref="/foo/nested#fragment2"&gt;&lt;/import&gt;-->
</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:

<!--&lt;import ref="/foo/nested#fragment2" nodecount="1"&gt;&lt;/import&gt;-->

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

Imperative Module Imports

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);
Extended Imports concepts

Lazy-Loading Modules

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

Module Inheritance

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>

Imports Contexts

We should be able to have relative import refs that resolve against local contexts in the document tree.

Here, we get just that - as "Imports Contexts", which could be:

  • Simple Base Path Contexts (below)
  • Scoped Module Contexts (below)
  • Named Contexts (below)
  • Extended Scoped Module Contexts (below)

And to facilitate thinking in contexts, we also get an Element.prototype.import() API for context-based module imports.

"Base Path" Contexts

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('#fragment1'); // Relative path (beginning without a slash), resolving to: /foo/nested#fragment1

"Scoped Module" Contexts

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 contexts, 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('#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 

Named Contexts

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

Extended Scoped Module Contexts

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('#fragment2'); // the local module: foo#fragment2, and if not found, the inherited module: /bar/nested#fragment2

Data Binding

Data binding is about 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 expr attribute!

And there's one more: Quantum Scripts for when we need to write extended reactive logic on the UI!

Discrete Data-Binding

Here, we get a comment-based data-binding tag <?{ }?> which gives us a regular HTML comment but also 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). Thus, 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

Inline Data-Binding

Here, we get the expr attribute for a declarative, neat, key/value data-binding syntax:

<div expr="<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 expr="& color:someColor; & backgroundColor:'red'"></div>

--> without being space-sensitive:

<div expr="& color:someColor; &backgroundColor: 'red'"></div>

--> the rest of which can be seen below:

Directive Type Usage
& CSS Property <div expr="& color:someColor; & backgroundColor:someBgColor;"></div>
% Class Name <div expr="% active:app.isActive; % expanded:app.isExpanded;"></div>
~ Attribute Name <a expr="~ href:person.profileUrl+'#bio'; ~ title:'Click me';"></a>
Boolean Attribute <a expr="~ ?required:formField.required; ~ ?aria-checked: formField.checked"></a>
@ Structural Directive: See below
@text Plain text content <span expr="@text:firstName+' '+lastName;"></span>
@html Markup content <span expr="@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 expr="@items: value of [1,2,3] / 'foo#fragment';"></ul>
Same as above but with a key declaration <ul expr="@items: (value,key) of [1,2,3] / 'foo#fragment';"></ul>
Same as above but with different variable names <ul expr="@items: (product,id) of store.products / 'foo#fragment';"></ul>
Same as above but with a dynamic importRef <ul expr="@items: (product,id) of store.products / store.importRef;"></ul>
For ... In Loops
Idea Usage
A for...in loop over an object <ul expr="@items: key in {a:1,b:2} / 'foo#fragment';"></ul>
Same as above but with a value and index declaration <ul expr="@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). Thus, 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.

Quantum Scripts

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.abort();

But while that is automatic, DOM event handlers bound via addEventListener() would still need to be terminated in their own way.

Data Plumbing

Components often need to manage, and be managed by, dynamic data. 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!

The Bindings API

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 mutations in 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';
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');

The Context API

Component trees on the typical UI often call for more than the normal top-down flow of data that the Bindings API facilitates. A child may 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 mechanism behind HTML Imports and Data Binding 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 and unsubscribed 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 saved that "admin" work!

--> all of which gives us a standardized API underneath 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);

Polyfill

OOHTML is being developed as something to be used today—via a polyfill. This is an intentional effort that has continued to ensure that the proposal evolves 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 the Scoped Styles feature, you'd need an external polyfill like the samthor/scoped polyfill (more details below):

<head>
  <script src="https://unpkg.com/style-scoped/scoped.min.js"></script>
</head>
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';

// Initialize the lib
init.call(window, Quantum[, options = {}]);

└ 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! (See async/defer.)

    Of the two, the "OOHTML Lite" edition is the recommend option (as used above) for faster load times unless there's a requirment to have classic scripts follow their native synchronous timing, 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 or async 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 standard text/javascript and module 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.mimeType=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.

  • Scoped CSS. This feature is only in "concept" implementation and doesn't work right now as is. The current implementation simply wraps <style scoped> blocks in an @scope {} block - which itself isn't supported in any browser yet. To try this "concept" implementation, set the style.strategy config to @scope:

    <head>
      <meta name="scoped-css" content="style.strategy=@scope"> <!-- Must come before the polyfil -->
      <script src="https://unpkg.com/@webqit/oohtml/dist/main.js"></script>
    <head>

    Now the following <style scoped>...

    <style scoped>
      h2 { color: red; }
    </style>

    ...will be wrapped to something like:

    <style ref="scoped8eff" scoped>
      @scope from (:has(> style[ref="scoped8eff"])) {
        h2 { color: red; }
      }
    </style>

    Browser support for @scope {} style blocks may be coming soon, but in the meantime, you could try one of the polyfills for <style scoped> out there; e.g. samthor/scoped:

    <script src="https://unpkg.com/style-scoped/scoped.min.js"></script>
  • HTML Imports. The attribute names for exposing reusable modules and for referencing them - the def and ref keywords, respectively - aren't finalized. While the principle of operation remains, these attributes may be renamed in subsequent iterations. But the polyfill is designed to always defer to any syntax that has been explicitly specified using a meta tag. Here's an example:

    <head>
      <meta name="html-imports" content="template.attr.moduledef=def; template.attr.fragmentdef=def; import.attr.moduleref=ref;"> <!-- Must come before the polyfil -->
      <script src="https://unpkg.com/@webqit/oohtml/dist/main.js"></script>
    <head>

    Now, even when the default attribute names change, your def and ref implementation will still work:

Examples

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" aria-labelledby="cat" namespace>
        <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" aria-labelledby="cat" namespace>
        <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" aria-labelledby="cat" namespace>
        <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 expr="~href: '/animals#'+name;"><?{ index+': '+name }?></a></li>
  </template>

  <!-- The loop -->
  <ul expr="@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>

Getting Involved

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:

License

MIT.

Package Sidebar

Install

npm i @webqit/oohtml

Weekly Downloads

1,013

Version

3.1.7

License

MIT

Unpacked Size

2.78 MB

Total Files

57

Last publish

Collaborators

  • ox-harris