Revisiting the HTML Problem Space and Introducing OOHTML

oxharris

Oxford Harrison

Posted on January 24, 2024

Revisiting the HTML Problem Space and Introducing OOHTML

It's 2024! Is it perhaps time to give attention to HTML?

It's a time when many people are interested in going back to the basics! Even whole teams are looking to make the big return from rethinking best practices! (How we, in fact, got fed such a line remains a mystery!)

But perhaps, no one is talking about the alternative and how still "philosophical" it is to build anything of substance in just HTML, CSS and JS in 2024! In fact, you may contend that a lot still goes unanswered in our web-native playbooks!

I'd agree!

Actually, while it's now possible to streamline your workflow with features like native ES Modules + Import Maps (I can see you on that, Joe Pea), the Shadow DOM (I see that you love that, Passle), CSS nesting, etc., so much is still only philosophical!

For example, more recently, I decided to play the devil's advocate on the subject myself, and, so, threw in an obvious question: how do you do reactivity? (Hoping to hear something intriguing!) But it turns out, we still aren't close! And you're almost always disappointed each time!

It's something we cannot possibly be dogmatic about or evangelize as though there were no limitations when we talk about web-native development. That could make one come across as being out of touch with reality or as being merely dismissive of framework-era innovations!

Web-native development, from one perspective, simply addresses overheads!

That's the whole point about "leveraging native platform capabilities and streamlining your workflow and tooling budget"! — The web-native manifesto.

That's the whole point about native technologies like Web Components, speaking of which:

"One of the best outcomes from delegating our component model to the platform is that, when we do, many things get cheaper. Not only can we throw out the code to create and manage separate trees, browsers will increasingly compete on performance for apps structured this way."Alex Russel

And should any aspect of that, at some point, begin to go counterproductive, such that you wind up with more boilerplates and homegrown tooling, or more runtime regressions — being oftentimes the counterargument, it should be time again to address overheads, not sugar coat them!

In the spirit of that, here is what I am happy to do today: discuss specific pain points that have yet to be addressed natively and touch each one with the relevant part of a proposal you're sure to be excited about: Object-Oriented HTML (OOHTML)!

OOHTML is a new set of HTML and DOM-level features designed to make the HTML-First + Progressive Enhancement paradigm a viable idea — initially developed out of need at WebQit! If you've ever tried, as much as we have, to make success of the above, you probably would come to the same design and architectural conclusions as below and you might as well be inclined to put your money where your mouth is!

Coincidentally, with vanilla HTML increasingly becoming an attractive option for the modern UI author — amidst a multitude of frameworks, it is a perfect time to revisit the HTML problem space!

This is a long discussion but I can tell you this will be worth your time and contribution! See what's on the agenda:

Show Full Outline


Question 1: How Do You Do Reactivity?

This isn't something Web Components do today! And I do in fact fear that inventing a WC-only solution to this effect, such that you now have to employ a custom <my-div> element where a regular <div> element would ordinarily suffice, would yet constitute a new form of overhead!

And I'd like to say that what we do in Lit today isn't to me a particularly attractive thing! TL;DR: a runtime solution to a markup-level problem is an absolute non-starter! That's to still miss the essence of a markup language!

You enter a framework like Vue or Svelte and you see something more like the ideal: a markup-based solution — wherein HTML is your first language:

<!-- Vue -->
<script setup>
  let count = 0;
</script>

<template>
  <button>Count is: {{ count }}</button>
</template>
Enter fullscreen mode Exit fullscreen mode
<!-- Svelte -->
<script>
  let count = 0;
</script>

<button>Count is: {count}!</button>
Enter fullscreen mode Exit fullscreen mode

Something more HTML-ish now!

You are able to just write markup and progressively add logic, as against going ahead of the problem with a JS-first approach!

Now, you think of this as a native language feature, and you aren't totally mistaking! For that would be us delegating all of the work to the HTML parser and getting to write code that practically hits the ground running:

button.innerHTML = 'Count is: {count}!';
Enter fullscreen mode Exit fullscreen mode

Which would mean: no compile step; no waiting for some JS dependency to generate the UI!

Introducing: Native Data Binding for HTML

But the Vue/Svelte conventions above aren't where this leads! Not exactly!

First, we do need a binding syntax that upholds, instead of change, the default behaviour of the opening brace { and closing brace } characters in HTML! Whereas these should behave as mere text characters, a new behaviour is introduced above wherein, across the active DOM, every arbitrary text sequence in that form — {count} — is treated as special and automatically transformed. That as a native language feature (i.e. browsers suddenly waking up with this new rule) would be a breaking change to older code that make up a large percentage of the web today:

<body>
  Hello {curly braces}!
