@surface/htmlx
TypeScript icon, indicating that this package has built-in type declarations

1.0.0-dev.202301061616 • Public • Published

Summary

Introduction

Web Components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in web pages and web apps. Custom components and widgets build on the Web Component standards, will work across modern browsers, and can be used with any JavaScript library or framework that works with HTML.

However, the technology still lacks some important features presents on everyday workflow.

@surface/htmlx aims fill this gap adding the ability to use directives and data bindings within web components templates enabling the creation of more complex components with less effort.

Compiling

Note that it is not recommended to use the runtime compiler for more serious work. Consider using the webpack with @surface/htmlx-loader.

import type { IDisposable } from "@surface/core";
import { Compiler }         from "@surface/htmlx";

const templateFactory = Compiler.compile("my-element", "<span>Hello {name}!!!</span>")

class MyComponent extends HTMLElement implements IDisposable
{
    private readonly disposable: IDisposable;

    public constructor()
    {
        super();

        this.attachShadow({ mode: "open" });

        const { content, activator } = templateFactory.create();

        this.shadowRoot!.appendChild(content);

        // Activate providing the root element, host element, scope object and custom directives map.
        this.disposable = activator(this.shadowRoot, this, { name: "World" }, new Map());
    }

    public dispose(): void
    {

        // Disposes template bindings
        this.disposable.dispose();
    }
}

window.HTMLXElements.define("my-element", MyComponent);

const myComponent = new MyComponent();

document.body.appendChild(myComponent);

/* ... */

// Always call dispose before discard processed element to prevent memory leak.
myComponent.dispose();

Template Syntax

Interpolation

Interpolation has the syntax "Some Text {expression}" and can be used in the text node or in the attributes.

<my-element title="Hello {host.display}">Hello {host.name}</my-element>

Bindings

Bindings support both one way and two way flow.

One Way

<my-element :message="'Hello ' + host.name"></my-element>

Two Way

<my-element ::message="host.message"></my-element>

Notices that two way data binding supports only static property member expressions.

Following example is not allowed.

<my-element ::message="host[key]"></my-element>

Events

Binded events are executed in the scope of the template as opposed to events passed by attributes that are executed in the global scope.

<!--self-bounded-handler-->
<my-element @click="host.clickHandler"></my-element>

<!--lambda-handler-->
<my-element @click="event => host.clickHandler(event)"></my-element>

<!--headerless-lambda-->
<my-element @click="host.toggle = !host.toggle"></my-element>
<!--desugared to-->
<my-element @click="() => host.toggle = !host.toggle"></my-element>

Class and Style

class and style properties has a special binding handlers.

class binding expects an object of type Record<string, boolean> where only truthy properties will be added to the class list.

<my-element :class="{ foo: true, bar: false }"></my-element>
<!--results-->
<my-element class="foo"></my-element>

style binding expects an object of type Record<string, string> where all properties will be converted to css properties.

<my-element :style="{ display: host.display /* flex */ }"></my-element>
<!--results-->
<my-element style="display: flex"></my-element>

Reactivity

The core of the binding system is reactivity that allows the ui keep sync with the data. HTMLx templates can evaluate almost any valid javascript expression (see more). But only properties can be observed and requires that observed properties to be configurable and not readonly.

By design, no error or warning will be fired when trying to use an non observable property in an expression. Except for two way binding higher members.

Example assuming that the scope contains variables called amount and item:

<span>The value is: {(host.value + item.value) * amount}</span>

The above expression only be reevaluated when the properties host.value or item.value changes since the variables like amount are not reactive.

Scopes

Reactivity depends on the scope which may vary according to the context.

Template Directives

Template Directives allows us to dynamically create content associated with local scopes.

Directives can be used with templates or elements.

<template #if="true">OK</template>
<span #else>NOT OK</span>

It can also be composed where the decomposition will follow the order of directives.

<span #if="host.items.length > 0" #for="item of host.items">{item.name}</span>
<span #else>No data available</span>
<!--decomposes-to-->
<template #if="host.items.length > 0">
    <template #for="item of host.items">
        <span>{item.name}</span>
    </template>
</template>
<template #else>
    <span>No data available</span>
</template>

Conditional

Conditional directive statement are well straightforward. If the expression evaluated is truthy, the template is inserted.

<span #if="host.value == 1">ONE</span>
<span #else-if="host.value == 2">TWO</span>
<span #else>OTHER</span>

Loop

The loop directive works similarly to its js counterpart. Also supporting "for in", "for of" and array and object destructuring.