</body>
Enter fullscreen mode Exit fullscreen mode
element.innerHTML = 'Hello {curly braces}!';
Enter fullscreen mode Exit fullscreen mode

Consider the dilemma: should raw template tags be default-visible so as to be backwards-compatible with older code? Then, newer code would be having a "flash of unrendered content" and an inadvertent exposure of low-level implementation details! Should they be default hidden, then older code would be broken!

Given how the HTML language currently works, I found a sweet spot with a comment-based approach wherein regular HTML comments get to do the job and double up as dedicated insertion points for application data:

<body>
  Hello {curly braces}!

  <button>Count is: <?{count}?>!</button>

  Or, using the comment syntax you might know: <button>Count is: <!--?{count}?-->!</button>
</body>
Enter fullscreen mode Exit fullscreen mode
element.innerHTML = 'Count is: <?{count}?>. Hello {curly braces}!';
Enter fullscreen mode Exit fullscreen mode

Data-binding expressions are now default-hidden while application data becomes available at any timing! And the idea of having a special behaviour for certain text characters now requires an explicit opt-in using a special opening character sequence <? (or the longer equivalent <!--?) and a special closing character sequence ?> (or the longer equivalent ?-->). (Of course, syntax could be improved on where possible!)

This is something I really do like and I found that it resonates with many developers!

We next come to attribute bindings and the first challenge becomes whether or not to support text interpolation within attributes as Svelte would have it?:

<img {src} alt="{name} dances." />
Enter fullscreen mode Exit fullscreen mode

Heck! That again would be a breaking change in how curly braces are treated within attributes, just as has been considered above!

Also, how do you hydrate server-rendered attributes such that, given the <img> above, the browser still knows to map the src and alt attributes to certain application data after having been rendered to?:

<img src="/my/image.png" alt="John Doe dances." />
Enter fullscreen mode Exit fullscreen mode

Interestingly, I did set my foot on this path with OOHTML and that turned out messy!

Here is where Vue shines with its idea of having a separate, pseudo attribute do the binding for every given attribute:

<img v-bind:src="src" v-bind:alt="computedExpr">
<!-- Or, for short: <img :src="src" :alt="computedExpr"> -->
Enter fullscreen mode Exit fullscreen mode

which renders to:

<img v-bind:src="src" v-bind:alt="computedExpr" src="/my/image.png" alt="John Doe dances.">
Enter fullscreen mode Exit fullscreen mode

thus, obviating the trouble with the former approach.

But heck! The attribute bloat... the sheer idea of an extra attribute for every given attribute!

What if we simply had one dedicated attribute for everything wherein we follow a key/value mapping syntax?:

<img render="src:src; alt:computedExpr">
<!-- Or, with on-the-fly JS expressions: <img render="src:src; alt:name+' dances.'"> -->
Enter fullscreen mode Exit fullscreen mode

to render to?:

<img render="src:src; alt:computedExpr" src="/my/image.png" alt="John Doe dances.">
Enter fullscreen mode Exit fullscreen mode

That would give us a more compact binding syntax, plus the same benefit as with the Vue approach — of having the original binding logic retained, instead of replaced, after having been rendered!

We only now need to extend our syntax to support a wider range of DOM element features like "class list" and "style". For this, we introduce the idea of directives, or special symbols, that add the relevant meaning to each binding:

e.g. the tilde symbol ~ for attribute binding:

<img render="~src:src; ~alt:computedExpr" src="/my/image.png" alt="John Doe dances.">
Enter fullscreen mode Exit fullscreen mode

the percent symbol % for class binding:

<my-widget render="%active:app.isActive"></my-widget>
Enter fullscreen mode Exit fullscreen mode

the ampersand & for CSS binding:

<div render="&color:app.themeColor"></div>
Enter fullscreen mode Exit fullscreen mode

etc.

I find this very conventional on the backdrop of existing syntaxes like CSS rules! And we've introduced no breaking changes and no funky attribute names!

But how should bindings be resolved? From an embedded <script> tag in scope as we have above?:

<script>
  let count = 0;
</script>

<button>Count is: <?{ count }?>!</button>
Enter fullscreen mode Exit fullscreen mode

That idea might be more suited to the Single File Component (SFC) architecture than to the DOM itself where the actual building blocks aren't SFCs but the very DOM nodes themselves!

Given the DOM, it makes more sense for the said JS references in binding expressions to have to do with the state of those DOM nodes:

<button>Count is: <?{ count }?>!</button>
Enter fullscreen mode Exit fullscreen mode
button.count = 0;
Enter fullscreen mode Exit fullscreen mode

which also resonates with the object/property paradigm of Custom Elements:

<my-counter>Count is: <?{ count }?>!</my-counter>
Enter fullscreen mode Exit fullscreen mode
customElements.define('my-counter', class extends HTMLElement {
  connectedCallback() {
    this.count = 0;
  }
});
Enter fullscreen mode Exit fullscreen mode

But there goes one subtle challenge: using an element's root namespace for arbitrary state management and potentially conflicting with native methods and properties!

It turns out, a more decent approach would be to have a dedicated state object for this:

button.bindings.count = 0;
Enter fullscreen mode Exit fullscreen mode
connectedCallback() {
  this.bindings.count = 0;
}
Enter fullscreen mode Exit fullscreen mode

And to make this even more flexible, we don't have to restrict the resolution scope of references to the immediate host elements. A more flexible approach would be to have things resolve from the closest node in scope who exposes said property, such that:

<body>
  <div>
    <div>
      <button>Count is: <?{ count }?>!</button>
    </div>
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode
document.body.bindings.count = 0;
// Or, even
document.bindings.count = 0;
Enter fullscreen mode Exit fullscreen mode

still works!

This way, it becomes possible for higher-level state (or even the global state that lives at document.bindings) to still be reflected at deeply nested elements in the page without having to mechanically pass data from node to mode down the tree!

Wanna Try?

Simply include the OOHTML polyfill from a CDN!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

And here's something to try on a blank document:

<!DOCTYPE html>
<html>

  <head>
    <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
    <script>

      // Global count
      document.bindings.count = 0;
      setInterval(() => document.bindings.count++, 1000);

      // Local count
      customElements.define('my-counter', class extends HTMLElement {
        connectedCallback() {
          this.bindings.count = 0;
          setInterval(() => this.bindings.count++, 2000);
        }
      });
    </script>
  </head>

  <body>
    <h1>Global count is: <?{ count }?>.</h1>
    <my-counter>Local count is: <?{ count }?>.</my-counter>
  </body>

</html>
Enter fullscreen mode Exit fullscreen mode

And you could try writing the "Declarative Lists" example in the list of examples!

I ask that you share your thoughts and leave us a star 🌟!

You may want to visit the Data-Binding section on the project README to learn more!

Introducing: Quantum Reactivity

Data binding isn't all there is to reactivity on the UI!

While you're able to express JavaScript logic within binding tags:

<h1>Global count is: <?{ count }?>.</h1>
<h2>Global count, doubled, is: <?{ count * 2 }?>.</h2>
Enter fullscreen mode Exit fullscreen mode

you still need a way to write reactive logic in your main application code — i.e. the counter logic itself, above — as things begin to grow and require some form of dependency tracking.

Here's a sampling across frameworks of how our code could look in a reactive programming paradigm:

<!-- Vue -->
<script setup>
  import { ref, computed } from "vue";

  let count = ref(0);
  let doubleCount = computed(() => count.value * 2);
</script>

<template>
  <button>Count is: {{ count }}</button>
  <button>Double count is: {{ doubleCount }}</button>
</template>
Enter fullscreen mode Exit fullscreen mode
<!-- Svelte -->
<script>
  let count = 0;
  $: doubleCount = count * 2;
</script>

<button>Count is: {count}!</button>
<button>Double count is: {doubleCount}!</button>
Enter fullscreen mode Exit fullscreen mode
<!-- Upcoming Svelte 5 -->
<script>
  let count = $state(0);
  let doubleCount = $derived(count * 2);
</script>

<button>Count is: {count}!</button>
<button>Double count is: {doubleCount}!</button>
Enter fullscreen mode Exit fullscreen mode

But it's a whole new paradigm now from the ordinary imperative paradigm you started with! Many "reactive primitives" and much symbolism now, and there isn't a universal syntax for this form of programming!

Svelte 3, in 2019, did try to bring reactivity in vanilla JS syntax, as seen in the middle code above, but that was greatly limited in many ways! And, unfortunately, Svelte is unable to move forward with that idea in v5!

Problem with the above is: that whole idea of manually modelling relationships is an overhead; you're paying a price that need not be paid! Short explainer: you do not need to re-express the dependency graph of your logic when that's a baseline knowledge for any runtime! Plus, you could never do that manually as cheaply, and as accurately as a runtime would!

Long explainer is what I presented in Re-Exploring Reactivity and Introducing the Observer API and Reflex Functions — the previous post in this series! You'll find that we can have reactivity without a shift in programming paradigm!

It turns out, we're able to again delegate this work to the platform and simply enable reactive programming in the ordinary imperative form of JavaScript! Impossible? That is what we sought to explore with the Quantum JavaScript project!