<span #for="item of host.items">Name: {item.name}</span>

<span #for="index in host.items">Name: {host.items[index].name}</span>

<span #for="{ name } of host.items">Name: {name}</span>

<span #for="[key, value] of Object.entries(host.items)">{key}: {value}</span>

Placeholder and Injection

If you have already worked with a javascript framework then you should already be familiar with the concept of transclusion.

Transclusion means the inclusion of the content of one document within another document by reference.

Html5 already provides this through slots.

On surface/htmlx, templates additionally provide the ability to inject the client's templates into the component's shadowdom.

<!--my-element-->
<div class="card">
    <template #placeholder:header>
        <!-- Default content (optional) -->
        <span>{host.header}</span>
    </template>
</div>
<!--my-element/-->

<my-element>
    <span #inject:header>Custom Header</span>
</my-element>

Slots vs Placeholders

You might have thought that what would be possible to get the same result as above using slots.

You're right.

<!--my-element-->
<div class="card">
    <slot name="header">
        <span>{host.header}</span>
    </slot>
</div>
<!--my-element/-->

<my-element>
    <span slot="header">Custom Header</span>
</my-element>

The key difference here are scopes.

Something that Vue users are already familiar with.

Placeholders allow you to expose scopes that injections can use to customize the presentation.

<!--my-element-->
<div class="card">
    <span #placeholder:header="{ header: host.header }">{host.header}</span>
</div>
<!--my-element/-->

<my-element>
    <span #inject:header="scope">{scope.header}</span>
</my-element>
<!-- destructured also supported -->
<my-element>
    <span #inject:header="{ header }">{header}</span>
</my-element>

And, unlike slots, placeholders can instantiate the injected template many times as needed. Necessary for templating iterated data.

<!--my-element-->
<div class="card">
    <table>
        <tr #for="item of host.items" #placeholder:item="{ item }">
            <td>{item.name}</td>
        </tr>
    </table>
</div>
<!--my-element/-->

<my-element>
    <tr #inject:item="{ item }">
        <td>{item.name}</td>
    </tr>
</my-element>

Dynamic keys

#placeholder and #inject also supports dynamic keys using the syntax:

<span #placeholder.scope="scope" #placeholder.key="key"></span>
<span #inject.scope="scope" #inject.key="key"></span>

Useful to elaborate more complex scenarios.

<!--my-element-->
<table>
    <th #for="header of host.headers">
        <template #placeholder.scope="{ header }" #placeholder.key="`header.${header}`">{header}</template>
    </th>
    <tr #for="item of host.items">
        <td #for="header of host.headers">
            <template #placeholder.scope="{ value: item[header] }" #placeholder.key="`item.${header}`">{item.name}</template>
        </td>
    </tr>
</table>
<!--my-element/-->

<my-element :headers="['id', 'name']">
    <!--headers-->
    <template #inject:header.id="{ header }"><b>{header}</b></template>
    <template #inject:header.name="{ header }">{header}</template>
    <!--columns-->
    <template #inject:item.id="{ value }"><b>{value}</b></template>
    <template #inject:item.name="{ value }">{value}</template>
</my-element>

Styling injections

How said before. The injected templates are placed inside the shadowdom. Therefore, they are not affected by external CSS rules unless the css parts of the element are specified.

my-element::part(header)
{
    color: red;
}
<!--my-element-->
<template #placeholder:header>
    <div>Title</div>
</template>
<!--my-element/-->

<my-element>
    <template #inject:header>
        <span part="header">Custom Title</span>
    </template>
</my-element>

High order Component

Sometimes we need to take some third party component and apply some defaults to allow code reuse.

<!--third-party-element-->
    <span #placeholder:title="{ title: host.title }">{host.title}</span>
    <span #placeholder:content="{ content: host.content }">{host.content}</span>
<!--third-party-element/-->

This can be archived by wrapping the component and propagating host bindings to it.

<!--my-extended-element-->
    <third-party-element dark="host.darkAttribute" :title="host.title" :content="host.content" @click="host.dispatchEventClick">
        <span #inject:title="scope" #placeholder:title="scope">Default: {scope.title}</span>
        <span #inject:content="scope" #placeholder:content="scope">Default: {scope.content}</span>
    </third-party-element>
<!--my-extended-element/-->

That's fine for small components, but it can be a lot of work for large ones.

Fortunately, this can also be archived using the spread directive, which allows us to spread some directives from any source to the target element. And combined with host.$injections allows us forward injections from host to child elements.