Quantum JS is a runtime extension to JavaScript that lets us write ordinary JavaScript and have it work reactively! Given our markup above, we're able to simply opt-in to reactive programming on the regular <script> element using the quantum attribute:

<script quantum>
  // Global count
  let count = 0;
  let doubleCount = count * 2;
  setInterval(() => count++, 1000);

  document.bindings.count = count;
  document.bindings.doubleCount = doubleCount;
</script>
Enter fullscreen mode Exit fullscreen mode

and for the Custom Element definition, we're able to turn our existing connectedCallback() method to a Quantum function using a special quantum flag:

// Local count
customElements.define('my-counter', class extends HTMLElement {
  quantum connectedCallback() {
    let count = 0;
    let doubleCount = count * 2;
    setInterval(() => count++, 1000);

    this.bindings.count = count;
    this.bindings.doubleCount = doubleCount;
  }
});
Enter fullscreen mode Exit fullscreen mode

It's exciting how we practically now have a universal syntax across both reactive and non-reactive code and can now leverage metal-level accuracy and performance! And as a runtime extension to JavaScript, there isn't the "Svelte 3" sort of limitations like relying on symbolism or top-level let declaration, and others! On the contrary, you are able to enjoy the full range of the JS language — conditionals and loops, spread and "rest" syntax sugars, flow control statements, etc.

This Works Today!

Simply include the OOHTML Polyfill and you have a new useful magic right in the browser! You wanna try the "Imperative Lists" example in the list of examples to have a feel!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

You may also want to visit Quantum JS itself and leave us a star 🌟.


Question 2: How Do You Make Components?

Let's say: Web Components, or, in other words, Custom Elements and the Shadow DOM — being collectively a powerful mechanism for encapsulating behaviour, structure, and styling!

Cool, but... there are times you aren't thinking about a "custom" element or some special technology called Shadow DOM when you say "component"!

Given the tailwind component library as one random reference

a sampling of people's idea of that term should include:

  • a distinct unit of behaviour
    • e.g. a carousel component that enables a certain form of interaction consistently across instances, e.g. rotating through a series of images or content
  • a distinct structural form
    • e.g. a card component that models a certain structure consistently across instances, e.g. featuring an image, a title bar, a description line, a summary area
  • a distinct visual form
    • e.g. a grid component that presents elements in a certain pattern consistently across instances

This means that it isn't always a Web Components' call! For example, what have many "visual" components, like grid above, got to do with Custom Elements or Shadow DOM? And let's just say, this whole idea of subtree isolation which the Shadow DOM is best for, isn't always the idea for many, many "components"!

Consider, for example, structural components, like card above! I often just want a way to "model" the needed relationships, or, in other words, a way to associate the card's features like image and title bar with the card, and nothing more! (Something people have done for years with a naming convention like BEM!)

<div class="card">
  <img class="card__image" src="image.jpg" alt="Image">
  <h2 class="card__title">Card Title</h2>
  <div>
    <p class="card__description">Card Description</p>
  </div>
  <div>
    <p class="card__summary">Card Summary</p>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

which would translate to writing CSS selectors that feel "namespaced":

.card { ... }
.card__title { ... }
Enter fullscreen mode Exit fullscreen mode

This means, you more often just want to write modular markup and less often want to opt-in to subtree isolation and a new rendering model!

The ideal authoring experience for HTML, therefore === having a way to do both elegantly: author modular markup by default and let the use case decide when to opt-in to the Shadow DOM!

Introducing: Namespacing

Enter modular markup: we currently have to rely on annoying naming conventions to model relationships. (E.g. BEM, above.) And for the relationships that work with only IDREFs, like the relationship between a <label> and an <input>, and others involving ARIA Relationship Attributes, we are challenged with HTML's global ID system wherein you have to generate IDs that must be unique throughout the document:

<form>

  <fieldset>
    <legend>Home Address</legend>

    <label for="home__address-line">Address</label>
    <input id="home__address-line">

    <label for="home__city">City</label>
    <input id="home__city">
  <fieldset>

  <fieldset>
    <legend>Delivery Address</legend>

    <label for="delivery__address-line">Address</label>
    <input id="delivery__address-line">

    <label for="delivery__city">City</label>
    <input id="delivery__city">
  <fieldset>

</form>
Enter fullscreen mode Exit fullscreen mode

This has you thinking globally even when what you're working on has no global relevance! For a fairly-sized document, the level of coordination needed at the global level is often too unrealistic to happen by hand!

But how about a way to keep those relationships local, or implicitly namespaced? Meet the 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>
Enter fullscreen mode Exit fullscreen mode

Notice how we haven't introduced a breaking change to how IDREFs are resolved in browsers! You need the tilde ~ character to denote "relativity", or "local" resolution.

You're able to use that in selectors to match things within a specified namespace:

fieldset #~address-line {}
Enter fullscreen mode Exit fullscreen mode
document.querySelector('#~address-line'); // null; not in the global namespace
document.getElementById('~address-line'); // null; not in the global namespace

fieldset.querySelector('#~address-line'); // input#address-line; in the fieldset namespace
Enter fullscreen mode Exit fullscreen mode

In JavaScript, you are additionally able to access these elements declaratively using the namespace API:

let { city, ... } = fieldset.namespace;
Enter fullscreen mode Exit fullscreen mode

And you get reactivity for free on top of that — in being able to observe DOM changes happening within given namespace:

Observe.observe(fieldset.namespace, changes => {
  console.log(changes);
});
Enter fullscreen mode Exit fullscreen mode

Good Thinking?

This is all currently possible using the OOHTML Polyfill!

The "Multi-Level Namespacing" example in the list of examples is something you may want to try!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

You may also want to visit the Namespacing section in the project README to learn more!

I again ask that you share your thoughts and leave us a star 🌟!

Introducing: Style and Script Scoping

While namespacing helps us keep IDREFs relative, we still need a way to keep component-specific style sheets and scripts scoped! This is a common idea across frameworks!

Here, we are able to do that using a new scoped attribute:

<div>

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

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

</div>
Enter fullscreen mode Exit fullscreen mode

Just like that!

Now, that comes especially crucial to SPAs!

It is often asked how the scoped attribute on the <style> element compares with the @scope rule in the CSS Cascading and Inheritance Level 6 Module which lets us do something similar:

<div>
  <style>
    @scope {
      p { color: red; }
    }
  </style>
  <p>this is red</p>
</div>
<p>not red</p>
Enter fullscreen mode Exit fullscreen mode

But here's the deal with the attribute-based approach:

  • Free of an extra nesting level!

  • Consistent with the syntax for two other things in the scoping agenda: scoped scripts — <script scoped> — and scoped HTML modules — <template scoped> — as we'll see shortly!

  • Presents one way to interpret a <style> element: as either "entirely scoped" or "entirely unscoped"; which isn't the case with the @scope rule approach:

    Code
    <style scoped>
    /* scoped rules */
    <style>
    
    <style>
    /* unscoped rules */
    <style>
    

    vs

    <style>
    @scope {
      /* scoped rules */
    }
    /* unscoped rules */
    @scope {
      /* scoped rules */
    }
    /* unscoped rules */
    </style>
    

    Notice how the latter requires you to parse the CSS source text itself to know what's going on with a <style> element, vs how the the former removes the guesswork at the attribute level!

  • Presents us an opportunity to have scoped style sheets that can truly make no global footprint, as in, this time, map to their immediate host element instead of to the document:

    Code
    <div>
      <style scoped>
      /* scoped rules */
      </style>
    </div>
    
    <style>
    /* unscoped rules */
    </style>
    


    // Given that the scoped style sheet really has no global relevance
    console.log(div.styleSheets.length); // 1
    // Only the second style sheet making a global footprint
    console.log(document.styleSheets.length); // 1
    

That said, here's how the scoped attribute works for the <style> element:

  • the :scoped pseudo selector is treated as a reference the style sheet's host element
  • the relative ID selector, e.g. #~child, is resolved within given namespace:

    Code
    <div namespace>
    
      <p id="child"></p>
    
      <div namespace>
        <p id="child"></p>
      </div>
    
      <style scoped>
      /* matches only the first "p" */
      #~child { color: red; }
      /* matches all "p" as usual */
      #child { background-color: whitesmoke; }
      </style>
    
    </div>
    

  • where multiple identical scoped styles exists, you are able to add an (experimental) shared directive, as in <style scoped shared>, to get only the first instance processed and shared by all host as an adopted style sheet, to optimise performance:

    Code
    <ul>
      <li>
        <style scoped shared></style>
      </li>
      <li>
        <style scoped shared></style>
      </li>
    </ul>
    

and here's how the scoped attribute works for the <script> element:

  • the this keyword is bound to the script's host element
  • the <script> element is (re)executed on each re-insertion into the DOM

Did that just pique your interest?

Try Now!

Simply include the OOHTML Polyfill and go ahead with scoping!

The "Single Page Application" example in the list of examples is something I'm sure you wanna try!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

You may also want to visit the Style and Script Scoping section in the project README!

And we'd like for you to share your thoughts and leave us a star 🌟!


Question 3: How Do You Re-Use Components?

A component architecture is a way to organise code! That involves being able to work on disparate pieces of an idea and have them automatically come together! It's about the biggest need frameworks fill! Now, something like that in the HTML/DOM land is the <defs> and <use> system in SVG!