<!--my-extended-element-->
    <my-element ...attributes|properties|listeners="host"></my-element>
    <!--or-->
    <my-element
        ...attributes="host.element1"
        ...properties="host.element2"
        ...listeners="host.element3"
    >
        <template
            #for="key of host.$injections"
            #inject.key="key"
            #inject.scope="scope"
            #placeholder.key="key"
            #placeholder.scope="scope"
        >
        </template>
    </my-element>
<!--my-extended-element/-->

Awaiting painting

Sometimes you may need to access some interface element that can be dynamically rendered as some data changes.

import { Compiler, painting } from "@surface/htmlx";

const template =
`
    <table>
        <tr #for="item of host.items">
            <td>{item.name}</td>
        </tr>
    </table>
`;

const templateFactory = Compiler.compile("my-element", template)

class MyComponent extends HTMLElement
{
    public items: string[] = [];

    public constructor()
    {
        super();

        this.attachShadow({ mode: "open" });

        const { content, activator } = templateFactory.create();

        this.shadowRoot!.appendChild(content);

        // Activate providing the root element, host element, scope object and custom directives map.
        this.disposable = activator(this.shadowRoot, this, { host: this }, new Map());
    }

    public dispose(): void
    {

        // Disposes template bindings
        this.disposable.dispose();
    }

    public changeData(): void
    {
        this.items = ["One", "Two", "Three"];

        const table = this.shadowRoot.querySelector("table");

        console.log(table.rows.length) // expected 3, but logged 0;
    }
}

When the data changes, all associated ui updated is scheduled and executed asynchronously. Therefore, it is necessary to wait for the execution of all updates before accessing the element.

This can be done awaiting the promise returned by the painting function.

import { Compiler, painting } from "@surface/htmlx";

const template =
`
    <table>
        <tr #for="item of host.items">
            <td>{item.name}</td>
        </tr>
    </table>
`;

const templateFactory = Compiler.compile("my-element", template)

class MyComponent extends HTMLElement
{
    public items: string[] = [];

    public constructor()
    {
        super();

        this.attachShadow({ mode: "open" });

        const { content, activator } = templateFactory.create();

        this.shadowRoot!.appendChild(content);

        // Activate providing the root element, host element, scope object and custom directives map.
        this.disposable = activator(this.shadowRoot, this, { host: this }, new Map());
    }

    public dispose(): void
    {

        // Disposes template bindings
        this.disposable.dispose();
    }

    public async changeData(): Promise<void>
    {
        this.items = ["One", "Two", "Three"];

        await painting();

        const table = this.shadowRoot.querySelector("table");

        console.log(table.rows.length) // logged 3;
    }
}

Custom Directives

Custom directives enables behaviors without a need to dive into the elements internals. It requires extending the Directive class and registering using HTMLXElement.registerDirective on global scope or element scope through @element decorator.

import type { DirectiveContext, DirectiveEntry } from "@surface/htmlx";
import { Compiler, Directive }                   from "@surface/htmlx";
import HTMLXElement { Directive }                from "@surface/htmlx-element";

const customDirectives: Map<string, DirectiveEntry> = new Map();

class ShowDirective extends Directive
{
    private display: string;

    public constructor(context: DirectiveContext)
    {
        super(context);

        this.display = context.element.style.display;
    }

    protected onValueChange(value: boolean): void
    {
        this.context.element.style.display = value ? this.display : "none";
    }
}

customDirectives.set("show", ShowDirective);

const template =
`
    <span #show="host.show">
        Show if host.show is true
    </span>

    <span #el-show="host.show">
        Show if host.show is true
    </span>
`;

const templateFactory = Compiler.compile("my-element", template)

class MyComponent extends HTMLElement implements IDisposable
{
    private readonly disposable: IDisposable;

    public constructor()
    {
        super();

        this.attachShadow({ mode: "open" });

        const { content, activator } = templateFactory.create();

        this.shadowRoot!.appendChild(content);

        // Activate providing the root element, host element, scope object and custom directives map.
        this.disposable = activator(this.shadowRoot, this, { name: "World" }, customDirectives);
    }

    public dispose(): void
    {

        // Disposes template bindings
        this.disposable.dispose();
    }
}

Considerations

Although most features can be used in any pattern. Directives like placeholder and injection rely heavily on the relationship between shadow dom and light dom.

Package Sidebar

Install

npm i @surface/htmlx

Weekly Downloads

0

Version

1.0.0-dev.202301061616

License

MIT

Unpacked Size

97.2 kB

Total Files

90

Last publish

Collaborators

  • hitalloexiled