You could mention the <template> element which affords us a place to define reusable pieces of markup. But the <template> element is really only half the idea of a templating system; the remaining half left out to imperative DOM APIs:

let fragment = document.querySelector('template').content;
document.body.appendChild(fragment.cloneNode(true));
Enter fullscreen mode Exit fullscreen mode

And when it comes to dealing with remote contents: a different set of APIs:

fetch('/file.html').then(response => response.text()).then(content => {
  document.body.append(content);
});
Enter fullscreen mode Exit fullscreen mode

This has meant doing half of the work in HTML and half in JS!

We do have the HTML Modules proposal, but that's still a JavaScript feature, not HTML!

But here's what I have always wanted: a simple define-and-use system in HTML, just as with the SVG <defs> and <use> concept!

Introducing: HTML Imports

HTML Imports is a system for templating and reusing objects - in both declarative and programmatic terms! It extends the language with a definition attribute — def, complements that with a new <import> element, and has everything working together as a real-time module system!

Here, we get a way to both define and reuse a snippet within same document, exactly as with the SVG <defs> and <use> system we've always had:

<head>

  <template def="foo">
    <div></div>
  </template>

</head>
<body>

  <import ref="foo"></import>

</body>
Enter fullscreen mode Exit fullscreen mode

...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>
Enter fullscreen mode Exit fullscreen mode

Module Definition

You use the def attribute to expose a <template> element, and optionally, its direct children, as definition:

<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>
Enter fullscreen mode Exit fullscreen mode

And you are able to nest modules nicely for code organisation:

<head>

  <template def="bar">
    <div def="fragment1"></div>

    <template def="nested">
      <div def="fragment2"></div>
    </template>
  </template>

</head>
Enter fullscreen mode Exit fullscreen mode

Remote Modules

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="bar" src="/foo.html"></template>
Enter fullscreen mode Exit fullscreen mode

Loaded file:

-- file: /foo.html --
<div def="fragment1"></div>
<template def="nested" src="/nested.html"></template>
Enter fullscreen mode Exit fullscreen mode

Sub-loaded file:

-- file: /nested.html --
<div def="fragment2"></div>
Enter fullscreen mode Exit fullscreen mode

And you can lazy-load modules using the loading="lazy" directive; meaning that loading doesn't happen until the first attempt to access the given model:

<template def="foo" src="/foo.html" loading="lazy"></template>
Enter fullscreen mode Exit fullscreen mode

Module Imports

You use the <import> element for declarative module imports:

Notice the before and after

<body> <!-- Before -->
  <import ref="/foo">Default content</import>
</body>
Enter fullscreen mode Exit fullscreen mode
<body> <!-- After -->
  <div def="fragment1"></div>
  <div def="fragment2"></div>
  <p></p>
</body>
Enter fullscreen mode Exit fullscreen mode
<body> <!-- Before -->
  <import ref="/foo#fragment1">Default content</import>
</body>
Enter fullscreen mode Exit fullscreen mode
<body> <!-- After -->
  <div def="fragment1"></div>
</body>
Enter fullscreen mode Exit fullscreen mode

and the HTMLImports API for programmatic module imports:

const result  = document.import('/foo#fragment1'); // module:/foo#fragment1, received synchronously
const divElement = result.value;
Enter fullscreen mode Exit fullscreen mode
document.import('/foo#fragment1', divElement => {
  console.log(divElement); // module:/foo#fragment1, received synchronously
});
Enter fullscreen mode Exit fullscreen mode
document.import('/bar/nested#fragment2', divElement => {
  console.log(divElement); // module:/bar/nested#fragment2;
});
Enter fullscreen mode Exit fullscreen mode

Scoped Modules

Just as with scoped styles and scripts above, you are able to scope <template> elements to create an object-scoped module system:

Notice how paths are resolved as to whether globally or relatively.

<section> <!-- module host -->

  <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), resolves to the local module: foo#fragment1 -->
    <import ref="/foo#fragment1"></import> <!-- Absolute path (beginning with a slash), resolves to the global module: /foo#fragment1 -->
  </div>

</section>
Enter fullscreen mode Exit fullscreen mode

Paths are also resolved the same way on the HTMLImports API:

// Showing relative path resolution
document.querySelector('div').import('foo#fragment1', divElement => {
  console.log(divElement); // the local module: foo#fragment1
});
Enter fullscreen mode Exit fullscreen mode
// Showing absolute path resolution
document.querySelector('div').import('/foo#fragment1', divElement => {
  console.log(divElement); // the global module: foo#fragment1
});
Enter fullscreen mode Exit fullscreen mode

Consider:

Custom elements can now, sometimes, be designed to have their logic decoupled from markup:

<my-element> <!-- module host -->

  <template def="foo" scoped> <!-- Scoped to host object and not available globally -->
    <div def="fragment1"></div>
  </template>

</my-element>


and in JS:

customElements.define('my-element', class extends HTMLElement {
  connectedCallback() {
    const { value: divElement } = this.import('foo#fragment1'); // the local module: foo#fragment1
    this.shadowRoot.appendChild(divElement.cloneNode());
  }
});

Cool Yet?

This is something you may find really interesting! You'll find that there's a whole lot that a declarative define-and-use system in HTML can change in your daily workflow!

Simply grab the OOHTML polyfill and game on with some of the examples!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

Once you're comfortable with the basics, you may also want to visit the rest of the story in the HTML Imports section in the project README!

And as before, we'd like for you to share your thoughts and leave us a star 🌟!


Question 4: How Do You Pass Data/Props?

In a hierarchy of components, parent components often need to pass data down to child components or, in some scenarios, just expose certain data for consumption by anyone, including child components!

On the first scenario, frameworks that do string concatenation for rendering, e.g. Lit, often let you do parent-to-child data-passing via markup/data interpolation, and everything together is parsed into what becomes the DOM:

// Example in Lit
render() {
  return html`
    <h1>Parent Component</h1>
    <!-- use the dot (.) syntax to pass data as a property -->
    <child-component .data=${this.childData}></child-component>
  `; 
}
Enter fullscreen mode Exit fullscreen mode

By contrast, data-passing on an already constructed DOM tree is really what we're talking about when it comes to the DOM!

Interestingly, you could do something as simple as setting arbitrary properties directly on child elements from within a parent element:

// Inside a custom element
connectedCallback() {
  const child = this.querySelector('#child');
  child.data = this.childData;
}
Enter fullscreen mode Exit fullscreen mode

Only that this isn't very elegant, given that, again, we would be littering an element's root namespace with application data and potentially conflicting with native DOM properties and methods! This is to say, we need a better idea around here!

Introducing: The Bindings API

This is a simple, read/write, data object exposed on the document object and on DOM elements as a bindings property:

// Read
console.log(document.bindings); // {}
// Modify
document.bindings.app = { title: 'Demo App' };
console.log(document.bindings.app); // { title: 'Demo App' }
Enter fullscreen mode Exit fullscreen mode
const node = document.querySelector('div');
// Read
console.log(node.bindings); // {}
// Modify
node.bindings.style = 'tall-dark';
node.bindings.normalize = true;
Enter fullscreen mode Exit fullscreen mode

This is the same Bindings API we introduced in the data-binding section! But it happens to be designed for arbitrary state management - which fits right in with our case:

// Notice our use of the Bindings API 
connectedCallback() {
  const child = this.querySelector('#child');
  child.bindings.data = this.bindings.childData;
}
Enter fullscreen mode Exit fullscreen mode

And above, if we went further to take advantage of the namespace API, we'd be able to access the said child elements declaratively, as against having to imperatively query the DOM:

// Notice our use of the Namespace API 
connectedCallback() {
  this.namespace.child.bind(this.bindings.childData);
}
Enter fullscreen mode Exit fullscreen mode

or...

// We could destructure the above for a more beautiful code
connectedCallback() {
  let { child } = this.namespace;
  let { childData } = this.bindings;
  child.bind(childData);
}
Enter fullscreen mode Exit fullscreen mode

Now as a perk, we get reactivity for free on all of the moving parts above: a way to observe when this.bindings.childData changes and a way to observe when this.namespace.child changes:

// Notice the observers
connectedCallback() {
  const bind = (child, data) => child.bind(data);
  // bind current values
  bind(this.namespace.child, this.bindings.childData);
  // Bind new values on change
  Observer.observe(this.bindings, 'childData', (e) => bind(this.namespace.child, e.value));
  Observer.observe(this.namespace, 'child', (e) => bind(e.value, this.bindings.childData));
}
Enter fullscreen mode Exit fullscreen mode

Better yet, we are able to have that same reactivity happen declaratively if we implemented our connectedCallback() method as quantum function:

// Notice the double star
quantum connectedCallback() {
  let { child } = this.namespace;
  let { childData } = this.bindings;
  child.bind(childData);
}
Enter fullscreen mode Exit fullscreen mode

And when not dealing with class instances, we are able to achieve the same logic, and same reactivity, using a scoped, quantum script:

<div namespace>

  <div id="child"></div>

  <script scoped quantum>
    let { child } = this.namespace;
    let { childData } = this.bindings;
    child.bind(childData);
  </script>

</div>
Enter fullscreen mode Exit fullscreen mode

Good Job There?

You'll find that it makes it easier to reason about state management in the DOM, and provides a better way to write Web Components!

Simply include the OOHTML polyfill from a CDN and game on!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

And here's one take-home idea for a Single Page Application: the code below, wherein on each navigation to a URL, data is programmatically fetched (via fetch()), then JSONed to a JavaScript object and held as global state at document.bindings (which you're able to directly render anywhere in the page):

<!DOCTYPE html>
<html>

  <head>
    <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
    <script>
      async function route() {
        const data = await fetch(`http://localhost:3000/api/${ location.hash.substring(1) }`).then(response => response.json());
        document.bind({ pageTitle: data.pageTitle });
      }
      window.addEventListener('hashchange', route);
    </script>
  </head>

  <body>
    <h1>Page title is: <?{ pageTitle }?>.</h1>
  </body>

</html>
Enter fullscreen mode Exit fullscreen mode

(Now, you may fuse that with the "Single Page Application" example in the list of examples to implement individual page layouts for each URL!)

Maybe you'll have some thoughts to share!

You may also want to visit the Bindings API section on the project README to learn more! And do remember to leave us a star 🌟!

Introducing: The Context API

While we're able to explicitly bind data on DOM nodes using the Bindings API, giving parents a way to pass data down to child components, sometimes the goal is to just expose certain data at a certain level in the DOM tree for consumption by anyone, including child components! This time, instead of the below:

// Inside "parent" component
connectedCallback() {
  let { child } = this.namespace;
  let { childData } = this.bindings;
  child.bind(childData);
}
Enter fullscreen mode Exit fullscreen mode

we now want "child" to request said data from "context":

// Inside "child" component
connectedCallback() {
  let parent = this.parentNode;
  let { childData } = parent.bindings;
  this.bind(childData);
}
Enter fullscreen mode Exit fullscreen mode

much like an inversion of control!

Now, while we've used the immediate parent node for our data source above, it is sometimes impossible to predetermine the exact location up the tree to find said data, in which case, the idea of walking up the DOM tree mechanically - this.parentNode.parentNode... - not only creates a tight coupling between components, but also fails quickly!

That's where a Context API comes in!

You'd find the same philosophy with React, and something similar with Lit, and perhaps something of same sort with other frameworks!

In our case, we simply leverage the DOM's existing event system to fire a "request" event and let an arbitrary "provider" in context fulfil the request. All of this is made available via an API named contexts, exposed on the document object and on DOM elements!

Here, we get a 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!
Enter fullscreen mode Exit fullscreen mode

and a 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);
Enter fullscreen mode Exit fullscreen mode

And everything comes as one standardized API for looking up the document context for any use case! In fact, the Context API is integral to the Namespace API and the Data Binding and HTML Imports features in OOHTML!

Now, we are able to use the Context API to retrieve the said "binding" in our parent-child scenario earlier:

// Inside "child" component
connectedCallback() {
  const requestParams = { kind: 'data-binding', detail: 'childData' };
  let { value: childData } = this.contexts.request(requestParams);
  this.bind(childData);
}
Enter fullscreen mode Exit fullscreen mode

Exciting Yet?

This is covered in detail in the Context API section on the project README!

It goes without saying that this too is possible today using the OOHTML polyfill!

Polyfill
<head>
  <script src="https://unpkg.com/@webqit/oohtml/dist/main.lite.js"></script>
  <!-- other code -->
</head>
Enter fullscreen mode Exit fullscreen mode

Take that as an invitation to explore and share your thoughts! And we'll be more than delighted to have you leave us a star 🌟!

That's now a wrap!


But that's probably a lot to unpack! Well, think of OOHTML as not one, but a suite of small, interrelated proposals with one agenda: towards a more dynamic, object-oriented HTML! While features may be discussed or explored individually, the one agenda helps us stay aligned with the original problem!

As with everything on the web, your contribution is how this gets better!

The polyfill is actively developed and kept in sync with the spec. You'll find that it goes a long way to help us not think in a vacuum! And it's gone as far as help us build interesting internal apps! (And that's a go ahead to give yours a shot! Not to mean that there aren't bugs there waiting to be discovered!)

Finally, here we go:

GitHub logo webqit / oohtml

Towards a more dynamic and object-oriented HTML.

OOHTML

npm version bundle License

ExplainerFeaturesModular HTMLHTML ImportsData BindingData PlumbingImplementationExamplesLicense

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?)

Status

Implementation

OOHTML may be used today. This implementation adheres closely to the spec and helps evolve the proposal through a practice-driven process.

💖 💪 🙅 🚩
oxharris
Oxford Harrison

Posted on January 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related